diff --git a/.gitignore b/.gitignore index c7a9a9da3a1..0bfbb0e5e84 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,10 @@ docs/reference/react-performance-audit.md validation-screenshots/ .stably-browser +# PR verification evidence screenshots are referenced from notes but should not +# be committed. +notes/artifacts/ + # Playwright test-results/ playwright-report/ diff --git a/src/cli/args.ts b/src/cli/args.ts index a114e5e1121..64a604ae46e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -127,6 +127,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if ( [ 'automations', + 'project', 'repo', 'worktree', 'terminal', @@ -156,6 +157,7 @@ export function isCommandGroup(commandPath: string[]): boolean { (commandPath.length === 1 && [ 'automations', + 'project', 'repo', 'worktree', 'terminal', diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index ab517e126d6..8186872d2f8 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { CORE_HANDLERS } from './handlers/core' import { AUTOMATION_HANDLERS } from './handlers/automations' +import { PROJECT_HANDLERS } from './handlers/project' import { REPO_HANDLERS } from './handlers/repo' import { WORKTREE_HANDLERS } from './handlers/worktree' import { FILE_HANDLERS } from './handlers/file' @@ -37,6 +38,7 @@ function buildHandlers(): Map { const groups = [ CORE_HANDLERS, AUTOMATION_HANDLERS, + PROJECT_HANDLERS, REPO_HANDLERS, WORKTREE_HANDLERS, FILE_HANDLERS, diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 3d6c9df9aea..cd614976015 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -6,6 +6,7 @@ import { quoteCliCommandArgument } from './shell-command-quote' import { RuntimeRpcFailureError } from './runtime-client' import { formatCliError, + formatAutomationShow, formatComputerAction, formatGetAppState, formatTerminalRead, @@ -13,6 +14,7 @@ import { printResult } from './format' import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { Automation } from '../shared/automations-types' let testScreenshotDir: string | null = null @@ -140,6 +142,59 @@ describe('formatWorktreeList', () => { }) }) +describe('formatAutomationShow', () => { + function automation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-legacy', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } + } + + it('shows explicit run context before the legacy repo id', () => { + const output = formatAutomationShow({ + automation: automation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + } + }) + }) + + expect(output).toContain('runProjectId: github:stablyai/orca') + expect(output).toContain('runHostId: runtime:gpu') + expect(output).toContain('projectHostSetupId: setup-gpu') + expect(output).toContain('runRepoId: repo-gpu') + expect(output).toContain('runPath: /srv/orca') + expect(output).toContain('legacyRepoId: repo-legacy') + expect(output).not.toContain('projectId: repo-legacy') + }) +}) + describe('formatTerminalRead', () => { it('warns limited cursor reads to continue with the next cursor', () => { const output = formatTerminalRead({ diff --git a/src/cli/format.ts b/src/cli/format.ts index 500c5ca55d0..e6b751d42eb 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -22,6 +22,14 @@ export { formatListWindows } from './computer-format' export type { ComputerActionFollowUpTarget } from './computer-format' +export { + formatProjectHostSetupCreateResult, + formatProjectHostSetupDeleteResult, + formatProjectHostSetupList, + formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, + formatProjectList +} from './project-format' export { formatTerminalClose, formatTerminalCreate, diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index be09b4060e3..cbbe7517a06 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -7,7 +7,13 @@ import type { AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' -import type { TuiAgent } from '../../shared/types' +import { + buildWorkspaceRunContext, + normalizeTaskSourceContext, + type TaskSourceContext, + type WorkspaceRunContext +} from '../../shared/task-source-context' +import type { ProjectHostSetup, TuiAgent } from '../../shared/types' import { DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS @@ -30,6 +36,11 @@ import { } from '../flags' import { RuntimeClientError } from '../runtime-client' import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' +import { + assertWorkspaceTargetFlagsCompatible, + hasWorkspaceProjectTarget, + resolveProjectCreateTarget +} from '../worktree-project-target' type AutomationCreateParams = Omit & { repo?: string @@ -270,6 +281,46 @@ function getPrecheckFlag( } } +function getSourceContextFlag( + flags: Map +): TaskSourceContext | null | undefined { + if (!flags.has('source-context')) { + return undefined + } + const value = flags.get('source-context') + if (typeof value !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context requires a JSON TaskSourceContext or null' + ) + } + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') + } + if (parsed === null) { + return null + } + if (!parsed || typeof parsed !== 'object') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context must be a JSON TaskSourceContext or null' + ) + } + const sourceContext = normalizeTaskSourceContext( + parsed as Parameters[0] + ) + if (!sourceContext) { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context is not a valid TaskSourceContext' + ) + } + return sourceContext +} + function getWorkspaceModeFlag( flags: Map ): 'existing' | 'new_per_run' | undefined { @@ -293,11 +344,25 @@ async function resolveDefaultTarget( flags: Map, cwd: string, client: Parameters[0]['client'] -): Promise<{ repo?: string; workspace?: string }> { +): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> { + assertWorkspaceTargetFlagsCompatible(flags) const repo = getOptionalStringFlag(flags, 'repo') if (repo && getOptionalStringFlag(flags, 'workspace')) { throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') } + if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --workspace or project target flags, not both.' + ) + } + const projectTarget = await resolveProjectCreateTarget(flags, client) + if (projectTarget) { + return { + repo: projectTarget.repoSelector, + runContext: buildAutomationRunContextFromSetup(projectTarget.setup) + } + } const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) if (repo || workspace) { return { repo, workspace } @@ -316,15 +381,46 @@ async function getExplicitTarget( flags: Map, cwd: string, client: Parameters[0]['client'] -): Promise<{ repo?: string; workspace?: string }> { +): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> { + assertWorkspaceTargetFlagsCompatible(flags) const repo = getOptionalStringFlag(flags, 'repo') if (repo && getOptionalStringFlag(flags, 'workspace')) { throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') } + if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --workspace or project target flags, not both.' + ) + } + const projectTarget = await resolveProjectCreateTarget(flags, client) + if (projectTarget) { + return { + repo: projectTarget.repoSelector, + runContext: buildAutomationRunContextFromSetup(projectTarget.setup) + } + } const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) return { repo, workspace } } +function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext { + const runContext = buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }) + if (!runContext) { + throw new RuntimeClientError( + 'invalid_argument', + `Project host setup is missing automation run context fields: ${setup.id}` + ) + } + return runContext +} + export const AUTOMATION_HANDLERS: Record = { 'automations list': async ({ client, json }) => { const result = await client.call<{ automations: Automation[] }>('automation.list') @@ -342,6 +438,7 @@ export const AUTOMATION_HANDLERS: Record = { throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') } const target = await resolveDefaultTarget(flags, cwd, client) + const sourceContext = getSourceContextFlag(flags) const workspaceMode = getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run') const result = await client.call<{ automation: Automation }>('automation.create', { @@ -349,6 +446,8 @@ export const AUTOMATION_HANDLERS: Record = { prompt: getRequiredStringFlag(flags, 'prompt'), precheck: getPrecheckFlag(flags), agentId: getProviderFlag(flags), + ...(target.runContext ? { runContext: target.runContext } : {}), + ...(sourceContext !== undefined ? { sourceContext } : {}), repo: target.repo, workspace: target.workspace, workspaceMode, @@ -364,6 +463,7 @@ export const AUTOMATION_HANDLERS: Record = { 'automations edit': async ({ flags, client, cwd, json }) => { const target = await getExplicitTarget(flags, cwd, client) const schedule = getScheduleFlag(flags, false) + const sourceContext = getSourceContextFlag(flags) const result = await client.call<{ automation: Automation }>('automation.update', { id: getRequiredStringFlag(flags, 'id'), updates: { @@ -371,6 +471,8 @@ export const AUTOMATION_HANDLERS: Record = { prompt: getOptionalStringFlag(flags, 'prompt'), precheck: getPrecheckFlag(flags), agentId: getOptionalProviderFlag(flags), + ...(target.runContext ? { runContext: target.runContext } : {}), + ...(sourceContext !== undefined ? { sourceContext } : {}), repo: target.repo, workspace: target.workspace, workspaceMode: getWorkspaceModeFlag(flags), diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts new file mode 100644 index 00000000000..cc5f32e2cd2 --- /dev/null +++ b/src/cli/handlers/project.ts @@ -0,0 +1,204 @@ +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + RepoKind +} from '../../shared/types' +import type { CommandHandler } from '../dispatch' +import { + formatProjectHostSetupCreateResult, + formatProjectHostSetupDeleteResult, + formatProjectHostSetupList, + formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, + formatProjectList, + printResult +} from '../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { resolveRepoPathArgument } from '../repo-path-arguments' +import { RuntimeClientError } from '../runtime-client' + +function getOptionalRepoKind(flags: Map): RepoKind | undefined { + const kind = getOptionalStringFlag(flags, 'kind') + if (kind === undefined) { + return undefined + } + if (kind === 'git' || kind === 'folder') { + return kind + } + throw new RuntimeClientError('invalid_argument', '--kind must be git or folder') +} + +export const PROJECT_HANDLERS: Record = { + 'project list': async ({ client, json }) => { + const result = await client.call<{ projects: Project[] }>('project.list') + printResult(result, json, formatProjectList) + }, + 'project setups': async ({ flags, client, json }) => { + const projectFilter = getOptionalStringFlag(flags, 'project') + const hostFilter = getOptionalStringFlag(flags, 'host') + const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + const setups = result.result.setups.filter( + (setup) => + (projectFilter === undefined || setup.projectId === projectFilter) && + (hostFilter === undefined || setup.hostId === hostFilter) + ) + printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList) + }, + 'project setup-existing-folder': async ({ flags, client, cwd, json }) => { + const rawPath = getRequiredStringFlag(flags, 'path') + const args: ProjectHostSetupExistingFolderArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'], + path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'), + kind: getOptionalRepoKind(flags), + displayName: getOptionalStringFlag(flags, 'display-name') + } + const result = await client.call<{ result: ProjectHostSetupResult }>( + 'projectHostSetup.setupExistingFolder', + args + ) + printResult(result, json, formatProjectHostSetupResult) + }, + 'project setup-clone': async ({ flags, client, cwd, json }) => { + const rawDestination = getRequiredStringFlag(flags, 'destination') + const args: ProjectHostSetupCloneArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'], + url: getRequiredStringFlag(flags, 'url'), + destination: resolveRepoPathArgument( + rawDestination, + cwd, + client.isRemote, + 'Project setup clone' + ), + displayName: getOptionalStringFlag(flags, 'display-name') + } + const result = await client.call<{ result: ProjectHostSetupResult }>( + 'projectHostSetup.clone', + args + ) + printResult(result, json, formatProjectHostSetupResult) + }, + 'project setup-create': async ({ flags, client, cwd, json }) => { + const path = getOptionalStringFlag(flags, 'path') + const args: ProjectHostSetupCreateArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'], + setupId: getOptionalStringFlag(flags, 'setup-id'), + path: + path === undefined + ? undefined + : resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup create'), + kind: getOptionalRepoKind(flags), + displayName: getOptionalStringFlag(flags, 'display-name'), + worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'), + gitUsername: getOptionalStringFlag(flags, 'git-username'), + setupState: getOptionalSetupState(flags), + setupMethod: getOptionalIndependentSetupMethod(flags) + } + const result = await client.call<{ result: ProjectHostSetupCreateResult }>( + 'projectHostSetup.create', + args + ) + printResult(result, json, formatProjectHostSetupCreateResult) + }, + 'project setup-update': async ({ flags, client, cwd, json }) => { + const path = getOptionalStringFlag(flags, 'path') + const args: ProjectHostSetupUpdateArgs = { + setupId: getRequiredStringFlag(flags, 'setup'), + updates: { + displayName: getOptionalStringFlag(flags, 'display-name'), + path: + path === undefined + ? undefined + : resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'), + worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'), + gitUsername: getOptionalStringFlag(flags, 'git-username'), + kind: getOptionalRepoKind(flags), + setupState: getOptionalSetupState(flags), + setupMethod: getOptionalSetupMethod(flags) + } + } + const result = await client.call<{ result: ProjectHostSetupUpdateResult }>( + 'projectHostSetup.update', + args + ) + printResult(result, json, formatProjectHostSetupUpdateResult) + }, + 'project setup-delete': async ({ flags, client, json }) => { + const result = await client.call<{ result: ProjectHostSetupDeleteResult }>( + 'projectHostSetup.delete', + { + setupId: getRequiredStringFlag(flags, 'setup') + } + ) + printResult(result, json, formatProjectHostSetupDeleteResult) + } +} + +function getOptionalSetupState( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupState'] { + const state = getOptionalStringFlag(flags, 'state') + if (state === undefined) { + return undefined + } + if ( + state === 'ready' || + state === 'not-set-up' || + state === 'setting-up' || + state === 'error' || + state === 'unsupported' + ) { + return state + } + throw new RuntimeClientError( + 'invalid_argument', + '--state must be ready, not-set-up, setting-up, error, or unsupported' + ) +} + +function getOptionalIndependentSetupMethod( + flags: Map +): ProjectHostSetupCreateArgs['setupMethod'] { + const method = getOptionalStringFlag(flags, 'method') + if (method === undefined) { + return undefined + } + if (method === 'imported-existing-folder' || method === 'cloned' || method === 'provisioned') { + return method + } + throw new RuntimeClientError( + 'invalid_argument', + '--method must be imported-existing-folder, cloned, or provisioned' + ) +} + +function getOptionalSetupMethod( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupMethod'] { + const method = getOptionalStringFlag(flags, 'method') + if (method === undefined) { + return undefined + } + if ( + method === 'legacy-repo' || + method === 'imported-existing-folder' || + method === 'cloned' || + method === 'provisioned' + ) { + return method + } + throw new RuntimeClientError( + 'invalid_argument', + '--method must be legacy-repo, imported-existing-folder, cloned, or provisioned' + ) +} diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts index fc4c9f22809..e0432cc9bc6 100644 --- a/src/cli/handlers/repo.ts +++ b/src/cli/handlers/repo.ts @@ -1,33 +1,8 @@ -import { resolve as resolvePath } from 'path' import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format' import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' -import { RuntimeClientError } from '../runtime-client' - -function isAbsoluteServerPath(value: string): boolean { - return ( - value.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(value) || - value.startsWith('\\\\') || - value.startsWith('//') - ) -} - -function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string { - if (!isRemote) { - return resolvePath(cwd, inputPath) - } - // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. - // Relative remote paths would silently target the wrong machine. - if (!isAbsoluteServerPath(inputPath)) { - throw new RuntimeClientError( - 'invalid_argument', - 'Remote repo add requires --path to be an absolute path on the remote server.' - ) - } - return inputPath -} +import { resolveRepoPathArgument } from '../repo-path-arguments' export const REPO_HANDLERS: Record = { 'repo list': async ({ client, json }) => { @@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record = { 'repo add': async ({ flags, client, cwd, json }) => { const repoPath = getRequiredStringFlag(flags, 'path') const result = await client.call<{ repo: Record }>('repo.add', { - path: resolveRepoAddPath(repoPath, cwd, client.isRemote) + path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add') }) printResult(result, json, formatRepoShow) }, diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 1ee5a3b37d4..009ce6c7cc4 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -21,6 +21,11 @@ import { resolveCurrentWorktreeSelector } from '../selectors' import { isTuiAgent } from '../../shared/tui-agent-config' +import { + assertWorkspaceTargetFlagsCompatible, + hasWorkspaceProjectTarget, + resolveProjectCreateRepoSelector +} from '../worktree-project-target' import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' type HookWarningResult = { @@ -149,10 +154,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri return `id:${worktreeId.slice(0, separatorIndex)}` } -function getCreateRepoSelector( +async function getCreateRepoSelector( flags: Map, - cwdParentWorktree: string | undefined -): string { + cwdParentWorktree: string | undefined, + client: Parameters[0]['client'] +): Promise { + const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client) + if (projectRepoSelector) { + return projectRepoSelector + } const explicitRepo = getPresentStringFlag(flags, 'repo') if (explicitRepo) { return explicitRepo @@ -195,6 +205,7 @@ export const WORKTREE_HANDLERS: Record = { }, 'worktree create': async ({ flags, client, cwd, json }) => { assertParentFlagsCompatible(flags) + assertWorkspaceTargetFlagsCompatible(flags) const callerTerminalHandle = typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && process.env.ORCA_TERMINAL_HANDLE.length > 0 @@ -210,7 +221,8 @@ export const WORKTREE_HANDLERS: Record = { const setupDecision = getOptionalSetupDecision(flags) const noParent = flags.get('no-parent') === true let cwdParentWorktree: string | undefined - if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) { + const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags) + if ((!explicitParentWorktree && !noParent) || needsCwdRepoInference) { try { // Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running // inside an Orca worktree. Cwd keeps CLI-created children nestable and @@ -222,7 +234,7 @@ export const WORKTREE_HANDLERS: Record = { } const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') const result = await client.call('worktree.create', { - repo: getCreateRepoSelector(flags, cwdParentWorktree), + repo: await getCreateRepoSelector(flags, cwdParentWorktree, client), name: getRequiredStringFlag(flags, 'name'), baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), diff --git a/src/cli/help.ts b/src/cli/help.ts index 6445ac2458e..a11293db900 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -29,6 +29,15 @@ Automations: automations run Run an Orca automation now automations runs List automation run history +Projects: + project list List durable projects known to Orca + project setups List project host setups + project setup-existing-folder Make a project available on a host by importing an existing folder + project setup-clone Make a project available on a host by cloning a repository + project setup-create Create independent project host setup metadata + project setup-update Update project host setup metadata + project setup-delete Remove a project host setup + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -185,7 +194,7 @@ Common Commands: orca environment show --environment [--json] orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] + orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] @@ -204,6 +213,13 @@ Common Commands: orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] orca terminal close [--terminal ] [--json] + orca project list [--json] + orca project setups [--project ] [--host ] [--json] + orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] + orca project setup-clone --project --host --url --destination [--display-name ] [--json] + orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json] + orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json] + orca project setup-delete --setup [--json] orca repo list [--json] orca repo add --path [--json] orca repo show --repo [--json] @@ -486,6 +502,8 @@ export function formatFlagHelp(flag: string): string { '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', staged: '--staged Open staged source-control changes', provider: '--provider Agent id such as codex, claude, or gemini', + 'source-context': + '--source-context Explicit TaskSourceContext for automation task/provider data', trigger: '--trigger Automation schedule preset, cron, or RRULE', schedule: '--schedule Alias for --trigger', time: '--time Time used with daily/weekdays/weekly presets', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 002fe513005..1f9d798f135 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -141,6 +141,18 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'computer press-key Press a single key such as Return or Escape' ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-existing-folder Make a project available on a host by importing an existing folder' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-create Create independent project host setup metadata' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-update Update project host setup metadata' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-delete Remove a project host setup' + ) expect(callMock).not.toHaveBeenCalled() }) @@ -154,7 +166,6 @@ describe('orca root help', () => { expect(rootHelp).toContain('linear Read Linear ticket context for agents') expect(rootHelp).not.toContain('linear issue') expect(rootHelp).not.toContain('linear search') - expect(rootHelp).not.toContain('linear status set') logSpy.mockClear() await main(['linear', '--help'], '/tmp/repo') @@ -163,10 +174,6 @@ describe('orca root help', () => { expect(groupHelp).toContain('orca linear') expect(groupHelp).toContain('issue') expect(groupHelp).toContain('search') - expect(groupHelp).toContain('status set') - expect(groupHelp).toContain('comment add') - expect(groupHelp).toContain('attach') - expect(groupHelp).toContain('create') expect(groupHelp).not.toContain('--comments') expect(groupHelp).not.toContain('--attachments') @@ -187,21 +194,6 @@ describe('orca root help', () => { expect(searchHelp).toContain('orca linear search ') expect(searchHelp).toContain('--workspace Connected Linear workspace id, or all') expect(searchHelp).toContain('--query Text to search across Linear issues') - - logSpy.mockClear() - await main(['linear', 'comment', 'add', '--help'], '/tmp/repo') - - const commentHelp = String(logSpy.mock.calls[0][0]) - expect(commentHelp).toContain('orca linear comment add []') - expect(commentHelp).toContain('--body-file Read Linear body from a file or stdin') - expect(commentHelp).toContain('--write-id Retry id from linear_write_unconfirmed') - - logSpy.mockClear() - await main(['linear', 'create', '--help'], '/tmp/repo') - - const createHelp = String(logSpy.mock.calls[0][0]) - expect(createHelp).toContain('orca linear create --title ') - expect(createHelp).toContain('--parent-current Use the current linked issue as parent') expect(callMock).not.toHaveBeenCalled() }) @@ -325,7 +317,9 @@ describe('orca cli worktree awareness', () => { await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src') - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { worktree: 'id:repo::/tmp/repo/feature' }) @@ -1005,6 +999,153 @@ describe('orca cli worktree awareness', () => { }) }) + it('resolves project and host flags to the matching repo for worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-local', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-local', + path: '/tmp/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-gpu', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }), + okFixture('req_create', { + worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--name', + 'feature', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { + repo: 'id:repo-gpu', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + noParent: true, + callerTerminalHandle: undefined + }) + }) + + it('resolves project-host-setup directly for worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-gpu', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }), + okFixture('req_create', { + worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--project-host-setup', + 'setup-gpu', + '--name', + 'feature', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'worktree.create', + expect.objectContaining({ repo: 'id:repo-gpu' }) + ) + }) + + it('rejects mixing repo and project target flags on worktree.create', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-local', + '--project', + 'github:stablyai/orca', + '--name', + 'feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Choose either --repo or project target flags, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('passes an explicit parent through worktree.create without cwd inference', async () => { queueFixtures( callMock, @@ -1368,6 +1509,174 @@ describe('orca cli worktree awareness', () => { }) }) + it('lists projects through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_list', { + projects: [ + { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + }, + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'list', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('project.list') + }) + + it('filters project host setups locally after fetching setup compatibility state', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-local', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-local', + path: '/tmp/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: 'setup-remote', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-remote', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.list') + expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local') + }) + + it('sets up an existing project folder with a path resolved against the local cli cwd', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-local', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + path: path.resolve('/tmp/orca'), + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + }, + repo: { + id: 'repo-1', + path: path.resolve('/tmp/orca'), + displayName: 'Orca', + badgeColor: '#7c3aed', + addedAt: 1 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-existing-folder', + '--project', + 'github:stablyai/orca', + '--host', + 'local', + '--path', + '..', + '--kind', + 'git', + '--display-name', + 'Orca', + '--json' + ], + '/tmp/orca/worktrees/feature' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', { + projectId: 'github:stablyai/orca', + hostId: 'local', + path: path.resolve('/tmp/orca/worktrees'), + kind: 'git', + displayName: 'Orca' + }) + }) + + it('rejects remote project setup relative paths instead of resolving against client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'project', + 'setup-existing-folder', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--path', + './orca', + '--pairing-code', + 'remote-runtime', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote project setup requires --path to be an absolute path on the remote server.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -2490,7 +2799,9 @@ describe('orca cli worktree awareness', () => { await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src') expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { + worktree: undefined + }) }) it('passes emulator gesture points through to the runtime', async () => { @@ -2626,7 +2937,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', @@ -2644,6 +2957,243 @@ describe('orca cli worktree awareness', () => { }) }) + it('resolves project and host flags for automation create', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-local', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-local', + path: '/tmp/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-gpu', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }), + okFixture('req_automation_create', { + automation: { id: 'auto-1', name: 'GPU review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'GPU review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'automation.create', + expect.objectContaining({ + repo: 'id:repo-gpu', + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + }, + workspace: undefined, + workspaceMode: 'new_per_run' + }) + ) + }) + + it('resolves project-host-setup flags for automation edit with explicit run context', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-gpu', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }), + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'GPU review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['automations', 'edit', 'auto-1', '--project-host-setup', 'setup-gpu', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'automation.update', + expect.objectContaining({ + id: 'auto-1', + updates: expect.objectContaining({ + repo: 'id:repo-gpu', + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + } + }) + }) + ) + }) + + it('passes automation source context JSON through create', async () => { + const sourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'gpu-bot' + } + queueFixtures( + callMock, + okFixture('req_automation_create', { + automation: { id: 'auto-1', name: 'GPU task review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'GPU task review', + '--trigger', + 'daily', + '--prompt', + 'Review open work', + '--provider', + 'codex', + '--repo', + 'id:repo-gpu', + '--source-context', + JSON.stringify(sourceContext), + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'automation.create', + expect.objectContaining({ + repo: 'id:repo-gpu', + sourceContext + }) + ) + }) + + it('clears automation source context on edit with null', async () => { + queueFixtures( + callMock, + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'GPU task review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'automation.update', + expect.objectContaining({ + id: 'auto-1', + updates: expect.objectContaining({ + sourceContext: null + }) + }) + ) + }) + + it('rejects invalid automation source context JSON before calling the runtime', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'GPU task review', + '--trigger', + 'daily', + '--prompt', + 'Review open work', + '--provider', + 'codex', + '--repo', + 'id:repo-gpu', + '--source-context', + '{nope', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--source-context must be valid JSON' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('rejects invalid automation --day values before calling the runtime', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -2814,8 +3364,12 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }), - okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_create', { + automation: { id: 'auto-1', name: 'Daily review' } + }), + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2840,7 +3394,9 @@ describe('orca cli worktree awareness', () => { ) await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith( 2, 'automation.create', @@ -2904,8 +3460,16 @@ describe('orca cli worktree awareness', () => { }) it.each([ - { flag: 'enabled', value: 'false', message: '--enabled does not take a value' }, - { flag: 'disabled', value: 'false', message: '--disabled does not take a value' } + { + flag: 'enabled', + value: 'false', + message: '--enabled does not take a value' + }, + { + flag: 'disabled', + value: 'false', + message: '--disabled does not take a value' + } ])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -2943,7 +3507,9 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_automation_create', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2966,7 +3532,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', @@ -2987,7 +3555,9 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2996,7 +3566,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { id: 'auto-1', updates: { @@ -3064,9 +3636,15 @@ describe('orca cli worktree awareness', () => { missedRunGraceMinutes: undefined } }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { + id: 'auto-1' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { + id: 'auto-1' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { + id: 'auto-1' + }) }) it('rejects ambiguous positional and flag automation ids before dispatch', async () => { @@ -3088,4 +3666,171 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + + it('updates project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_update', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '/srv/orca', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-update', + '--setup', + 'setup-gpu', + '--display-name', + 'GPU VM', + '--path', + '/srv/orca', + '--worktree-base-path', + '../worktrees', + '--state', + 'ready', + '--method', + 'imported-existing-folder', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', { + setupId: 'setup-gpu', + updates: { + displayName: 'GPU VM', + path: path.resolve('/tmp/repo', '/srv/orca'), + worktreeBasePath: '../worktrees', + gitUsername: undefined, + kind: undefined, + setupState: 'ready', + setupMethod: 'imported-existing-folder' + } + }) + }) + + it('creates independent project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_create', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-create', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--setup-id', + 'setup-gpu', + '--display-name', + 'GPU VM', + '--state', + 'setting-up', + '--method', + 'provisioned', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.create', { + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + setupId: 'setup-gpu', + path: undefined, + kind: undefined, + displayName: 'GPU VM', + worktreeBasePath: undefined, + gitUsername: undefined, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + }) + + it('deletes project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_delete', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '/srv/orca', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { + setupId: 'setup-gpu' + }) + }) }) diff --git a/src/cli/project-format.ts b/src/cli/project-format.ts new file mode 100644 index 00000000000..d5569a0306b --- /dev/null +++ b/src/cli/project-format.ts @@ -0,0 +1,80 @@ +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteResult, + ProjectHostSetupResult, + ProjectHostSetupUpdateResult +} from '../shared/types' + +export function formatProjectList(result: { projects: Project[] }): string { + if (result.projects.length === 0) { + return 'No projects found.' + } + return result.projects + .map((project) => { + const identity = project.providerIdentity + ? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}` + : 'no-provider' + return `${project.id} ${project.displayName} ${identity}` + }) + .join('\n') +} + +export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string { + if (result.setups.length === 0) { + return 'No project host setups found.' + } + return result.setups + .map( + (setup) => + `${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}` + ) + .join('\n') +} + +export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string { + const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo.id) +} + +export function formatProjectHostSetupCreateResult(result: { + result: ProjectHostSetupCreateResult +}): string { + const { project, setup } = result.result + return formatProjectHostSetupResultFields(project, setup, undefined) +} + +export function formatProjectHostSetupUpdateResult(result: { + result: ProjectHostSetupUpdateResult +}): string { + const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo?.id) +} + +export function formatProjectHostSetupDeleteResult(result: { + result: ProjectHostSetupDeleteResult +}): string { + const { project, setup, repo } = result.result + return [ + `deleted: ${setup.id}`, + formatProjectHostSetupResultFields(project, setup, repo?.id) + ].join('\n') +} + +function formatProjectHostSetupResultFields( + project: Project, + setup: ProjectHostSetup, + repoId: string | undefined +): string { + return [ + `projectId: ${project.id}`, + `project: ${project.displayName}`, + `setupId: ${setup.id}`, + `hostId: ${setup.hostId}`, + `path: ${setup.path}`, + `state: ${setup.setupState}`, + `method: ${setup.setupMethod}`, + `repoId: ${repoId ?? 'none'}` + ].join('\n') +} diff --git a/src/cli/repo-path-arguments.ts b/src/cli/repo-path-arguments.ts new file mode 100644 index 00000000000..efc0148eff0 --- /dev/null +++ b/src/cli/repo-path-arguments.ts @@ -0,0 +1,31 @@ +import { resolve as resolvePath } from 'path' +import { RuntimeClientError } from './runtime-client' + +function isAbsoluteServerPath(value: string): boolean { + return ( + value.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith('\\\\') || + value.startsWith('//') + ) +} + +export function resolveRepoPathArgument( + inputPath: string, + cwd: string, + isRemote: boolean, + remotePathSubject = 'Remote repo path' +): string { + if (!isRemote) { + return resolvePath(cwd, inputPath) + } + // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. + // Relative remote paths would silently target the wrong machine. + if (!isAbsoluteServerPath(inputPath)) { + throw new RuntimeClientError( + 'invalid_argument', + `${remotePathSubject} requires --path to be an absolute path on the remote server.` + ) + } + return inputPath +} diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index ff3071a9372..e4d0b9640cc 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -1,7 +1,16 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' -const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch'] +const AUTOMATION_TARGET_FLAGS = [ + 'repo', + 'workspace', + 'project', + 'host', + 'project-host-setup', + 'source-context', + 'workspace-mode', + 'base-branch' +] const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone'] const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout'] const AUTOMATION_STATE_FLAGS = [ @@ -32,7 +41,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ path: ['automations', 'create'], summary: 'Create a scheduled Orca automation', usage: - 'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--json]', + 'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>|--project <id> [--host <id>]|--project-host-setup <id>] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'name', @@ -46,6 +55,8 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.', 'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.', + 'Use --project with --host, or --project-host-setup, to run on a specific project host setup.', + 'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.', 'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.', 'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.', 'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.' diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index e47c5512fa6..12484a38f4c 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -102,10 +102,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', + 'project', + 'host', + 'project-host-setup', 'name', 'agent', 'prompt', @@ -122,6 +125,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.', 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', + 'Use --project with --host to create on a ready project host setup without spelling the backing repo id.', 'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.', 'Use --no-parent when the new worktree should be independent of the current workspace.', 'By default this creates the worktree and its first terminal without switching the active Orca workspace.', @@ -133,6 +137,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ examples: [ 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id:<repoId> --name related-task --json', + 'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json', 'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', 'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json', diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index cf80d837bb5..8fb4e2fbd11 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { AUTOMATION_COMMAND_SPECS } from './automations' import { CORE_COMMAND_SPECS } from './core' import { FILE_COMMAND_SPECS } from './file' +import { PROJECT_COMMAND_SPECS } from './project' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' @@ -14,6 +15,7 @@ import { LINEAR_COMMAND_SPECS } from './linear' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...PROJECT_COMMAND_SPECS, ...FILE_COMMAND_SPECS, ...AUTOMATION_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts new file mode 100644 index 00000000000..f9070da9f40 --- /dev/null +++ b/src/cli/specs/project.ts @@ -0,0 +1,113 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['project', 'list'], + summary: 'List durable projects known to Orca', + usage: 'orca project list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca project list', 'orca project list --json'] + }, + { + path: ['project', 'setups'], + summary: 'List project host setups', + usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'], + notes: ['A setup means a project is available on a host at a concrete filesystem path.'], + examples: [ + 'orca project setups', + 'orca project setups --project github:stablyai/orca', + 'orca project setups --host local' + ] + }, + { + path: ['project', 'setup-existing-folder'], + summary: 'Make a project available on a host by importing an existing folder', + usage: + 'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], + notes: ['For remote runtimes, --path must be an absolute path on the remote server.'], + examples: [ + 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', + 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' + ] + }, + { + path: ['project', 'setup-clone'], + summary: 'Make a project available on a host by cloning a repository', + usage: + 'orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'], + notes: [ + 'For remote runtimes, --destination must be an absolute parent directory on the remote server.', + 'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.' + ], + examples: [ + 'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src', + 'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json' + ] + }, + { + path: ['project', 'setup-create'], + summary: 'Create independent project host setup metadata', + usage: + 'orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'project', + 'host', + 'setup-id', + 'path', + 'kind', + 'display-name', + 'worktree-base-path', + 'git-username', + 'state', + 'method' + ], + notes: [ + 'Creates setup metadata without registering a repo compatibility record.', + 'Use setup-existing-folder when Orca should import and manage an actual checkout path now.' + ], + examples: [ + 'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json' + ] + }, + { + path: ['project', 'setup-update'], + summary: 'Update project host setup metadata', + usage: + 'orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'setup', + 'display-name', + 'path', + 'worktree-base-path', + 'git-username', + 'kind', + 'state', + 'method' + ], + notes: [ + 'Repo-backed setups mirror safe fields onto the repo record.', + 'Path and availability state changes are only supported for independent setup records.' + ], + examples: [ + 'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"', + 'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json' + ] + }, + { + path: ['project', 'setup-delete'], + summary: 'Remove a project host setup', + usage: 'orca project setup-delete --setup <setup-id> [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup'], + notes: [ + 'Independent setups are removed directly.', + 'Repo-backed setups remove the registered repo compatibility record.' + ], + examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json'] + } +] diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 27d02d82891..7be9ba302f0 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -1,4 +1,5 @@ import type { Automation, AutomationRun } from '../shared/automations-types' +import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -178,6 +179,17 @@ export function formatAutomationList(result: { automations: Automation[] }): str export function formatAutomationShow(result: { automation: Automation }): string { const automation = result.automation + const runContext = automation.runContext ?? null + const projectLines = runContext + ? [ + `runProjectId: ${runContext.projectId}`, + `runHostId: ${runContext.hostId}`, + `projectHostSetupId: ${runContext.projectHostSetupId}`, + `runRepoId: ${runContext.repoId}`, + `runPath: ${runContext.path}`, + `legacyRepoId: ${getAutomationLegacyRepoId(automation)}` + ] + : [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`] return [ `id: ${automation.id}`, `name: ${automation.name}`, @@ -193,7 +205,7 @@ export function formatAutomationShow(result: { automation: Automation }): string : 'none' }`, `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, - `projectId: ${automation.projectId}`, + ...projectLines, `workspaceMode: ${automation.workspaceMode}`, `workspaceId: ${automation.workspaceId ?? 'null'}`, `baseBranch: ${automation.baseBranch ?? 'null'}`, diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts new file mode 100644 index 00000000000..6626270655a --- /dev/null +++ b/src/cli/worktree-project-target.ts @@ -0,0 +1,85 @@ +import type { ProjectHostSetup } from '../shared/types' +import type { RuntimeClient } from './runtime-client' +import { RuntimeClientError } from './runtime-client' + +export type ProjectCreateTarget = { + repoSelector: string + setup: ProjectHostSetup +} + +function getPresentStringFlag( + flags: Map<string, string | boolean>, + name: string +): string | undefined { + if (!flags.has(name)) { + return undefined + } + const value = flags.get(name) + if (typeof value === 'string' && value.length > 0) { + return value + } + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`) +} + +export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean { + return flags.has('project') || flags.has('host') || flags.has('project-host-setup') +} + +export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void { + const hasProjectTarget = hasWorkspaceProjectTarget(flags) + if (flags.has('repo') && hasProjectTarget) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose either --repo or project target flags, not both.' + ) + } + if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) { + throw new RuntimeClientError( + 'invalid_argument', + '--host requires --project unless --project-host-setup is provided.' + ) + } +} + +export async function resolveProjectCreateRepoSelector( + flags: Map<string, string | boolean>, + client: RuntimeClient +): Promise<string | undefined> { + return (await resolveProjectCreateTarget(flags, client))?.repoSelector +} + +export async function resolveProjectCreateTarget( + flags: Map<string, string | boolean>, + client: RuntimeClient +): Promise<ProjectCreateTarget | undefined> { + const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup') + const projectId = getPresentStringFlag(flags, 'project') + const hostId = getPresentStringFlag(flags, 'host') + if (!projectHostSetupId && !projectId && !hostId) { + return undefined + } + const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + const setup = result.result.setups.find((candidate) => { + if (candidate.setupState !== 'ready') { + return false + } + if (projectHostSetupId) { + return candidate.id === projectHostSetupId + } + return ( + candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId) + ) + }) + if (!setup) { + throw new RuntimeClientError( + 'invalid_argument', + projectHostSetupId + ? `Project host setup is not ready or was not found: ${projectHostSetupId}` + : `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}` + ) + } + return { + repoSelector: `id:${setup.repoId}`, + setup + } +} diff --git a/src/main/automations/headless-dispatch.ts b/src/main/automations/headless-dispatch.ts new file mode 100644 index 00000000000..1280ca5d0ae --- /dev/null +++ b/src/main/automations/headless-dispatch.ts @@ -0,0 +1,71 @@ +import type { + Automation, + AutomationRun, + AutomationRunOutputSnapshot +} from '../../shared/automations-types' +import type { AutomationRunTargetResult } from './run-target-resolution' + +const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 + +export type HeadlessAutomationDispatchLaunch = { + workspaceId: string + workspaceDisplayName?: string | null + terminalSessionId: string | null + completion?: Promise<{ + status: 'completed' | 'dispatch_failed' + outputSnapshot?: AutomationRunOutputSnapshot | null + error?: string | null + }> +} + +export type HeadlessAutomationDispatcher = (request: { + automation: Automation + run: AutomationRun + target: Extract<AutomationRunTargetResult, { ok: true }> +}) => Promise<HeadlessAutomationDispatchLaunch> + +export function createHeadlessAutomationOutputSnapshotBuffer(): { + append: (chunk: string) => void + snapshot: () => AutomationRunOutputSnapshot | null +} { + const chunks: string[] = [] + let totalChars = 0 + let truncated = false + + return { + append(chunk): void { + if (!chunk) { + return + } + chunks.push(chunk) + totalChars += chunk.length + let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS + while (overflowChars > 0 && chunks.length > 0) { + const firstChunk = chunks[0]! + if (firstChunk.length <= overflowChars) { + chunks.shift() + totalChars -= firstChunk.length + overflowChars -= firstChunk.length + truncated = true + continue + } + chunks[0] = firstChunk.slice(overflowChars) + totalChars -= overflowChars + truncated = true + overflowChars = 0 + } + }, + snapshot(): AutomationRunOutputSnapshot | null { + const content = chunks.join('').trim() + if (!content) { + return null + } + return { + format: 'plain_text', + content, + capturedAt: Date.now(), + truncated + } + } + } +} diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts new file mode 100644 index 00000000000..417737a9960 --- /dev/null +++ b/src/main/automations/run-target-resolution.ts @@ -0,0 +1,99 @@ +import type { Store } from '../persistence' +import type { Automation } from '../../shared/automations-types' +import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import type { ProjectHostSetup, Repo } from '../../shared/types' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' + +export type AutomationRunTargetResult = + | { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup } + | { ok: false; error: string } + +type AutomationRunTargetOptions = { + allowRemoteHostScheduling?: boolean +} + +function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null { + if (automation.workspaceMode === 'existing') { + const parsed = automation.workspaceId + ? splitWorktreeIdForFilesystem(automation.workspaceId) + : null + return parsed?.worktreePath ?? null + } + return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null +} + +export function resolveAutomationRunTarget( + store: Store, + automation: Automation, + options: AutomationRunTargetOptions = {} +): AutomationRunTargetResult { + const context = automation.runContext ?? null + if (!context) { + const repo = store.getRepo(getAutomationLegacyRepoId(automation)) + const cwd = getLegacyPrecheckCwd(store, automation) + if (!repo || !cwd) { + return { ok: false, error: 'Automation run target is no longer available.' } + } + return { ok: true, cwd, repo } + } + const parsedHost = parseExecutionHostId(context.hostId) + if ( + parsedHost?.kind === 'runtime' && + (!options.allowRemoteHostScheduling || automation.schedulerOwner !== 'remote_host_service') + ) { + return { + ok: false, + error: + 'Remote-server automation scheduling is not available from this Orca client yet. Run this automation on the remote server or update Orca when durable remote scheduling is available.' + } + } + + const setup = store + .getProjectHostSetups() + .find((candidate) => candidate.id === context.projectHostSetupId) + if (!setup) { + return { + ok: false, + error: 'Project is not set up on the selected automation host anymore.' + } + } + if (setup.setupState !== 'ready') { + return { + ok: false, + error: `Project setup on the selected automation host is ${setup.setupState}.` + } + } + if ( + setup.projectId !== context.projectId || + setup.hostId !== context.hostId || + setup.repoId !== context.repoId + ) { + return { + ok: false, + error: 'Automation run target no longer matches the selected project host setup.' + } + } + + const repo = store.getRepo(context.repoId) + if (!repo) { + return { + ok: false, + error: 'Repository for the selected automation host is no longer available.' + } + } + if (getRepoExecutionHostId(repo) !== context.hostId) { + return { + ok: false, + error: 'Repository is no longer attached to the selected automation host.' + } + } + if (repo.path !== setup.path || context.path !== setup.path) { + return { + ok: false, + error: 'Project path for the selected automation host has changed.' + } + } + + return { ok: true, cwd: setup.path, repo, setup } +} diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts new file mode 100644 index 00000000000..ab1233e5765 --- /dev/null +++ b/src/main/automations/run-usage-collection.ts @@ -0,0 +1,99 @@ +import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' + +function createUnavailableAutomationUsage( + collectedAt: number, + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string +): AutomationRunUsage { + return { + status: 'unavailable', + provider, + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + } +} + +function getAutomationUsageProvider( + automation: Automation | undefined +): AutomationRunUsage['provider'] { + if (automation?.agentId === 'codex') { + return 'codex' + } + if (automation?.agentId === 'claude') { + return 'claude' + } + return null +} + +export async function collectAutomationRunUsage({ + automation, + run, + claudeUsage, + codexUsage +}: { + automation: Automation | undefined + run: AutomationRun + claudeUsage: ClaudeUsageStore | null + codexUsage: CodexUsageStore | null +}): Promise<AutomationRunUsage> { + const collectedAt = Date.now() + const unavailable = ( + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => + createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage) + + if (!automation || run.status !== 'completed') { + return unavailable( + getAutomationUsageProvider(automation), + 'run_not_finished', + 'Usage is only collected for completed automation runs.' + ) + } + if (automation.executionTargetType === 'ssh') { + return unavailable( + getAutomationUsageProvider(automation), + 'remote_usage_unavailable', + 'Remote automation usage is not available from local usage logs.' + ) + } + if (automation.agentId === 'claude') { + if (!claudeUsage) { + return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') + } + return claudeUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + if (automation.agentId === 'codex') { + if (!codexUsage) { + return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') + } + return codexUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.') +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 553674a9c34..37d0ee7fe7e 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -105,6 +105,46 @@ describe('AutomationService prechecks', () => { }) }) + it('does not run scheduled prechecks when the selected host setup is stale', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo({ path: '/repo/current' })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Conditional check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: '/repo/old' + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'scheduled') + const service = new AutomationService(store, { tickMs: 60_000 }) + + const result = await service.runPrecheck(automation.id, run.id) + + expect(result).toMatchObject({ + command: 'test -f ready', + exitCode: null, + error: 'Project path for the selected automation host has changed.' + }) + expect(runAutomationPrecheckMock).not.toHaveBeenCalled() + }) + it('does not run prechecks for manual dispatches', async () => { vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) const store = await createStore() @@ -129,4 +169,65 @@ describe('AutomationService prechecks', () => { await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull() expect(runAutomationPrecheckMock).not.toHaveBeenCalled() }) + + it('honors scheduled prechecks before headless dispatch', async () => { + vi.setSystemTime(new Date('2026-05-12T08:59:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Conditional remote check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00Z').getTime() + }) + runAutomationPrecheckMock.mockResolvedValue({ + command: 'test -f ready', + exitCode: 1, + timedOut: false, + durationMs: 5, + stdout: '', + stderr: 'missing', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: Date.now(), + completedAt: Date.now() + }) + const headlessDispatcher = vi.fn() + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true, + headlessDispatcher + }) + const run = store.createAutomationRun(automation, Date.now(), 'scheduled') + const requestHeadlessDispatch = ( + service as unknown as { + requestHeadlessDispatch: ( + automationArg: typeof automation, + runArg: typeof run, + targetArg: { ok: true; cwd: string; repo: Repo } + ) => Promise<unknown> + } + ).requestHeadlessDispatch.bind(service) + + await requestHeadlessDispatch(automation, run, { + ok: true, + cwd: '/repo', + repo: store.getRepo('r1')! + }) + + expect(headlessDispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'skipped_precheck', + error: 'Precheck exited with code 1.' + }) + }) }) diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 58651ceda6e..237ee7b91c0 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import type { Repo } from '../../shared/types' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' const testState = { dir: '' } @@ -124,6 +125,227 @@ describe('AutomationService', () => { ) }) + it('skips dispatch when the selected project host setup is gone', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Manual check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'missing-setup', + repoId: 'r1', + path: '/repo' + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('skipped_unavailable') + expect(run.error).toBe('Project is not set up on the selected automation host anymore.') + expect(send).not.toHaveBeenCalled() + }) + + it('skips dispatch when the saved project host setup path is stale', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo({ path: '/repo/current' })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Manual check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: '/repo/old' + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('skipped_unavailable') + expect(run.error).toBe('Project path for the selected automation host has changed.') + expect(send).not.toHaveBeenCalled() + }) + + it('skips runtime-owned automations before desktop renderer dispatch', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('skipped_unavailable') + expect(run.error).toContain('Remote-server automation scheduling is not available') + expect(send).not.toHaveBeenCalled() + }) + + it('dispatches remote-host scheduled automations when service runs in serve mode', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true + }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatching') + expect(send).toHaveBeenCalledWith( + 'automations:dispatchRequested', + expect.objectContaining({ + automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }), + run: expect.objectContaining({ id: run.id, status: 'dispatching' }) + }) + ) + }) + + it('dispatches remote-host automations headlessly when no renderer is available', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true, + headlessDispatcher: vi.fn().mockResolvedValue({ + workspaceId: 'remote-wt-1', + workspaceDisplayName: 'Remote automation', + terminalSessionId: 'remote-tab-1', + completion: Promise.resolve({ + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'Done.', + capturedAt: Date.now(), + truncated: false + }, + error: null + }) + }) + }) + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatched') + expect(run.workspaceId).toBe('remote-wt-1') + expect(run.workspaceDisplayName).toBe('Remote automation') + expect(run.terminalSessionId).toBe('remote-tab-1') + await vi.waitFor(() => + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'completed', + workspaceId: 'remote-wt-1', + terminalSessionId: 'remote-tab-1', + outputSnapshot: expect.objectContaining({ content: 'Done.' }) + }) + ) + }) + it('attaches provider usage when a completed run can be attributed', async () => { vi.setSystemTime(new Date('2026-05-13T10:00:00')) const store = await createStore() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index d2386b0de99..bd6e7fd534e 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -6,13 +6,18 @@ import type { AutomationDispatchResult, AutomationPrecheckResult, AutomationRun, - AutomationRunStatus, - AutomationRunUsage + AutomationRunStatus } from '../../shared/automations-types' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { runAutomationPrecheck } from './precheck-runner' +import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' +import { collectAutomationRunUsage } from './run-usage-collection' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import { + didAutomationPrecheckPass, + formatAutomationPrecheckFailure +} from '../../shared/automation-precheck' const DEFAULT_TICK_MS = 60 * 1000 @@ -25,15 +30,25 @@ export class AutomationService { private evaluating = false private readonly claudeUsage: ClaudeUsageStore | null private readonly codexUsage: CodexUsageStore | null + private readonly allowRemoteHostScheduling: boolean + private readonly headlessDispatcher: HeadlessAutomationDispatcher | null constructor( store: Store, - opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {} + opts: { + tickMs?: number + claudeUsage?: ClaudeUsageStore + codexUsage?: CodexUsageStore + allowRemoteHostScheduling?: boolean + headlessDispatcher?: HeadlessAutomationDispatcher + } = {} ) { this.store = store this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS this.claudeUsage = opts.claudeUsage ?? null this.codexUsage = opts.codexUsage ?? null + this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false + this.headlessDispatcher = opts.headlessDispatcher ?? null } setWebContents(webContents: WebContents | null): void { @@ -87,8 +102,10 @@ export class AutomationService { if (run.trigger !== 'scheduled' || !automation.precheck) { return null } - const cwd = this.getPrecheckCwd(automation) - if (!cwd) { + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + if (!target.ok) { return { command: automation.precheck.command, exitCode: null, @@ -98,7 +115,7 @@ export class AutomationService { stderr: '', stdoutTruncated: false, stderrTruncated: false, - error: 'Automation precheck target is no longer available.', + error: target.error, startedAt: Date.now(), completedAt: Date.now() } @@ -107,8 +124,8 @@ export class AutomationService { precheck: automation.precheck, target: automation.executionTargetType === 'ssh' - ? { type: 'ssh', cwd, connectionId: automation.executionTargetId } - : { type: 'local', cwd } + ? { type: 'ssh', cwd: target.cwd, connectionId: automation.executionTargetId } + : { type: 'local', cwd: target.cwd } }) } @@ -124,7 +141,12 @@ export class AutomationService { if (run.usage) { return run } - const usage = await this.collectRunUsage(run) + const usage = await collectAutomationRunUsage({ + automation: this.store.listAutomations().find((entry) => entry.id === run.automationId), + run, + claudeUsage: this.claudeUsage, + codexUsage: this.codexUsage + }) return this.store.updateAutomationRun({ runId: run.id, status: run.status, @@ -135,83 +157,6 @@ export class AutomationService { }) } - private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> { - const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId) - const collectedAt = Date.now() - const unavailable = ( - provider: AutomationRunUsage['provider'], - unavailableReason: AutomationRunUsage['unavailableReason'], - unavailableMessage: string - ): AutomationRunUsage => ({ - status: 'unavailable', - provider, - model: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - reasoningOutputTokens: null, - totalTokens: null, - estimatedCostUsd: null, - estimatedCostSource: null, - providerSessionId: null, - attribution: null, - collectedAt, - unavailableReason, - unavailableMessage - }) - - if (!automation || run.status !== 'completed') { - return unavailable( - automation?.agentId === 'codex' - ? 'codex' - : automation?.agentId === 'claude' - ? 'claude' - : null, - 'run_not_finished', - 'Usage is only collected for completed automation runs.' - ) - } - if (automation.executionTargetType === 'ssh') { - return unavailable( - automation.agentId === 'codex' - ? 'codex' - : automation.agentId === 'claude' - ? 'claude' - : null, - 'remote_usage_unavailable', - 'Remote automation usage is not available from local usage logs.' - ) - } - if (automation.agentId === 'claude') { - if (!this.claudeUsage) { - return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') - } - return this.claudeUsage.getAutomationRunUsage({ - worktreeId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - startedAt: run.startedAt, - completedAt: collectedAt - }) - } - if (automation.agentId === 'codex') { - if (!this.codexUsage) { - return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') - } - return this.codexUsage.getAutomationRunUsage({ - worktreeId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - startedAt: run.startedAt, - completedAt: collectedAt - }) - } - return unavailable( - null, - 'provider_unsupported', - 'This agent does not report usage to Orca yet.' - ) - } - private async evaluateDueRuns(): Promise<void> { if (this.evaluating) { return @@ -230,16 +175,6 @@ export class AutomationService { } } - private getPrecheckCwd(automation: Automation): string | null { - if (automation.workspaceMode === 'existing') { - const parsed = automation.workspaceId - ? splitWorktreeIdForFilesystem(automation.workspaceId) - : null - return parsed?.worktreePath ?? null - } - return this.store.getRepo(automation.projectId)?.path ?? null - } - private async evaluateAutomation(automation: Automation, now: number): Promise<void> { const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { @@ -267,8 +202,22 @@ export class AutomationService { automation: Automation, run: AutomationRun ): Promise<AutomationRun> { + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + if (!target.ok) { + return this.store.updateAutomationRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + error: target.error + }) + } const webContents = this.webContents if (!webContents || webContents.isDestroyed() || !this.rendererReady) { + if (this.headlessDispatcher) { + return await this.requestHeadlessDispatch(automation, run, target) + } return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', @@ -286,6 +235,70 @@ export class AutomationService { webContents.send('automations:dispatchRequested', payload) return updated } + + private async requestHeadlessDispatch( + automation: Automation, + run: AutomationRun, + target: Extract<AutomationRunTargetResult, { ok: true }> + ): Promise<AutomationRun> { + const precheckResult = + run.trigger === 'scheduled' && automation.precheck + ? await this.runPrecheck(automation.id, run.id) + : null + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + return this.store.updateAutomationRun({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: automation.workspaceId, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) + } + try { + const launch = await this.headlessDispatcher!({ automation, run, target }) + const updated = this.store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + error: null + }) + if (launch.completion) { + void launch.completion + .then((completion) => + this.markDispatchResult({ + runId: run.id, + status: completion.status, + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }) + ) + .catch((error) => + this.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + error: error instanceof Error ? error.message : String(error) + }) + ) + } + return updated + } catch (error) { + return this.store.updateAutomationRun({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: automation.workspaceId, + error: error instanceof Error ? error.message : String(error) + }) + } + } } function isFinalRunStatus(status: AutomationRunStatus): boolean { diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index a6404ed0220..8d2f8e9ad96 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => { const result = healthCheckDaemon(socketPath, tokenPath) socket.emit('connect') - socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n')) + socket.emit( + 'data', + Buffer.from( + '{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n' + ) + ) await expect(result).resolves.toBe(true) expect(socket.listenerCount('connect')).toBe(0) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 92220f8d548..6bf71a37f98 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -74,15 +74,36 @@ describe('daemon health', () => { }) it('passes when a daemon answers ping', async () => { + const ptySpawnHealthCheck = vi.fn(async () => {}) const server = new DaemonServer({ socketPath, tokenPath, + ptySpawnHealthCheck, spawnSubprocess: () => createMockSubprocess() }) await server.start() try { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) + expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() + } finally { + await server.shutdown() + } + }) + + it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { + const server = new DaemonServer({ + socketPath, + tokenPath, + ptySpawnHealthCheck: vi.fn(async () => { + throw new Error('stale node-pty helper') + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) } finally { await server.shutdown() } diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index a4e95ce72bf..8982e908fe9 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -137,7 +137,10 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis settle(false) return } - sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' })) + // Why: a protocol-live daemon with a stale cwd or node-pty helper + // will answer ping but cannot create terminals, so reuse must check + // the PTY spawn prerequisites too. + sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' })) continue } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 14df83d33c5..4608fa71475 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -20,6 +20,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -66,6 +67,7 @@ const { } }) + const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') @@ -101,6 +103,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ + checkDaemonHealth: checkDaemonHealthMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -268,6 +272,8 @@ async function importFresh() { setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() + checkDaemonHealthMock.mockClear() + checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() @@ -1026,9 +1032,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' ) - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() getAppPathMock.mockReturnValue('/fake/app/out/main') + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( @@ -1069,8 +1075,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after readiness', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( @@ -1124,8 +1130,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after startup error', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index b7fbca39edf..a27b466d90b 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -202,11 +202,10 @@ describe('DaemonServer', () => { await startServer() const c = await connectClient() - // Why: older app builds still send the removed ptySpawnHealth probe. - // The daemon must reject it gracefully so a downgraded client lands on - // its session-preserving branch instead of crashing the daemon. - await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow( - 'Unknown request type: ptySpawnHealth' + // Why: downgraded clients can send request types this daemon does not + // know. Reject gracefully instead of crashing the session server. + await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow( + 'Unknown request type: definitelyUnknownRequest' ) await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({ pong: true diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c41b9cee1db..8f76b0e391e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' +import { checkPtySpawnHealth } from './pty-subprocess' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -22,6 +23,7 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string + ptySpawnHealthCheck?: () => Promise<void> spawnSubprocess: (opts: { sessionId: string cols: number @@ -45,6 +47,7 @@ export class DaemonServer { private host: TerminalHost private socketPath: string private tokenPath: string + private ptySpawnHealthCheck: () => Promise<void> private clients = new Map<string, ConnectedClient>() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -62,6 +65,7 @@ export class DaemonServer { this.tokenPath = opts.tokenPath this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth } async start(): Promise<void> { @@ -385,6 +389,10 @@ export class DaemonServer { case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': if (request.payload.killSessions) { this.host.dispose() diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index f589e21c13f..acfc0ea2c54 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -33,6 +33,7 @@ import { isShellProcess } from '../../shared/shell-process-detection' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 +const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000 export type PtySubprocessOptions = { sessionId: string @@ -235,6 +236,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +export async function checkPtySpawnHealth(): Promise<void> { + if (process.platform !== 'darwin') { + return + } + + ensureNodePtySpawnHelperExecutable() + preflightMacNodePtySpawnEnvironment() + + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : getDefaultCwd() + + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { + ...process.env, + TERM: 'xterm-256color' + } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + await new Promise<void>((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + return + } + resolve() + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + + // Why: ping only proves the daemon protocol is alive. A real short-lived + // PTY spawn catches stale node-pty helper paths captured by this process. + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + return + } + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + }) + }) +} + function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 1b6883ef837..a64e7cf2c37 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -201,6 +201,11 @@ export type SystemResolverHealthRequest = { type: 'systemResolverHealth' } +export type PtySpawnHealthRequest = { + id: string + type: 'ptySpawnHealth' +} + export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -260,6 +265,7 @@ export type DaemonRequest = | ShutdownRequest | PingRequest | SystemResolverHealthRequest + | PtySpawnHealthRequest | GetSnapshotRequest | TakePendingOutputRequest diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts index 8c0c7df7576..579b7e1f3e7 100644 --- a/src/main/git/repo-clone-path.ts +++ b/src/main/git/repo-clone-path.ts @@ -14,6 +14,21 @@ export type ClaimedCloneTarget = { type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'> +export function deriveCloneRepoNameFromUrl(url: string): string { + // Why: direct callers can supply URLs whose default git clone folder would + // be "." or ".."; rejecting them prevents parent/destination deletion. + const source = url.replace(/\.git\/?$/, '') + const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + if (!repoName || repoName === '.' || repoName === '..') { + throw new Error('Invalid repository name derived from URL') + } + if (repoName.includes('/') || repoName.includes('\\')) { + throw new Error('Invalid repository name derived from URL') + } + return repoName +} + export function deriveValidatedClonePath(args: { url: string; destination: string }): string { if ( !args.destination || @@ -23,17 +38,7 @@ export function deriveValidatedClonePath(args: { url: string; destination: strin throw new Error('Clone destination must be an absolute path') } - // Why: direct callers can supply URLs whose default git clone folder would - // be "." or ".."; rejecting them prevents parent/destination deletion. - const source = args.url.replace(/\.git\/?$/, '') - const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') - const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) - if (!repoName || repoName === '.' || repoName === '..') { - throw new Error('Invalid repository name derived from URL') - } - if (repoName.includes('/') || repoName.includes('\\')) { - throw new Error('Invalid repository name derived from URL') - } + const repoName = deriveCloneRepoNameFromUrl(args.url) const clonePath = join(args.destination, repoName) const resolvedDestination = resolve(args.destination) diff --git a/src/main/index.ts b/src/main/index.ts index 943a60d5aae..4d2fe6804df 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -109,6 +109,7 @@ import { browserManager } from './browser/browser-manager' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { AgentAwakeService } from './agent-awake-service' import { getCrashBreadcrumbSnapshot, @@ -158,6 +159,16 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null + +function buildHeadlessAutomationWorkspaceName(runTitle: string, scheduledFor: number): string { + const slug = runTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) + const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) + return `auto-${slug || 'run'}-${stamp}` +} let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null @@ -1306,7 +1317,95 @@ app.whenReady().then(async () => { } }) runtime = runtimeService - automations = new AutomationService(store, { claudeUsage, codexUsage }) + automations = new AutomationService(store, { + claudeUsage, + codexUsage, + // Why: desktop clients may mirror remote-host automations, but only a + // server process should execute schedules owned by `remote_host_service`. + allowRemoteHostScheduling: isServeMode, + headlessDispatcher: isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + + if (automation.workspaceMode === 'new_per_run') { + const created = await runtimeService.createManagedWorktree({ + repoSelector: target.repo.id, + name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), + baseBranch: automation.baseBranch ?? undefined, + setupDecision: 'inherit', + activate: false, + createdWithAgent: automation.agentId, + startupAgent: automation.agentId, + startupPrompt: automation.prompt, + telemetrySource: 'unknown' + }) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtimeService.launchAgentTerminal( + `id:${automation.workspaceId}`, + { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + } + ) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + + const completion = (async () => { + const wait = await runtimeService.waitForTerminal(terminalHandle, { + condition: 'tui-idle' + }) + const read = await runtimeService.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + completion + } + } + : undefined + }) runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index c0c641f262e..44c689b3aef 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -1,5 +1,9 @@ +import type { TaskSourceContext } from '../../shared/task-source-context' + export type WorkItemArgs = { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 2a5896dc0cc..9846bd5f3cc 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -8,6 +8,8 @@ const { getIssueMock, listIssuesMock, listWorkItemsMock, + listLabelsMock, + listAssignableUsersMock, getAuthenticatedViewerMock, mergePRMock, setPRAutoMergeMock, @@ -22,6 +24,8 @@ const { getIssueMock: vi.fn(), listIssuesMock: vi.fn(), listWorkItemsMock: vi.fn(), + listLabelsMock: vi.fn(), + listAssignableUsersMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), setPRAutoMergeMock: vi.fn(), @@ -46,6 +50,8 @@ vi.mock('../github/client', () => ({ getIssue: getIssueMock, listIssues: listIssuesMock, listWorkItems: listWorkItemsMock, + listLabels: listLabelsMock, + listAssignableUsers: listAssignableUsersMock, getAuthenticatedViewer: getAuthenticatedViewerMock, mergePR: mergePRMock, setPRAutoMerge: setPRAutoMergeMock, @@ -74,6 +80,7 @@ describe('registerGitHubHandlers', () => { badgeColor: string addedAt: number connectionId?: string | null + executionHostId?: string | null issueSourcePreference?: 'origin' | 'upstream' } let repos: FixtureRepo[] = [] @@ -91,6 +98,8 @@ describe('registerGitHubHandlers', () => { getIssueMock.mockReset() listIssuesMock.mockReset() listWorkItemsMock.mockReset() + listLabelsMock.mockReset() + listAssignableUsersMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() setPRAutoMergeMock.mockReset() @@ -156,6 +165,58 @@ describe('registerGitHubHandlers', () => { expect(getIssueMock).not.toHaveBeenCalled() }) + it('rejects GitHub source context from a different host', async () => { + registerGitHubHandlers(store as never, stats as never) + + expect(() => + handlers['gh:listWorkItems'](null, { + repoPath: '/workspace/repo', + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:openclaw-2', + repoId: 'repo-1' + } + }) + ).toThrow('Access denied: GitHub source host does not match repository host') + + expect(listWorkItemsMock).not.toHaveBeenCalled() + }) + + it('guards label metadata lookups with source host context', async () => { + listLabelsMock.mockResolvedValue(['bug']) + repos = [ + ...repos, + { + id: 'repo-ssh', + path: '/workspace/remote-repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'openclaw-2', + executionHostId: 'ssh:openclaw-2' + } + ] + registerGitHubHandlers(store as never, stats as never) + + await expect( + handlers['gh:listLabels'](null, { + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:openclaw-2', + repoId: 'repo-ssh' + } + }) + ).resolves.toEqual(['bug']) + + expect(listLabelsMock).toHaveBeenCalledWith('/workspace/remote-repo', undefined, 'openclaw-2') + }) + it('forwards listIssues for registered repositories and unwraps items', async () => { listIssuesMock.mockResolvedValue({ items: [] }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index e6810e1d147..87acce471ef 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -14,6 +14,8 @@ import type { GitHubPRRefreshReason, PRRefreshOutcome } from '../../shared/types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { @@ -130,7 +132,11 @@ function broadcastWorkItemMutated( // Why: returns the full Repo object instead of just the path string so that // callers have access to repo.id for stat tracking and other context. -type RepoScopedArgs = { repoPath: string; repoId?: string } +type RepoScopedArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { const repoPath = typeof args === 'string' ? args : args.repoPath @@ -145,6 +151,13 @@ function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo if (repoId && resolve(repo.path) !== resolvedRepoPath) { throw new Error('Access denied: repository path does not match repo id') } + if ( + typeof args !== 'string' && + args.sourceContext?.provider === 'github' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + throw new Error('Access denied: GitHub source host does not match repository host') + } return repo } @@ -275,10 +288,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) - ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { - const repo = assertRegisteredRepo(args, store) - return getIssue(repo.path, args.number, repoConnectionId(repo)) - }) + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredRepo(args, store) + return getIssue(repo.path, args.number, repoConnectionId(repo)) + } + ) ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { const repo = assertRegisteredRepo(args, store) @@ -295,7 +319,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:createIssue', - (_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => { + (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { const repo = assertRegisteredRepo(args, store) const fields = args.labels !== undefined || args.assignees !== undefined @@ -416,6 +440,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: GitHubOwnerRepo | null @@ -442,6 +468,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string @@ -470,6 +498,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean @@ -487,7 +517,16 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:resolveReviewThread', - async (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => { + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + } + ) => { const repo = assertRegisteredRepo(args, store) // Why: thread resolve doesn't carry the PR number, so we cannot target // a specific cache entry. The renderer cache stores per-(repo, type, number) @@ -546,6 +585,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -687,6 +727,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null @@ -716,6 +758,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean prRepo?: GitHubOwnerRepo | null @@ -743,7 +787,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:updatePRState', async ( event, - args: { repoPath: string; prNumber: number; updates: GitHubPullRequestStateUpdate } + args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -773,7 +817,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:rerunPRChecks', async ( _event, - args: { repoPath: string; prNumber: number; headSha?: string; failedOnly?: boolean } + args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -794,7 +838,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:requestPRReviewers', - async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => { + async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { const repo = assertRegisteredRepo(args, store) const result = await requestPRReviewers( repo.path, @@ -814,7 +858,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:removePRReviewers', - async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => { + async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { const repo = assertRegisteredRepo(args, store) const result = await removePRReviewers( repo.path, @@ -834,10 +878,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:updateIssue', - async ( - event, - args: { repoPath: string; repoId?: string; number: number; updates: GitHubIssueUpdate } - ) => { + async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { const repo = assertRegisteredRepo(args, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -863,6 +904,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -898,12 +940,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) - ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => { + ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) - ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => { + ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts new file mode 100644 index 00000000000..ace50f94068 --- /dev/null +++ b/src/main/ipc/gitlab.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/types' +import { toSshExecutionHostId } from '../../shared/execution-host' + +const { ipcHandlers, listWorkItemsMock, getWorkItemByProjectRefMock } = vi.hoisted(() => ({ + ipcHandlers: new Map<string, (...args: unknown[]) => unknown>(), + listWorkItemsMock: vi.fn(), + getWorkItemByProjectRefMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + ipcHandlers.set(channel, handler) + }) + } +})) + +vi.mock('../gitlab/client', () => ({ + addIssueComment: vi.fn(), + addMRInlineComment: vi.fn(), + addMRComment: vi.fn(), + closeMR: vi.fn(), + createIssue: vi.fn(), + diagnoseAuth: vi.fn(), + getAuthenticatedViewer: vi.fn(), + getJobTrace: vi.fn(), + getIssue: vi.fn(), + getMergeRequest: vi.fn(), + getMergeRequestForBranch: vi.fn(), + getProjectSlug: vi.fn(), + getRateLimit: vi.fn(), + getWorkItemByProjectRef: getWorkItemByProjectRefMock, + listAssignableUsers: vi.fn(), + listIssues: vi.fn(), + listLabels: vi.fn(), + listMergeRequests: vi.fn(), + listTodos: vi.fn(), + listWorkItems: listWorkItemsMock, + mergeMR: vi.fn(), + reopenMR: vi.fn(), + resolveMRDiscussion: vi.fn(), + retryJob: vi.fn(), + updateIssue: vi.fn(), + updateMR: vi.fn(), + updateMRReviewers: vi.fn() +})) + +vi.mock('../gitlab/work-item-details', () => ({ + getWorkItemDetails: vi.fn() +})) + +vi.mock('../gitlab/gitlab-project-recents', () => ({ + recordGitLabProjectRecent: vi.fn() +})) + +import { registerGitLabHandlers } from './gitlab' + +function repo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-local', + path: '/local/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function storeWithRepos(repos: Repo[]): Pick<Store, 'getRepos' | 'getRepo'> { + return { + getRepos: () => repos, + getRepo: (id: string) => repos.find((candidate) => candidate.id === id) + } +} + +describe('GitLab IPC handlers', () => { + it('resolves repoId and source host context before listing work items', async () => { + const remoteRepo = repo({ + id: 'repo-ssh', + path: '/ssh/orca', + connectionId: 'builder', + executionHostId: toSshExecutionHostId('builder') + }) + listWorkItemsMock.mockResolvedValueOnce({ items: [] }) + registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store) + + const handler = ipcHandlers.get('gitlab:listWorkItems') + await expect( + handler?.(null, { + repoPath: '/does/not/matter', + repoId: 'repo-ssh', + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-ssh' + } + }) + ).resolves.toEqual({ items: [] }) + + expect(listWorkItemsMock).toHaveBeenCalledWith( + '/ssh/orca', + 'opened', + 1, + 20, + undefined, + undefined, + 'builder' + ) + }) + + it('rejects source context for a different host', async () => { + registerGitLabHandlers( + storeWithRepos([repo({ id: 'repo-local', path: '/local/orca' })]) as Store + ) + + const handler = ipcHandlers.get('gitlab:listWorkItems') + await expect( + handler?.(null, { + repoPath: '/local/orca', + repoId: 'repo-local', + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-local' + } + }) + ).rejects.toThrow('source host does not match') + }) + + it('resolves pasted URL lookups by repoId and source host context', async () => { + const remoteRepo = repo({ + id: 'repo-ssh', + path: '/ssh/orca', + connectionId: 'builder', + executionHostId: toSshExecutionHostId('builder') + }) + getWorkItemByProjectRefMock.mockResolvedValueOnce({ + type: 'issue', + number: 42, + title: 'Remote issue' + }) + registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store) + + const handler = ipcHandlers.get('gitlab:workItemByPath') + await expect( + handler?.(null, { + repoPath: '/local/orca', + repoId: 'repo-ssh', + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-ssh' + }, + host: 'gitlab.com', + path: 'stablyai/orca', + iid: 42, + type: 'issue' + }) + ).resolves.toMatchObject({ number: 42 }) + + expect(getWorkItemByProjectRefMock).toHaveBeenCalledWith( + '/ssh/orca', + { host: 'gitlab.com', path: 'stablyai/orca' }, + 42, + 'issue', + 'builder' + ) + }) +}) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index c753c1de624..04fe0354ee8 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -10,6 +10,8 @@ import type { GitLabWorkItem, Repo } from '../../shared/types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import { normalizeGitLabIssueAssignee, @@ -50,15 +52,41 @@ import { import { getWorkItemDetails } from '../gitlab/work-item-details' import type { ProjectRef } from '../gitlab/gl-utils' +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined { + const sourceRepoId = + args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null + const repoId = args.repoId?.trim() || sourceRepoId || null + if (repoId) { + const repo = store.getRepo(repoId) + if (repo) { + return repo + } + } + const resolvedRepoPath = resolve(args.repoPath) + return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath) +} + // Why: mirror github.ts assertRegisteredRepo — main-process handlers // must never operate on a path the user hasn't explicitly registered as -// a repo (filesystem-auth boundary). -function assertRegisteredRepo(repoPath: string, store: Store): Repo { - const resolvedRepoPath = resolve(repoPath) - const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath) +// a repo (filesystem-auth boundary). Source context adds a host check so a +// task fetched from one machine cannot mutate a same-path repo on another. +function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo { + const repo = findRegisteredGitLabRepo(args, store) if (!repo) { throw new Error('Access denied: unknown repository path') } + if ( + args.sourceContext?.provider === 'gitlab' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + throw new Error('Access denied: GitLab source host does not match repository host') + } return repo } @@ -79,15 +107,18 @@ export function registerGitLabHandlers(store: Store): void { getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null }) ) - ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => { + const repo = assertRegisteredRepo(args, store) return getProjectSlug(repo.path, repoConnectionId(repo)) }) ipcMain.handle( 'gitlab:mrForBranch', - async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async ( + _event, + args: GitLabRepoSelectorArgs & { branch: string; linkedMRIid?: number | null } + ) => { + const repo = assertRegisteredRepo(args, store) return getMergeRequestForBranch( repo.path, args.branch, @@ -97,8 +128,8 @@ export function registerGitLabHandlers(store: Store): void { } ) - ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { + const repo = assertRegisteredRepo(args, store) return getMergeRequest(repo.path, args.iid, repoConnectionId(repo)) }) @@ -108,12 +139,14 @@ export function registerGitLabHandlers(store: Store): void { _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null state?: 'opened' | 'merged' | 'closed' | 'all' page?: number perPage?: number } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) const state = normalizeGitLabMRListState(args.state) const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000) const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100) @@ -129,10 +162,13 @@ export function registerGitLabHandlers(store: Store): void { } ) - ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - return getIssue(repo.path, args.number, repoConnectionId(repo)) - }) + ipcMain.handle( + 'gitlab:issue', + async (_event, args: GitLabRepoSelectorArgs & { number: number }) => { + const repo = assertRegisteredRepo(args, store) + return getIssue(repo.path, args.number, repoConnectionId(repo)) + } + ) ipcMain.handle( 'gitlab:listIssues', @@ -140,12 +176,14 @@ export function registerGitLabHandlers(store: Store): void { _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null state?: 'opened' | 'closed' | 'all' assignee?: string limit?: number } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100) const state = normalizeGitLabIssueListState(args.state) const assignee = normalizeGitLabIssueAssignee(args.assignee) @@ -178,8 +216,8 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:createIssue', - async (_event, args: { repoPath: string; title: string; body: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async (_event, args: GitLabRepoSelectorArgs & { title: string; body: string }) => { + const repo = assertRegisteredRepo(args, store) return createIssue( repo.path, args.title, @@ -192,8 +230,11 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:updateIssue', - async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async ( + _event, + args: GitLabRepoSelectorArgs & { number: number; updates: GitLabIssueUpdate } + ) => { + const repo = assertRegisteredRepo(args, store) return updateIssue( repo.path, args.number, @@ -206,8 +247,8 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:addIssueComment', - async (_event, args: { repoPath: string; number: number; body: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async (_event, args: GitLabRepoSelectorArgs & { number: number; body: string }) => { + const repo = assertRegisteredRepo(args, store) return addIssueComment( repo.path, args.number, @@ -218,13 +259,13 @@ export function registerGitLabHandlers(store: Store): void { } ) - ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => { + const repo = assertRegisteredRepo(args, store) return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) - ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => { + const repo = assertRegisteredRepo(args, store) return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) @@ -237,12 +278,14 @@ export function registerGitLabHandlers(store: Store): void { _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null state?: 'opened' | 'merged' | 'closed' | 'all' page?: number perPage?: number } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) return listWorkItems( repo.path, normalizeGitLabMRListState(args.state), @@ -259,8 +302,8 @@ export function registerGitLabHandlers(store: Store): void { // Powers GitLabItemDialog's tabs. ipcMain.handle( 'gitlab:workItemDetails', - async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async (_event, args: GitLabRepoSelectorArgs & { iid: number; type: 'issue' | 'mr' }) => { + const repo = assertRegisteredRepo(args, store) return getWorkItemDetails( repo.path, args.iid, @@ -271,23 +314,29 @@ export function registerGitLabHandlers(store: Store): void { } ) - ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) - }) + ipcMain.handle( + 'gitlab:closeMR', + async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { + const repo = assertRegisteredRepo(args, store) + return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) + } + ) - ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) - }) + ipcMain.handle( + 'gitlab:reopenMR', + async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { + const repo = assertRegisteredRepo(args, store) + return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) + } + ) ipcMain.handle( 'gitlab:mergeMR', async ( _event, - args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' } + args: GitLabRepoSelectorArgs & { iid: number; method?: 'merge' | 'squash' | 'rebase' } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) return mergeMR( repo.path, args.iid, @@ -300,8 +349,8 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:updateMR', - async (_event, args: { repoPath: string; iid: number; updates: GitLabMRUpdate }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async (_event, args: GitLabRepoSelectorArgs & { iid: number; updates: GitLabMRUpdate }) => { + const repo = assertRegisteredRepo(args, store) return updateMR( repo.path, args.iid, @@ -318,12 +367,14 @@ export function registerGitLabHandlers(store: Store): void { _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null iid: number reviewerIds: number[] projectRef?: ProjectRef | null } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) return updateMRReviewers( repo.path, args.iid, @@ -337,8 +388,8 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:addMRComment', - async (_event, args: { repoPath: string; iid: number; body: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async (_event, args: GitLabRepoSelectorArgs & { iid: number; body: string }) => { + const repo = assertRegisteredRepo(args, store) return addMRComment( repo.path, args.iid, @@ -355,12 +406,14 @@ export function registerGitLabHandlers(store: Store): void { _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null iid: number input: GitLabMRInlineCommentInput projectRef?: ProjectRef | null } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) return addMRInlineComment( repo.path, args.iid, @@ -376,9 +429,9 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:resolveMRDiscussion', async ( _event, - args: { repoPath: string; iid: number; discussionId: string; resolved: boolean } + args: GitLabRepoSelectorArgs & { iid: number; discussionId: string; resolved: boolean } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) return resolveMRDiscussion( repo.path, args.iid, @@ -392,8 +445,11 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:jobTrace', - async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async ( + _event, + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null } + ) => { + const repo = assertRegisteredRepo(args, store) return getJobTrace( repo.path, args.jobId, @@ -406,8 +462,11 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle( 'gitlab:retryJob', - async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + async ( + _event, + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null } + ) => { + const repo = assertRegisteredRepo(args, store) return retryJob( repo.path, args.jobId, @@ -421,8 +480,8 @@ export function registerGitLabHandlers(store: Store): void { // Why: My Todos surface — cross-project, user-scoped. The repoPath is // only used for the registered-repo guard; `glab api todos` doesn't // care about cwd because the endpoint is user-scoped. - ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) + ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => { + const repo = assertRegisteredRepo(args, store) return listTodos(repo.path, repoConnectionId(repo)) }) @@ -434,15 +493,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:workItemByPath', async ( _event, - args: { - repoPath: string + args: GitLabRepoSelectorArgs & { host: string path: string iid: number type: 'issue' | 'mr' } ) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args, store) const projectRef: ProjectRef = { host: args.host, path: args.path } const result = await getWorkItemByProjectRef( repo.path, diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 8a45a89b931..88ddd56502d 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -101,6 +101,7 @@ describe('registerHostedReviewHandlers', () => { await handlers['hostedReview:getCreationEligibility'](null, { repoPath, + repoId: repo.id, worktreePath, branch: 'feature/pr', base: 'main' @@ -128,6 +129,7 @@ describe('registerHostedReviewHandlers', () => { await handlers['hostedReview:create'](null, { repoPath, + repoId: repo.id, worktreePath, provider: 'github', base: 'main', @@ -158,4 +160,26 @@ describe('registerHostedReviewHandlers', () => { }) ) }) + + it('rejects creation when repoId and repoPath point at different registered repos', async () => { + store.getRepo.mockImplementation((repoId: string) => + repoId === repo.id ? { ...repo, path: '/other/repo' } : null + ) + + registerHostedReviewHandlers(store as never, stats as never) + + await expect( + handlers['hostedReview:create'](null, { + repoPath, + repoId: repo.id, + worktreePath, + provider: 'github', + base: 'main', + head: 'feature/pr', + title: 'Feature PR' + }) + ).rejects.toThrow('Access denied: unknown repository') + + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index fe23909ab5c..69ea3084f39 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -98,7 +98,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ipcMain.handle( 'hostedReview:getCreationEligibility', async (_event, args: HostedReviewCreationEligibilityArgs) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) return getHostedReviewCreationEligibility({ ...args, @@ -109,7 +109,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ) ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => { - const repo = assertRegisteredRepo(args.repoPath, store) + const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) const result = await createHostedReview( worktreePath, diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 1f845f01603..311affa5987 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -589,6 +589,31 @@ describe('preflight', () => { }) }) + it('returns no remote agents when the SSH connection is unavailable', async () => { + getActiveMultiplexerMock.mockReturnValue(null) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + }) + + it('returns no remote agents when the SSH connection is disposed', async () => { + const request = vi.fn() + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => true, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + expect(request).not.toHaveBeenCalled() + }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 15591d81750..82981552c5f 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -248,7 +248,9 @@ export async function refreshShellPathAndDetectAgents( export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> { const mux = getActiveMultiplexer(args.connectionId) if (!mux || mux.isDisposed()) { - throw new Error(`No active SSH connection for "${args.connectionId}"`) + // Why: remote agent detection is passive UI polling. A disconnected host has + // no detectable agents until reconnect, but should not spam IPC errors. + return [] } const result = (await mux.request('preflight.detectAgents', { commands: KNOWN_AGENT_COMMANDS diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a165d4d3c45..94c41a23be7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2831,6 +2831,43 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated') }) + it('returns idle process inspection results for detached SSH PTYs without a provider', async () => { + const provider = { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + registerSshPtyProvider('ssh-1', provider as never) + registerPtyHandlers(mainWindow as never) + setPtyOwnership('remote-pty', 'ssh-1') + unregisterSshPtyProvider('ssh-1') + + await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe( + false + ) + await expect( + handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' }) + ).resolves.toBeNull() + expect(provider.hasChildProcesses).not.toHaveBeenCalled() + expect(provider.getForegroundProcess).not.toHaveBeenCalled() + }) + it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { const spawn = vi.fn(async () => ({ id: 'remote-pty' })) registerSshPtyProvider('ssh-1', { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9636c46215d..1e0afbda7ea 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -226,6 +226,13 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should + // read as idle instead of surfacing repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + return connectionId == null || sshProviders.has(connectionId) +} + function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId } @@ -2801,6 +2808,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:hasChildProcesses', async (_event, args: { id: string }): Promise<boolean> => { + if (!hasPtyProviderForInspection(args.id)) { + return false + } return getProviderForPty(args.id).hasChildProcesses(args.id) } ) @@ -2808,6 +2818,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getForegroundProcess', async (_event, args: { id: string }): Promise<string | null> => { + if (!hasPtyProviderForInspection(args.id)) { + return null + } return getProviderForPty(args.id).getForegroundProcess(args.id) } ) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 6992d21939c..154255d25ed 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -38,12 +38,25 @@ const { mockGitProvider: { isGitRepo: vi.fn().mockReturnValue(true), isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + getHostPlatform: vi.fn().mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) }, mockFilesystemProvider: { readDir: vi.fn().mockResolvedValue([]), readFile: vi.fn().mockRejectedValue(new Error('not found')), - stat: vi.fn().mockRejectedValue(new Error('not found')) + stat: vi.fn().mockRejectedValue(new Error('not found')), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) }, mockMultiplexer: { request: vi.fn(), @@ -89,7 +102,7 @@ vi.mock('./filesystem-auth', () => ({ vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1' || id === 'conn-2') { + if (id === 'conn-1') { return mockGitProvider } return undefined @@ -98,7 +111,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1' || id === 'conn-2') { + if (id === 'conn-1') { return mockFilesystemProvider } return undefined @@ -107,7 +120,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1' || id === 'conn-2') { + if (id === 'conn-1') { return mockMultiplexer } return undefined @@ -809,6 +822,26 @@ describe('repos:addRemote', () => { mockStore.updateRepo.mockReset() mockGitProvider.isGitRepoAsync.mockReset() mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) + mockGitProvider.exec.mockReset() + mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' }) + mockGitProvider.clone.mockReset() + mockGitProvider.clone.mockResolvedValue({ stdout: '', stderr: '' }) + mockGitProvider.getHostPlatform.mockReset() + mockGitProvider.getHostPlatform.mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) + mockFilesystemProvider.stat.mockReset() + mockFilesystemProvider.stat.mockRejectedValue(new Error('not found')) + mockFilesystemProvider.createDirNoClobber.mockReset() + mockFilesystemProvider.createDirNoClobber.mockResolvedValue(undefined) + mockFilesystemProvider.deletePath.mockReset() + mockFilesystemProvider.deletePath.mockResolvedValue(undefined) mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() gitSpawnMock.mockReset() @@ -827,6 +860,14 @@ describe('repos:addRemote', () => { expect(handlers.has('repos:addRemote')).toBe(true) }) + it('registers the repos:cloneRemote handler', () => { + expect(handlers.has('repos:cloneRemote')).toBe(true) + }) + + it('registers the repos:createRemote handler', () => { + expect(handlers.has('repos:createRemote')).toBe(true) + }) + it('creates a remote repo with connectionId', async () => { const result = await handlers.get('repos:addRemote')!(null, { connectionId: 'conn-1', @@ -841,7 +882,8 @@ describe('repos:addRemote', () => { displayName: 'project', badgeColor: DEFAULT_REPO_BADGE_COLOR, externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' }) ) expect(result).toHaveProperty('repo.id') @@ -865,6 +907,376 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.displayName', 'My Server Repo') }) + it('clones a repo on an SSH target and registers the cloned path', async () => { + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user') + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user', + expect.objectContaining({ + signal: expect.any(AbortSignal), + timeoutMs: 10 * 60_000, + onProgress: expect.any(Function) + }) + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/orca', + connectionId: 'conn-1', + kind: 'git', + displayName: 'orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false + }) + ) + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/home/user/orca' + }) + expect(result).toHaveProperty('path', '/home/user/orca') + expect(result).toHaveProperty('connectionId', 'conn-1') + }) + + it('forwards SSH clone progress through the existing clone progress event', async () => { + mockGitProvider.clone.mockImplementationOnce( + async ( + _args: string[], + _cwd: string, + options?: { onProgress?: (progress: { phase: string; percent: number }) => void } + ) => { + options?.onProgress?.({ phase: 'Receiving objects', percent: 42 }) + return { stdout: '', stderr: '' } + } + ) + + await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:clone-progress', { + phase: 'Receiving objects', + percent: 42 + }) + }) + + it('returns an existing SSH repo instead of cloning the same target again', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(result).toBe(existing) + expect(mockGitProvider.clone).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('upgrades an existing SSH folder repo after cloning into that path', async () => { + const existing = { + id: 'existing-folder', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'folder' + } + const updated = { ...existing, kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + mockStore.updateRepo.mockReturnValue(updated) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user', + expect.objectContaining({ + signal: expect.any(AbortSignal), + timeoutMs: 10 * 60_000, + onProgress: expect.any(Function) + }) + ) + expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toBe(updated) + }) + + it('does not delete a fresh SSH clone target after git clone fails', async () => { + mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found')) + mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found')) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('repository not found') + + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('rejects concurrent SSH clones to the same destination', async () => { + let releaseClone!: () => void + mockGitProvider.clone.mockImplementationOnce( + async () => + new Promise<{ stdout: string; stderr: string }>((resolve) => { + releaseClone = () => resolve({ stdout: '', stderr: '' }) + }) + ) + + const firstClone = handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('A clone is already in progress for this SSH destination') + + releaseClone() + await firstClone + }) + + it('resolves SSH clone destinations under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '~/projects' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/ubuntu/projects', + expect.any(Object) + ) + }) + + it('does not clean up a pre-existing SSH clone target after git clone fails', async () => { + mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists')) + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('destination already exists') + + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('aborts an active SSH clone and reports the abort without deleting pre-existing targets', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockGitProvider.clone.mockImplementationOnce( + async (_args: string[], _cwd: string, options?: { signal?: AbortSignal }) => + new Promise<{ stdout: string; stderr: string }>((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted by test'))) + }) + ) + + const clonePromise = handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + + await expect(clonePromise).rejects.toThrow('Clone aborted') + const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal } + expect(options.signal.aborted).toBe(true) + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('rejects SSH clone destinations that are not absolute host paths', async () => { + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: 'relative/path' + }) + ).rejects.toThrow('Clone destination must be an absolute path on the SSH host') + + expect(mockGitProvider.clone).not.toHaveBeenCalled() + }) + + it('creates a new git project on an SSH target', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/created') + expect(mockGitProvider.exec).toHaveBeenCalledWith(['init'], '/home/user/created') + expect(mockGitProvider.exec).toHaveBeenCalledWith( + ['commit', '--allow-empty', '-m', 'Initial commit'], + '/home/user/created' + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/created', + connectionId: 'conn-1', + kind: 'git', + displayName: 'created', + externalWorktreeVisibility: 'hide' + }) + ) + expect(result).toHaveProperty('repo.path', '/home/user/created') + expect(result).toHaveProperty('repo.connectionId', 'conn-1') + }) + + it('resolves SSH create parents under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '~/projects', + name: 'created', + kind: 'folder' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith( + '/home/ubuntu/projects/created' + ) + expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created') + }) + + it('creates a new folder project on an SSH target without git init', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'notes', + kind: 'folder' + }) + + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/notes') + expect(mockGitProvider.exec).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/notes', + connectionId: 'conn-1', + kind: 'folder', + displayName: 'notes' + }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + it('rejects SSH create parent paths that are not absolute host paths', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: 'relative/path', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ error: 'Parent directory must be an absolute path on the SSH host' }) + expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() + expect(mockGitProvider.exec).not.toHaveBeenCalled() + }) + + it('rejects non-empty existing SSH create targets', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockFilesystemProvider.readDir.mockResolvedValueOnce([ + { name: 'package.json', isDirectory: false, isSymlink: false } + ]) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ + error: '"created" already exists at this location and is not empty.' + }) + expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() + expect(mockGitProvider.exec).not.toHaveBeenCalled() + }) + + it('removes a newly created SSH directory when git init fails', async () => { + mockGitProvider.exec.mockRejectedValueOnce(new Error('git init failed')) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ error: 'Failed to initialize git repository: git init failed' }) + expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created', true) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('preserves an existing empty SSH directory and removes only .git when commit fails', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockFilesystemProvider.readDir.mockResolvedValueOnce([]) + mockGitProvider.exec + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('Please tell me who you are')) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ + error: + 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' + }) + expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created/.git', true) + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalledWith('/home/user/created', true) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('returns existing repo if same connectionId and path already added', async () => { const existing = { id: 'existing-id', @@ -886,59 +1298,6 @@ describe('repos:addRemote', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() }) - it('allows the same resolved remote path on a different SSH connection', async () => { - const existing = { - id: 'machine-1-project', - path: '/home/user/project', - connectionId: 'conn-1', - displayName: 'project', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - mockStore.getRepos.mockReturnValue([existing]) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-2', - remotePath: '/home/user/project' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/project', - connectionId: 'conn-2' - }) - ) - expect(result).toHaveProperty('repo.connectionId', 'conn-2') - expect(result).toHaveProperty('repo.id') - expect(result).not.toEqual({ repo: existing }) - }) - - it('dedupes remote projects after git root resolution on the same SSH connection', async () => { - const existing = { - id: 'existing-id', - path: '/home/user/project', - connectionId: 'conn-1', - displayName: 'project', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - mockStore.getRepos.mockReturnValue([existing]) - mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ - isRepo: true, - rootPath: '/home/user/project' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project/src' - }) - - expect(result).toEqual({ repo: existing }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - it('throws when SSH connection is not found', async () => { const result = await handlers.get('repos:addRemote')!(null, { connectionId: 'unknown-conn', @@ -1083,6 +1442,31 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir') }) + it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/orca' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/orca/src' + }) + + expect(result).toEqual({ repo: existing }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('ignores SSH target label when custom displayName is provided', async () => { mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' }) mockStore.getSshTarget.mockReturnValueOnce({ @@ -1191,7 +1575,8 @@ describe('repos:add + repos:clone', () => { path: '/tmp/from-add', kind: 'git', externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' }) ) expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide') @@ -1260,7 +1645,10 @@ describe('repos:add + repos:clone', () => { destination }) - expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) + expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) expect(result).toEqual(upgraded) expect(result).toHaveProperty('badgeColor', '#8b5cf6') expect(mockStore.addRepo).not.toHaveBeenCalled() @@ -1425,6 +1813,33 @@ describe('repos:add + repos:clone', () => { expect(existsSync(clonePath)).toBe(false) }) + it('reports the full fatal clone error when stderr includes progress fragments', async () => { + const destination = await createTempRoot() + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + proc.stderr.emit( + 'data', + Buffer.from( + "Cloning into 'orca'...\rfatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n" + ) + ) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow( + `Clone failed: Destination already exists and is not empty: ${join( + destination, + 'orca' + )}. Choose a different parent folder, delete the existing folder, or add the existing repository instead.` + ) + }) + it('removes an owned fresh clone target when git spawn emits an error', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 10f7da3815f..6a0205a061a 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -13,6 +13,14 @@ import type { ProjectGroup, FolderWorkspace, ProjectGroupImportResult, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, NestedRepoScanResult, BaseRefDefaultResult, SparsePreset @@ -23,16 +31,21 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai' +import { + isRuntimePathAbsolute, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' import { isTuiAgent } from '../../shared/tui-agent-config' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' import { gitExecFileAsync, gitSpawn } from '../git/runner' import { isAbsolute, join, posix } from 'path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { cleanupClaimedCloneTarget, claimCloneTarget, + deriveCloneRepoNameFromUrl, deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' @@ -65,11 +78,15 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' +import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection' +import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { joinRemotePath } from '../ssh/ssh-remote-platform' import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath } from '../project-groups/folder-workspace-path-status' +import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -104,6 +121,193 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?: track('repo_added', props) } +function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult { + const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) + const project = store.getProjects().find((entry) => entry.id === setup.projectId) + if (!project) { + throw new Error(`Project setup was created without a project record: ${setup.projectId}`) + } + return { project, setup, repo } +} + +function alignRepoWithRequestedProject( + store: Store, + repo: Repo, + projectId: string, + setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder' +): ProjectHostSetupResult { + let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) + if (setup.projectId !== projectId) { + const project = store.getProjects().find((entry) => entry.id === projectId) + if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') { + throw new Error('Imported folder does not match the selected project identity.') + } + // Why: setup-on-host is an explicit user action for this project. When the + // folder lacks upstream metadata but the selected project has provider + // identity, stamp that identity so compatibility projection can merge it. + const updated = store.updateRepo(repo.id, { + upstream: { + owner: project.providerIdentity.owner, + repo: project.providerIdentity.repo + } + }) + if (!updated) { + throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) + } + repo = updated + setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) + } + const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) + if (!updated) { + throw new Error( + `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` + ) + } + repo = updated + return buildProjectHostSetupResult(store, repo) +} + +async function addLocalRepoFromPath( + store: Store, + path: string, + kind: 'git' | 'folder' = 'git' +): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { + const repoKind = kind === 'folder' ? 'folder' : 'git' + if (repoKind === 'git' && !isGitRepo(path)) { + return { error: `Not a valid git repository: ${path}` } + } + + const existing = store.getRepos().find((r) => r.path === path) + if (existing) { + return { repo: existing, alreadyExisted: true } + } + + const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind }) + const repo: Repo = { + id: randomUUID(), + path, + displayName: getRepoName(path), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...detected, + addedAt: Date.now(), + kind: repoKind, + ...(repoKind === 'git' + ? { + externalWorktreeVisibility: 'hide' as const, + externalWorktreeVisibilityLegacy: false, + // Why: new Add Project imports should become explicit ready host + // setups; `legacy-repo` is reserved for older records/projection. + projectHostSetupMethod: 'imported-existing-folder' as const + } + : {}) + } + + store.addRepo(repo) + return { repo, alreadyExisted: false } +} + +async function addRemoteRepoFromPath( + store: Store, + args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + setupMethod?: Repo['projectHostSetupMethod'] + } +): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider) { + return { error: `SSH connection "${args.connectionId}" not found or not connected` } + } + + let repoKind: 'git' | 'folder' = args.kind ?? 'git' + let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath) + + const existing = store + .getRepos() + .find( + (repo) => + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === + normalizeRuntimePathForComparison(resolvedPath) + ) + if (existing) { + return { repo: existing, alreadyExisted: true } + } + + if (args.kind !== 'folder') { + try { + const check = await gitProvider.isGitRepoAsync(resolvedPath) + if (check.isRepo) { + repoKind = 'git' + if (check.rootPath) { + resolvedPath = check.rootPath + } + } else { + return { error: `Not a valid git repository: ${args.remotePath}` } + } + } catch (err) { + if (err instanceof Error && err.message.includes('Not a valid git repository')) { + return { error: err.message } + } + return { error: `Not a valid git repository: ${args.remotePath}` } + } + } + + const existingAfterRootResolve = store + .getRepos() + .find( + (repo) => + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === + normalizeRuntimePathForComparison(resolvedPath) + ) + if (existingAfterRootResolve) { + return { repo: existingAfterRootResolve, alreadyExisted: true } + } + + const folderName = getRemoteRepoFolderName(resolvedPath) + let displayName = args.displayName || folderName + if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) { + const sshTarget = store.getSshTarget(args.connectionId) + if (sshTarget) { + displayName = sshTarget.label + } + } + + const detected = await detectRepoIconAndUpstream({ + repoPath: resolvedPath, + kind: repoKind, + connectionId: args.connectionId + }) + const repo: Repo = { + id: randomUUID(), + path: resolvedPath, + displayName, + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...detected, + addedAt: Date.now(), + kind: repoKind, + connectionId: args.connectionId, + ...(repoKind === 'git' + ? { + externalWorktreeVisibility: 'hide' as const, + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const) + } + : {}) + } + + store.addRepo(repo) + const mux = getActiveMultiplexer(args.connectionId) + if (mux) { + mux.notify('session.registerRoot', { rootPath: resolvedPath }) + } + + return { repo, alreadyExisted: false } +} + function getRemoteRepoFolderName(remotePath: string): string { const trimmed = remotePath.replace(/[\\/]+$/, '') if (!trimmed) { @@ -112,6 +316,278 @@ function getRemoteRepoFolderName(remotePath: string): string { return trimmed.split(/[\\/]/).at(-1) || remotePath } +async function cloneRemoteRepo( + store: Store, + mainWindow: BrowserWindow, + args: { + connectionId: string + url: string + destination: string + } +): Promise<Repo> { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!fsProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + const host = gitProvider.getHostPlatform?.() + if (!host) { + throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.') + } + const trimmedDestination = await resolveRemoteHomePath(args.connectionId, args.destination.trim()) + if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) { + throw new Error('Clone destination must be an absolute path on the SSH host') + } + const repoName = deriveCloneRepoNameFromUrl(args.url.trim()) + const clonePath = joinRemotePath(host, trimmedDestination, repoName) + if (relativePathInsideRoot(trimmedDestination, clonePath) === null) { + throw new Error('Clone path must be inside the destination directory') + } + const clonePathKey = normalizeRuntimePathForComparison(clonePath) + const existing = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === clonePathKey + ) + }) + if (existing && !isFolderRepo(existing)) { + emitRepoAdded('clone_url', true) + return existing + } + + const remoteCloneKey = `${args.connectionId}:${clonePathKey}` + if (remoteCloneInFlightByPath.has(remoteCloneKey)) { + throw new Error('A clone is already in progress for this SSH destination') + } + const controller = new AbortController() + const metadata: ActiveRemoteCloneMetadata = { + connectionId: args.connectionId, + clonePath, + controller + } + activeRemoteClone = metadata + remoteCloneInFlightByPath.add(remoteCloneKey) + try { + // Why: local clone creates the typed parent before spawning git. SSH clone + // must match that behavior or a fresh remote parent surfaces as spawn ENOENT. + await fsProvider.createDir(trimmedDestination) + // Why: the SSH relay exposes argv-based git execution, not a shell. Use + // the repo folder name as the target so git creates it inside the chosen + // parent, and keep the same flag separator safety as local clone. + await gitProvider.clone( + ['clone', '--progress', '--', args.url.trim(), repoName], + trimmedDestination, + { + signal: controller.signal, + timeoutMs: 10 * 60_000, + onProgress: (progress) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('repos:clone-progress', progress) + } + } + } + ) + } catch (err) { + if (controller.signal.aborted) { + throw new Error('Clone aborted') + } + const message = err instanceof Error ? err.message : String(err) + if (message.startsWith('Clone failed:')) { + throw new Error(`Clone failed: ${getGitCloneFailureMessage(message, { clonePath })}`) + } + throw err + } finally { + if (activeRemoteClone === metadata) { + activeRemoteClone = null + } + remoteCloneInFlightByPath.delete(remoteCloneKey) + } + if (existing && isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) + if (updated) { + emitRepoAdded('clone_url', false) + getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { + rootPath: clonePath + }) + return updated + } + } + const result = await addRemoteRepoFromPath(store, { + connectionId: args.connectionId, + remotePath: clonePath, + kind: 'git', + setupMethod: 'cloned' + }) + if ('error' in result) { + throw new Error(result.error) + } + emitRepoAdded('clone_url', result.alreadyExisted) + return result.repo +} + +async function createRemoteRepo( + store: Store, + args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' + } +): Promise<{ repo: Repo } | { error: string }> { + const name = args.name?.trim() ?? '' + const parentPath = await resolveRemoteHomePath(args.connectionId, args.parentPath?.trim() ?? '') + const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' + if (!name) { + return { error: 'Name cannot be empty' } + } + if (/[\\/]/.test(name) || name === '.' || name === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!parentPath) { + return { error: 'Parent directory is required' } + } + const gitProvider = getSshGitProvider(args.connectionId) + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!gitProvider || !fsProvider) { + return { error: `SSH connection "${args.connectionId}" not found or not connected` } + } + const host = gitProvider.getHostPlatform?.() + if (!host) { + return { error: 'SSH host platform is unavailable. Reconnect the SSH target before creating.' } + } + if (!isRuntimePathAbsolute(parentPath, host.pathFlavor)) { + return { error: 'Parent directory must be an absolute path on the SSH host' } + } + + const targetPath = joinRemotePath(host, parentPath, name) + if (relativePathInsideRoot(parentPath, targetPath) === null) { + return { error: 'Project path must be inside the parent directory' } + } + const targetPathKey = normalizeRuntimePathForComparison(targetPath) + const existing = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (existing) { + emitRepoAdded('folder_picker', true) + return { repo: existing } + } + + let createdDir = false + let targetExists = false + try { + await fsProvider.stat(targetPath) + targetExists = true + } catch { + targetExists = false + } + + if (targetExists) { + try { + const entries = await fsProvider.readDir(targetPath) + if (entries.length > 0) { + return { error: `"${name}" already exists at this location and is not empty.` } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to read directory: ${message}` } + } + } else { + try { + await fsProvider.createDirNoClobber(targetPath) + createdDir = true + } catch (err) { + const raceWinner = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (raceWinner) { + return { repo: raceWinner } + } + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to create directory: ${message}` } + } + } + + if (repoKind === 'git') { + let step: 'init' | 'commit' = 'init' + try { + await gitProvider.exec(['init'], targetPath) + step = 'commit' + await gitProvider.exec(['commit', '--allow-empty', '-m', 'Initial commit'], targetPath) + } catch (err) { + if (createdDir) { + await fsProvider.deletePath(targetPath, true).catch(() => undefined) + } else if (step === 'commit') { + await fsProvider + .deletePath(joinRemotePath(host, targetPath, '.git'), true) + .catch(() => undefined) + } + const message = err instanceof Error ? err.message : String(err) + if (step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message)) { + return { + error: + 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' + } + } + const stepLabel = + step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + const raceWinner = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (raceWinner) { + emitRepoAdded('folder_picker', true) + return { repo: raceWinner } + } + + const result = await addRemoteRepoFromPath(store, { + connectionId: args.connectionId, + remotePath: targetPath, + kind: repoKind, + displayName: name + }) + if ('error' in result) { + return result + } + emitRepoAdded('folder_picker', result.alreadyExisted) + return { repo: result.repo } +} + +async function resolveRemoteHomePath(connectionId: string, path: string): Promise<string> { + if (path !== '~' && path !== '~/' && !path.startsWith('~/')) { + return path + } + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + return path + } + try { + const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string } + return result.resolvedPath + } catch { + // Why: older relays may not support this yet; callers will surface the + // original path validation error instead of failing during resolution. + return path + } +} + type ActiveCloneMetadata = { path: string pathKey: string @@ -123,14 +599,22 @@ type ActiveCloneMetadata = { resolvePendingAbortCleanup: (() => void) | null } +type ActiveRemoteCloneMetadata = { + connectionId: string + clonePath: string + controller: AbortController +} + // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, // and a function-scoped variable would lose the reference to an in-flight clone. let activeClone: ActiveCloneMetadata | null = null +let activeRemoteClone: ActiveRemoteCloneMetadata | null = null let nextCloneGeneration = 1 const latestCloneGenerationByPath = new Map<string, number>() const pendingAbortCleanupByPath = new Map<string, Promise<void>>() const cloneInFlightByPath = new Map<string, Promise<void>>() +const remoteCloneInFlightByPath = new Set<string>() const activeNestedRepoScans = new Map<string, AbortController>() type CompletedNestedRepoScan = { scan: NestedRepoScanResult @@ -141,6 +625,18 @@ const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>() const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50 const GIT_AVAILABILITY_TIMEOUT_MS = 1500 +function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void { + for (const line of text.split(/[\r\n]+/)) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('repos:clone-progress', { + phase: match[1].trim(), + percent: parseInt(match[2], 10) + }) + } + } +} + const ProjectGroupCreateArgs = z.object({ name: z.string().min(1), parentPath: z.string().nullable().optional(), @@ -169,6 +665,57 @@ const ProjectGroupMoveProjectArgs = z.object({ order: z.number().finite().optional() }) +const ProjectHostSetupExistingFolderIpcArgs = z.object({ + projectId: z.string().min(1), + hostId: z.string().min(1), + path: z.string().min(1), + kind: z.enum(['git', 'folder']).optional(), + displayName: z.string().min(1).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() +}) + +const ProjectHostSetupCreateIpcArgs = z.object({ + projectId: z.string().min(1), + hostId: z + .string() + .min(1) + .transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return hostId + }), + setupId: z.string().min(1).optional(), + path: z.string().optional(), + kind: z.enum(['git', 'folder']).optional(), + displayName: z.string().min(1).optional(), + worktreeBasePath: z.string().optional(), + gitUsername: z.string().optional(), + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() +}) + +const ProjectHostSetupUpdateIpcArgs = z.object({ + setupId: z.string().min(1), + updates: z.object({ + displayName: z.string().optional(), + path: z.string().optional(), + worktreeBasePath: z.string().optional(), + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z + .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) + .optional(), + gitUsername: z.string().optional(), + kind: z.enum(['git', 'folder']).optional() + }) +}) + +const ProjectHostSetupDeleteIpcArgs = z.object({ + setupId: z.string().min(1) +}) + const FolderWorkspaceLinkedTaskArgs = z .object({ provider: z.enum(['github', 'gitlab', 'linear', 'jira']), @@ -529,6 +1076,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') + ipcMain.removeHandler('projects:list') + ipcMain.removeHandler('projectHostSetups:list') + ipcMain.removeHandler('projectHostSetups:create') + ipcMain.removeHandler('projectHostSetups:setupExistingFolder') + ipcMain.removeHandler('projectHostSetups:update') + ipcMain.removeHandler('projectHostSetups:delete') ipcMain.removeHandler('projectGroups:list') ipcMain.removeHandler('projectGroups:create') ipcMain.removeHandler('projectGroups:update') @@ -546,6 +1099,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:pickDirectory') ipcMain.removeHandler('repos:clone') ipcMain.removeHandler('repos:cloneAbort') + ipcMain.removeHandler('repos:cloneRemote') ipcMain.removeHandler('repos:isGitAvailable') ipcMain.removeHandler('repos:getDefaultCreateProjectParent') ipcMain.removeHandler('repos:getGitUsername') @@ -554,6 +1108,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:searchBaseRefDetails') ipcMain.removeHandler('repos:addRemote') ipcMain.removeHandler('repos:create') + ipcMain.removeHandler('repos:createRemote') ipcMain.removeHandler('sparsePresets:list') ipcMain.removeHandler('sparsePresets:save') ipcMain.removeHandler('sparsePresets:remove') @@ -562,6 +1117,105 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return store.getRepos() }) + ipcMain.handle('projects:list', () => store.getProjects()) + + ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups()) + + ipcMain.handle( + 'projectHostSetups:create', + (_event, rawArgs: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult => { + const args = parseProjectGroupIpcArgs( + ProjectHostSetupCreateIpcArgs, + rawArgs, + 'project_host_setup_create_invalid_args' + ) + const result = store.createProjectHostSetup(args) + if (!result) { + throw new Error(`Project not found: ${args.projectId}`) + } + notifyReposChanged(mainWindow) + return result + } + ) + + ipcMain.handle( + 'projectHostSetups:update', + (_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => { + const args = parseProjectGroupIpcArgs( + ProjectHostSetupUpdateIpcArgs, + rawArgs, + 'project_host_setup_update_invalid_args' + ) + const result = store.updateProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + notifyReposChanged(mainWindow) + return result + } + ) + + ipcMain.handle( + 'projectHostSetups:delete', + (_event, rawArgs: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult => { + const args = parseProjectGroupIpcArgs( + ProjectHostSetupDeleteIpcArgs, + rawArgs, + 'project_host_setup_delete_invalid_args' + ) + const result = store.deleteProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + notifyReposChanged(mainWindow) + return result + } + ) + + ipcMain.handle( + 'projectHostSetups:setupExistingFolder', + async ( + _event, + rawArgs: ProjectHostSetupExistingFolderArgs + ): Promise<ProjectHostSetupResult> => { + const args = parseProjectGroupIpcArgs( + ProjectHostSetupExistingFolderIpcArgs, + rawArgs, + 'project_host_setup_invalid_args' + ) + const parsedHost = parseExecutionHostId(args.hostId) + if (!parsedHost) { + throw new Error(`Unsupported host: ${args.hostId}`) + } + const existingProject = store.getProjects().find((project) => project.id === args.projectId) + if (!existingProject) { + throw new Error(`Project not found: ${args.projectId}`) + } + + const result = + parsedHost.kind === 'local' + ? await addLocalRepoFromPath(store, args.path, args.kind) + : parsedHost.kind === 'ssh' + ? await addRemoteRepoFromPath(store, { + connectionId: parsedHost.targetId, + remotePath: args.path, + displayName: args.displayName, + kind: args.kind + }) + : { + error: + 'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.' + } + if ('error' in result) { + throw new Error(result.error) + } + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', result.alreadyExisted) + return alignRepoWithRequestedProject(store, result.repo, args.projectId, args.setupMethod) + } + ) + ipcMain.handle('repos:isGitAvailable', () => isGitAvailable()) ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent()) @@ -829,6 +1483,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...(args.connectionId ? { connectionId: args.connectionId } : {}), externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder', ...(group ? { projectGroupId: group.id, @@ -882,42 +1537,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v _event, args: { path: string; kind?: 'git' | 'folder' } ): Promise<{ repo: Repo } | { error: string }> => { - const repoKind = args.kind === 'folder' ? 'folder' : 'git' - if (repoKind === 'git' && !isGitRepo(args.path)) { - return { error: `Not a valid git repository: ${args.path}` } + const result = await addLocalRepoFromPath(store, args.path, args.kind) + if ('error' in result) { + return result } - - // Check if already added - const existing = store.getRepos().find((r) => r.path === args.path) - if (existing) { - emitRepoAdded('folder_picker', true, repoKind === 'git') - return { repo: existing } - } - - const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind }) - const repo: Repo = { - id: randomUUID(), - path: args.path, - displayName: getRepoName(args.path), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...detected, - addedAt: Date.now(), - kind: repoKind, - ...(repoKind === 'git' - ? { - externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false - } - : {}) - } - - store.addRepo(repo) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) - // Why: `repos:add` validates git-ness via `isGitRepo(args.path)` above - // when kind is 'git', and `repoKind` reflects that resolved choice. - emitRepoAdded('folder_picker', false, repoKind === 'git') - return { repo } + emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') + return { repo: result.repo } } ) @@ -932,132 +1559,33 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v kind?: 'git' | 'folder' } ): Promise<{ repo: Repo } | { error: string }> => { - const gitProvider = getSshGitProvider(args.connectionId) - if (!gitProvider) { - return { error: `SSH connection "${args.connectionId}" not found or not connected` } + const result = await addRemoteRepoFromPath(store, args) + if ('error' in result) { + return result } - - const findExistingRemoteRepo = (path: string): Repo | undefined => - store - .getRepos() - .find( - (repo) => - repo.connectionId === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === - normalizeRuntimePathForComparison(path) - ) - - let repoKind: 'git' | 'folder' = args.kind ?? 'git' - let resolvedPath = args.remotePath - - // Why: `~` is a shell expansion that Node's fs APIs don't understand. - // Resolve tilde paths to absolute paths via the relay before storing, - // so all downstream fs operations (readDir, stat, etc.) work correctly. - if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) { - const mux = getActiveMultiplexer(args.connectionId) - if (mux) { - try { - const result = (await mux.request('session.resolveHome', { - path: resolvedPath - })) as { resolvedPath: string } - resolvedPath = result.resolvedPath - } catch { - // Relay may not support resolveHome yet — fall through to raw path - } - } - } - - // Why: check for duplicates after tilde resolution so that adding `~/` - // when `/home/ubuntu` is already stored correctly detects the duplicate. - const existing = findExistingRemoteRepo(resolvedPath) - if (existing) { - // Why: duplicate hit is suppressed by `emitRepoAdded` anyway, and for - // remote adds git-ness isn't resolved until the isGitRepoAsync check - // below — pass `undefined` rather than guess. - emitRepoAdded('folder_picker', true, undefined) - return { repo: existing } - } - - if (args.kind !== 'folder') { - // Why: when kind is not explicitly 'folder', verify the remote path is - // a git repo. Return an error on failure so the renderer can show the "Open as - // Folder" confirmation dialog — matching the local add-repo behavior - // where non-git directories require explicit user consent. - try { - const check = await gitProvider.isGitRepoAsync(resolvedPath) - if (check.isRepo) { - repoKind = 'git' - if (check.rootPath) { - resolvedPath = check.rootPath - } - const existingAfterRootResolution = findExistingRemoteRepo(resolvedPath) - if (existingAfterRootResolution) { - // Why: users may browse inside a repo; store identity is the Git root, - // but different SSH targets can legitimately share that same path. - emitRepoAdded('folder_picker', true, true) - return { repo: existingAfterRootResolution } - } - } else { - return { error: `Not a valid git repository: ${args.remotePath}` } - } - } catch (err) { - if (err instanceof Error && err.message.includes('Not a valid git repository')) { - return { error: err.message } - } - return { error: `Not a valid git repository: ${args.remotePath}` } - } - } - - const folderName = getRemoteRepoFolderName(resolvedPath) - - // When folderName is the home directory basename (e.g. 'ubuntu'), - // use SSH target label for a more descriptive name - let displayName = args.displayName || folderName - if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) { - const sshTarget = store.getSshTarget(args.connectionId) - if (sshTarget) { - displayName = sshTarget.label - } - } - - const detected = await detectRepoIconAndUpstream({ - repoPath: resolvedPath, - kind: repoKind, - connectionId: args.connectionId - }) - const repo: Repo = { - id: randomUUID(), - path: resolvedPath, - displayName, - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...detected, - addedAt: Date.now(), - kind: repoKind, - connectionId: args.connectionId, - ...(repoKind === 'git' - ? { - externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false - } - : {}) - } - - store.addRepo(repo) notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') + return { repo: result.repo } + } + ) - // Why: register the workspace root with the relay so mutating FS operations - // are scoped to this repo's path. Without this, the relay's path ACL would - // reject writes to the workspace after the first root is registered. - const mux = getActiveMultiplexer(args.connectionId) - if (mux) { - mux.notify('session.registerRoot', { rootPath: resolvedPath }) + ipcMain.handle( + 'repos:createRemote', + async ( + _event, + args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' } - - // Why: `repoKind` here reflects the SSH/remote-aware isGitRepoAsync - // result resolved above (or an explicit 'folder' kind), so it's the real - // git-vs-folder signal for this remote add. - emitRepoAdded('folder_picker', false, repoKind === 'git') - return { repo } + ): Promise<{ repo: Repo } | { error: string }> => { + const result = await createRemoteRepo(store, args) + if ('error' in result) { + return result + } + notifyReposChanged(mainWindow) + return result } ) @@ -1249,7 +1777,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...(repoKind === 'git' ? { externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' as const } : {}) } @@ -1486,6 +2015,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v clone.process.kill() activeClone = null } + if (activeRemoteClone) { + activeRemoteClone.controller.abort() + activeRemoteClone = null + } }) ipcMain.handle( @@ -1560,19 +2093,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const text = chunk.toString() stderrTail = (stderrTail + text).slice(-4096) - // Why: git progress lines use \r to overwrite in-place. Split on - // both \r and \n to find the latest progress fragment, then extract - // the phase name and percentage for the renderer. - const lines = text.split(/[\r\n]+/) - for (const line of lines) { - const match = line.match(/^([\w\s]+):\s+(\d+)%/) - if (match && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:clone-progress', { - phase: match[1].trim(), - percent: parseInt(match[2], 10) - }) - } - } + // Why: git progress lines use \r to overwrite in-place; parse + // fragments the same way for local and SSH clone flows. + emitCloneProgressFromText(mainWindow, text) }) const finishClone = async ( @@ -1612,8 +2135,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } else if (code === 0) { resolve() } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) + reject( + new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`) + ) } } @@ -1636,7 +2160,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) if (existing) { if (isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { kind: 'git' }) + const updated = store.updateRepo(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) if (updated) { notifyReposChanged(mainWindow) // Why: folder→git upgrade is a real new git repo provisioning event. @@ -1658,7 +2185,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'cloned' } store.addRepo(repo) @@ -1676,6 +2204,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + ipcMain.handle( + 'repos:cloneRemote', + async ( + _event, + args: { connectionId: string; url: string; destination: string } + ): Promise<Repo> => { + const repo = await cloneRemoteRepo(store, mainWindow, args) + notifyReposChanged(mainWindow) + return repo + } + ) + ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 993ed146d61..46dc429e13d 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -97,6 +97,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -115,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -159,6 +161,30 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(await list(null, undefined)).toEqual([]) }) + it('disconnects a saved runtime without removing it', async () => { + registerRuntimeEnvironmentHandlers() + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const disconnect = handler< + { selector: string }, + { disconnected: { id: string; name: string } } + >('runtimeEnvironments:disconnect') + expect(await disconnect(null, { selector: 'desk' })).toMatchObject({ + disconnected: { id: added.environment.id, name: 'desk' } + }) + + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith('desk') + + const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list') + expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) + }) + it('checks a saved remote runtime and records the runtime id on success', async () => { registerRuntimeEnvironmentHandlers() sendRemoteRuntimeRequestMock.mockResolvedValue({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3d206a149fb..9c11276407c 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -33,6 +33,7 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -103,6 +104,20 @@ export function registerRuntimeEnvironmentHandlers(): void { return { removed: redactRuntimeEnvironment(removed) } } ) + ipcMain.handle( + 'runtimeEnvironments:disconnect', + (_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => { + const environment = resolveEnvironment(getUserDataPath(), args.selector) + // Why: disconnect is intentionally non-destructive; it drops live + // transport state while keeping the paired server available for later. + closeRemoteRuntimeRequestConnection(environment.id) + if (args.selector !== environment.id) { + closeRemoteRuntimeRequestConnection(args.selector) + } + closeSubscriptionsForEnvironment(environment.id) + return { disconnected: redactRuntimeEnvironment(environment) } + } + ) ipcMain.handle( 'runtimeEnvironments:getStatus', async ( diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 5f9518f1737..87f2f8b63d4 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -3,24 +3,27 @@ import type { Store } from '../persistence' import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/types' export function registerSessionHandlers(store: Store): void { - ipcMain.handle('session:get', () => { - return store.getWorkspaceSession() + // Why: hostId is an optional second arg so an older renderer that invokes + // these channels without it keeps reading/writing the 'local' partition + // exactly as before. Channel names stay stable. + ipcMain.handle('session:get', (_event, hostId?: string | null) => { + return store.getWorkspaceSession(hostId) }) - ipcMain.handle('session:set', (_event, args: WorkspaceSessionState) => { - store.setWorkspaceSession(args) + ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { + store.setWorkspaceSession(args, hostId) }) - ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch) => { - store.patchWorkspaceSession(args) + ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { + store.patchWorkspaceSession(args, hostId) }) // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk // before the window closes — regardless of before-quit ordering. - ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState) => { - store.setWorkspaceSession(args) + ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { + store.setWorkspaceSession(args, hostId) store.flush() event.returnValue = true }) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index df0ff73f016..a221b941151 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -879,7 +879,7 @@ describe('SSH IPC handlers', () => { await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate remote SSH sessions') + ).rejects.toThrow('Failed to terminate SSH host sessions') expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', 'pty-1', 'terminated') expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1') }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 3497fd9f99f..e2ea19d1f74 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -850,7 +850,7 @@ export function registerSshHandlers( if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the // grace window. Keep the lease/session intact so the user can retry. - throw new Error(`Failed to terminate remote SSH sessions: ${shutdownFailures.join('; ')}`) + throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) } if (session) { await portForwardManager!.removeAllForwards(args.targetId) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 4923512806f..f33e7476b17 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -308,6 +308,9 @@ describe('mergeWorktree', () => { linkedIssue: 42, linkedPR: 10, linkedLinearIssue: null, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2' as const, + projectHostSetupId: 'remote-repo', linkedGitLabMR: null, linkedGitLabIssue: null, isArchived: true, @@ -336,6 +339,10 @@ describe('mergeWorktree', () => { linkedLinearIssueOrganizationUrlKey: null, linkedGitLabMR: null, linkedGitLabIssue: null, + mobileDiffReview: undefined, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2', + projectHostSetupId: 'remote-repo', isArchived: true, isUnread: true, isPinned: true, diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 9330ee715cc..885d32fec05 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -279,6 +279,11 @@ export function mergeWorktree( id: `${repoId}::${git.path}`, ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, + ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta?.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: git.path, head: git.head, branch: git.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index c38a54e457c..f18693e6beb 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -33,6 +33,7 @@ import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' +import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { buildPosixRunnerScript, buildWindowsRunnerScript, @@ -1419,6 +1420,9 @@ export async function createRemoteWorktree( // Fresh creations must rotate instance identity so stale lineage cannot // attach to the new occupant of the same path. instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), lastActivityAt: now, // Why: grants the new worktree a short grace window at the top of the // Recent sort. During worktree creation (git fetch + add can take several @@ -1922,6 +1926,9 @@ export async function createLocalWorktree( // Fresh creations must rotate instance identity so stale lineage cannot // attach to the new occupant of the same path. instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 4dfa7f8d761..4e867b31c36 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -111,6 +111,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getProjectHostSetups: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -141,6 +142,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { mainWindow.webContents.send.mockReset() store.getRepos.mockReset() store.getRepo.mockReset() + store.getProjectHostSetups.mockReset() store.getSettings.mockReset() store.getWorktreeMeta.mockReset() store.setWorktreeMeta.mockReset() @@ -171,6 +173,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { addedAt: 0, worktreeBaseRef: null }) + store.getProjectHostSetups.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f143db087f1..c428ba6daac 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -228,6 +228,7 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), + getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), removeWorktreeLineage: vi.fn() @@ -292,6 +293,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta, store.getAllWorktreeMeta, store.setWorktreeMeta, + store.getProjectHostSetups, store.removeWorktreeMeta, store.getAllWorktreeLineage, store.removeWorktreeLineage, @@ -338,6 +340,20 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ]) store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') @@ -3213,7 +3229,11 @@ describe('registerWorktreeHandlers', () => { }) ]) expect(store.getWorktreeMeta).not.toHaveBeenCalled() - expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', { + projectId: 'repo:repo-ssh', + hostId: 'ssh:conn-1', + projectHostSetupId: 'repo-ssh' + }) }) it('falls back to reconstructed SSH rows when provider listing throws', async () => { @@ -3432,7 +3452,12 @@ describe('registerWorktreeHandlers', () => { } ]) store.getWorktreeMeta.mockReturnValue(undefined) - const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + const stampedMeta = { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + } store.setWorktreeMeta.mockReturnValue(stampedMeta) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { @@ -3442,7 +3467,12 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/discovered-wt', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) expect(listed[0]).toMatchObject({ id: 'repo-1::/workspace/discovered-wt', @@ -3450,9 +3480,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => { // Why: only the *first* discovery should stamp. Re-stamping on every list - // would overwrite real activity and reshuffle the sidebar on refresh. + // would overwrite real activity and reshuffle the sidebar on refresh. Host + // ownership can still be filled because it is derived from the repo setup. listWorktreesMock.mockResolvedValue([ { path: '/workspace/existing-wt', @@ -3474,14 +3505,245 @@ describe('registerWorktreeHandlers', () => { sortOrder: 0, lastActivityAt: 42 }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 42 + }) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { id: string lastActivityAt: number + projectId?: string + hostId?: string + projectHostSetupId?: string }[] - expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) expect(listed[0].lastActivityAt).toBe(42) + expect(listed[0]).toMatchObject({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + }) + + it('repairs legacy project ids when discovery now resolves the same host setup to a logical project', async () => { + // Why: provider identity can become available after metadata was written. + // Existing workspaces should move from repo-scoped IDs to the logical + // project ID without losing activity ordering. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-1', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + projectId?: string + hostId?: string + projectHostSetupId?: string + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + projectId: 'github:stablyai/orca' + }) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/existing-wt', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 42 + }) + }) + + it('does not repair ownership when discovery points at a different project-host setup', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-1', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + instanceId: 'existing-instance', + projectId: 'github:other/project', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'repo-other-host', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('repairs legacy project ids when SSH worktree listing falls back to persisted metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/orca', + displayName: 'orca', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-target-1' + } + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/orca': makeWorktreeMeta({ + instanceId: 'existing-instance', + projectId: 'repo:repo-ssh', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'repo-ssh', + lastActivityAt: 42 + }) + }) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-ssh', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-target-1', + repoId: 'repo-ssh', + path: '/remote/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 0, + updatedAt: 0 + } + ]) + store.setWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ + instanceId: 'existing-instance', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'repo-ssh', + lastActivityAt: 42 + }) + ) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { + id: string + projectId?: string + hostId?: string + projectHostSetupId?: string + lastActivityAt: number + }[] + + expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1') + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', { + projectId: 'github:stablyai/orca' + }) + expect(listed).toEqual([ + expect.objectContaining({ + id: 'repo-ssh::/remote/orca', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'repo-ssh', + lastActivityAt: 42 + }) + ]) + }) + + it('does not rewrite discovery metadata when instance and project-host ownership already exist', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { @@ -3507,6 +3769,9 @@ describe('registerWorktreeHandlers', () => { }) store.setWorktreeMeta.mockReturnValue({ instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', lastActivityAt: 42 }) @@ -3517,9 +3782,19 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/existing-wt', - expect.objectContaining({ instanceId: expect.any(String) }) + expect.objectContaining({ + instanceId: expect.any(String), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) - expect(listed[0].instanceId).toBe('new-instance') + expect(listed[0]).toMatchObject({ + instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) }) it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { @@ -3545,13 +3820,23 @@ describe('registerWorktreeHandlers', () => { kind: 'folder' }) store.getWorktreeMeta.mockReturnValue(undefined) - store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + store.setWorktreeMeta.mockReturnValue({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + }) await handlers['worktrees:list'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/folder', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b500e18021d..25742c21438 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' +import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { CreateWorktreeArgs, @@ -130,24 +131,57 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin deleteWorktreeHistoryDir(worktreeId) } +function getProjectHostSetupMetaUpdates( + store: Store, + repo: Repo, + existing?: WorktreeMeta +): Partial<Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'>> { + const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + const sameSetup = + existing?.projectHostSetupId === undefined || + existing.projectHostSetupId === ownership.projectHostSetupId + return { + // Why: project IDs can be upgraded from legacy repo IDs to provider-backed + // logical IDs. If the host setup is the same, repair ownership on discovery. + ...(sameSetup && existing?.projectId !== ownership.projectId + ? { projectId: ownership.projectId } + : {}), + ...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}), + ...(existing?.projectHostSetupId === undefined + ? { projectHostSetupId: ownership.projectHostSetupId } + : {}) + } +} + // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. // That makes them sort to the bottom of "Recent" even though the user just -// added the repo / folder. Stamp discovery time the first time we see a -// worktree so its very existence counts as a recency signal. Subsequent -// list calls find the persisted meta and skip the stamp. -function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { +// added the repo / folder. The same authoritative discovery pass is also the +// safest time to backfill project-host setup ownership for upgraded profiles. +function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) if (existing) { - if (!existing.instanceId) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...ownershipUpdates + } + if (Object.keys(updates).length > 0) { // Why: profiles created before lineage shipped already have WorktreeMeta // rows. Backfill on authoritative discovery so upgraded workspaces can - // immediately participate in instance-validated lineage. - return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + // immediately participate in instance-validated lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) } return existing } - return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...ownershipUpdates + }) } async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise<boolean> { @@ -366,6 +400,7 @@ function pruneLineageForMissingRepoWorktrees( } type SshWorktreeMetaCandidate = { + id: string path: string meta: WorktreeMeta } @@ -389,7 +424,7 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt } const candidates = index.get(parsed.repoId) ?? [] - candidates.push({ path: parsed.worktreePath, meta }) + candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) index.set(parsed.repoId, candidates) } return index @@ -411,15 +446,24 @@ function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): } function listDisconnectedSshWorktrees( + store: Store, repo: Repo, metaIndex: SshWorktreeMetaIndex ): ReturnType<typeof mergeWorktree>[] { const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>() for (const candidate of metaIndex.get(repo.id) ?? []) { + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) + const meta = + Object.keys(ownershipUpdates).length > 0 + ? { ...candidate.meta, ...ownershipUpdates } + : candidate.meta + if (Object.keys(ownershipUpdates).length > 0) { + store.setWorktreeMeta(candidate.id, ownershipUpdates) + } const worktree = mergeWorktree( repo.id, - synthesizeSshGitWorktree(repo, candidate.path, candidate.meta), - candidate.meta + synthesizeSshGitWorktree(repo, candidate.path, meta), + meta ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -451,7 +495,7 @@ function buildDetectedGitWorktrees( return detected } - meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) return toDetectedWorktree({ repo, worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), @@ -468,7 +512,7 @@ function stampAndMergeVisibleDetectedWorktree( repo: Repo, detected: DetectedWorktree ) { - const meta = resolveWorktreeMetaWithDiscoveryStamp(store, detected.id) + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) return mergeWorktree(repo.id, detected, meta, repo.displayName) } @@ -498,6 +542,11 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: repo.path, head: '', branch: '', @@ -539,12 +588,16 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { return ids .map((worktreeId) => { const existing = allMeta[worktreeId] - const meta = existing?.instanceId - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(ownershipUpdates).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...ownershipUpdates, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) return mergeFolderWorkspace(repo, worktreeId, meta) }) .sort((a, b) => { @@ -577,7 +630,12 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { .filter((worktree) => worktree.visible) .map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) - return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {})) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) + const repairedMeta = + meta && Object.keys(ownershipUpdates).length === 0 + ? meta + : store.setWorktreeMeta(worktree.id, ownershipUpdates) + return mergeFolderWorkspace(repo, worktree.id, repairedMeta) }) } @@ -591,6 +649,9 @@ function createFolderWorkspace( const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) const meta = store.setWorktreeMeta(worktreeId, { instanceId, + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), displayName: args.displayName || args.name, lastActivityAt: now, createdAt: now, @@ -685,7 +746,7 @@ export function registerWorktreeHandlers( `${repo.connectionId}:${repo.id}`, `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) try { @@ -697,7 +758,7 @@ export function registerWorktreeHandlers( `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, err ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } } else { gitWorktrees = await listRepoWorktrees(repo) @@ -750,7 +811,7 @@ export function registerWorktreeHandlers( `${repo.connectionId}:${repo.id}`, `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) try { @@ -762,7 +823,7 @@ export function registerWorktreeHandlers( `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, err ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } } else { gitWorktrees = await listRepoWorktrees(repo) @@ -814,7 +875,7 @@ export function registerWorktreeHandlers( } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { - const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) return { repoId: repo.id, authoritative: false, @@ -843,7 +904,7 @@ export function registerWorktreeHandlers( err ) if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) return { repoId: repo.id, authoritative: false, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9d569f46375..06764f66a21 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -16,7 +16,9 @@ import { join } from 'path' import { tmpdir } from 'os' import type { PersistedState, + Project, ProjectGroup, + ProjectHostSetup, Repo, TerminalPaneLayoutNode, TerminalTab, @@ -27,11 +29,13 @@ import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { + getDefaultPersistedState, getDefaultWorkspaceSession, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { SshConnectionStore } from './ssh/ssh-connection-store' // Shared mutable state so the electron mock can reference a per-test directory @@ -175,6 +179,30 @@ const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({ ...overrides }) +const makeProject = (overrides: Partial<Project> = {}): Project => ({ + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +const makeProjectHostSetup = (overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup => ({ + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: '', + path: '/repo', + displayName: 'Project', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1, + ...overrides +}) + const makeTerminalTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({ id: 'tab1', ptyId: 'pty1', @@ -287,6 +315,87 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('backfills project host setup compatibility records from legacy repos on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [ + makeRepo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + makeRepo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'gpu-vm', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ] + }) + + const store = await createStore() + + expect(store.getProjects()).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + sourceRepoIds: ['local-repo', 'remote-repo'] + }) + ]) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + hostId: 'local', + path: '/Users/alice/orca' + }), + expect.objectContaining({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + hostId: 'ssh:gpu-vm', + path: '/home/alice/orca' + }) + ]) + + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.projects).toEqual(store.getProjects()) + expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups()) + }) + + it('preserves independent project host setup records on load', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })], + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + + const store = await createStore() + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ + 'r1', + 'cloud-project::gpu-vm' + ]) + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.projectHostSetups).toContainEqual(independentSetup) + }) + it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() @@ -671,26 +780,6 @@ describe('Store', () => { expect(store.getUI().projectOrderBy).toBe('manual') }) - it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }], - ui: {} - }) - const store = await createStore() - expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false) - }) - - it('hides the manual-default notice for profiles that already chose recent', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }], - ui: { projectOrderBy: 'recent' } - }) - const store = await createStore() - expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true) - }) - // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { @@ -1070,6 +1159,155 @@ describe('Store', () => { expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) + it('derives automation source and run contexts from the project host setup', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + upstream: { owner: 'stablyai', repo: 'orca' }, + connectionId: 'builder' + }) + ) + + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + expect(automation.runContext).toMatchObject({ + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'r1', + repoId: 'r1', + path: '/repo' + }) + expect(automation.sourceContext).toMatchObject({ + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'r1', + repoId: 'r1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + }) + + it('marks runtime-owned automations as remote-host scheduled', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + executionHostId: toRuntimeExecutionHostId('gpu-server'), + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ) + + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + expect(automation.schedulerOwner).toBe('remote_host_service') + expect(automation.runContext).toMatchObject({ + hostId: toRuntimeExecutionHostId('gpu-server') + }) + }) + + it('snapshots automation contexts onto runs', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + store.updateAutomation(automation.id, { sourceContext: null, runContext: null }) + + expect(run.runContext).toEqual(automation.runContext) + expect(run.sourceContext).toEqual(automation.sourceContext) + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + runContext: automation.runContext, + sourceContext: automation.sourceContext + }) + }) + + it('backfills legacy automation contexts on load', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + upstream: { owner: 'stablyai', repo: 'orca' }, + connectionId: 'builder' + }) + ) + const automation = store.createAutomation({ + name: 'Legacy nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + const persisted = readDataFile() as { + automations: Record<string, unknown>[] + automationRuns: Record<string, unknown>[] + } + delete persisted.automations[0].runContext + delete persisted.automations[0].sourceContext + delete persisted.automationRuns[0].runContext + delete persisted.automationRuns[0].sourceContext + writeDataFile(persisted) + + const reloaded = await createStore() + const migratedAutomation = reloaded + .listAutomations() + .find((entry) => entry.id === automation.id) + const migratedRun = reloaded + .listAutomationRuns(automation.id) + .find((entry) => entry.id === run.id) + + expect(migratedAutomation?.runContext).toMatchObject({ + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'r1', + repoId: 'r1', + path: '/repo' + }) + expect(migratedAutomation?.sourceContext).toMatchObject({ + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'r1', + repoId: 'r1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext) + expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext) + }) + it('persists automation precheck config and run results', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -2284,6 +2522,17 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('removeProject removes the derived project host setup compatibility record', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) + + store.removeProject('r1') + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2']) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2']) + }) + it('removeProject deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -2330,6 +2579,278 @@ describe('Store', () => { expect(store.getRepo('r1')!.displayName).toBe('renamed') }) + it('updateRepo keeps project host setup compatibility records in sync', async () => { + const store = await createStore() + store.addRepo(makeRepo({ worktreeBasePath: '../worktrees' })) + + store.updateRepo('r1', { + displayName: 'renamed', + worktreeBasePath: '../new-worktrees', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + + expect(store.getProjects()).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + displayName: 'renamed', + sourceRepoIds: ['r1'] + }) + ]) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ + id: 'r1', + projectId: 'github:stablyai/orca', + displayName: 'renamed', + worktreeBasePath: '../new-worktrees' + }) + ]) + }) + + it('repo mutations preserve independent project host setup records', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [makeRepo({ id: 'r1' })], + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + store.updateRepo('r1', { displayName: 'renamed' }) + store.reorderRepos(['r1']) + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ id: 'r1', displayName: 'renamed' }), + independentSetup + ]) + }) + + it('updates independent project host setup records directly', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + const result = store.updateProjectHostSetup({ + setupId: independentSetup.id, + updates: { + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + } + }) + + expect(result).toEqual({ + project: independentProject, + setup: expect.objectContaining({ + id: independentSetup.id, + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + }) + }) + expect(store.getProjectHostSetups()[0]).toMatchObject({ + displayName: 'GPU VM renamed', + path: '/srv/renamed' + }) + }) + + it('creates independent project host setup records for provisioning flows', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + + const result = store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + setupId: 'cloud-project::gpu-vm', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + + expect(result?.project).toMatchObject({ + id: 'github:stablyai/cloud-project', + displayName: 'Cloud Project' + }) + expect(result?.setup).toMatchObject({ + id: 'cloud-project::gpu-vm', + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + repoId: '', + path: '', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + expect(store.getRepos()).toHaveLength(1) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ id: 'r1', repoId: 'r1' }), + result?.setup + ]) + }) + + it('rejects duplicate project host setup creation for the same host', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm' + }) + store.createProjectHostSetup({ + projectId: independentSetup.projectId, + hostId: independentSetup.hostId, + setupId: independentSetup.id + }) + + expect(() => + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + setupId: 'duplicate' + }) + ).toThrow('Project host setup already exists: cloud-project::gpu-vm') + }) + + it('updates repo-backed project host setup metadata through the repo record', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', displayName: 'Repo', worktreeBasePath: '../old' })) + + const result = store.updateProjectHostSetup({ + setupId: 'r1', + updates: { + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + } + }) + + expect(result?.repo).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + projectHostSetupMethod: 'cloned' + }) + expect(result?.project).toMatchObject({ + id: 'repo:r1', + displayName: 'Repo renamed' + }) + expect(result?.setup).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + }) + }) + + it('rejects repo-backed project host setup path changes', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + + expect(() => + store.updateProjectHostSetup({ + setupId: 'r1', + updates: { path: '/other' } + }) + ).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.') + }) + + it('deletes independent project host setup records without deleting the project', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + const result = store.deleteProjectHostSetup({ setupId: independentSetup.id }) + + expect(result).toEqual({ project: independentProject, setup: independentSetup }) + expect(store.getProjects()).toEqual([independentProject]) + expect(store.getProjectHostSetups()).toEqual([]) + }) + + it('deletes repo-backed project host setups by removing the compatibility repo', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + + const result = store.deleteProjectHostSetup({ setupId: 'r1' }) + + expect(result?.project).toMatchObject({ id: 'repo:r1' }) + expect(result?.setup).toMatchObject({ id: 'r1', repoId: 'r1' }) + expect(result?.repo).toMatchObject({ id: 'r1' }) + expect(store.getRepo('r1')).toBeUndefined() + expect(store.getProjects()).toEqual([]) + expect(store.getProjectHostSetups()).toEqual([]) + expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() + }) + + it('updateRepo preserves repo-backed project host setup method', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + store.updateRepo('r1', { projectHostSetupMethod: 'cloned' }) + + expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned') + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ + id: 'r1', + setupMethod: 'cloned' + }) + ]) + }) + it('updateRepo drops repo icons that fail shared sanitization', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -7279,3 +7800,153 @@ describe('Store', () => { }) }) }) + +describe('Store host-partitioned workspace sessions', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId + }) + + it('migrates a legacy workspaceSession blob into the local partition', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('legacy-repo') + }) + + const store = await createStore() + + // The legacy blob is the 'local' partition; an explicit/default hostId reads it. + expect(store.getWorkspaceSession().activeRepoId).toBe('legacy-repo') + expect(store.getWorkspaceSession('local').activeRepoId).toBe('legacy-repo') + // No data was moved, so a downgrade still finds the legacy field intact. + store.flush() + const persisted = readDataFile() as { workspaceSession?: { activeRepoId?: string } } + expect(persisted.workspaceSession?.activeRepoId).toBe('legacy-repo') + }) + + it('is idempotent: re-loading already-partitioned state preserves all hosts', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('local-repo'), + workspaceSessionsByHostId: { + 'runtime:env-a': makeHostSession('runtime-repo'), + 'ssh:host-b': makeHostSession('ssh-repo') + } + }) + + const readSessionPartitions = (): unknown => { + const data = readDataFile() as { + workspaceSession?: unknown + workspaceSessionsByHostId?: unknown + } + return { + workspaceSession: data.workspaceSession, + workspaceSessionsByHostId: data.workspaceSessionsByHostId + } + } + + const first = await createStore() + first.flush() + const afterFirst = readSessionPartitions() + + const second = await createStore() + second.flush() + const afterSecond = readSessionPartitions() + + // Re-running the partition migration must not move or reshape any host. + expect(afterSecond).toEqual(afterFirst) + expect(second.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') + expect(second.getWorkspaceSession('ssh:host-b').activeRepoId).toBe('ssh-repo') + expect(second.getWorkspaceSession('local').activeRepoId).toBe('local-repo') + }) + + it('drops a stray "local" key in workspaceSessionsByHostId in favor of the legacy blob', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('canonical-local'), + workspaceSessionsByHostId: { + local: makeHostSession('shadow-local') + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local') + }) + + it('isolates writes: setting host A does not mutate host B or local', async () => { + const store = await createStore() + + store.setWorkspaceSession(makeHostSession('repo-local'), 'local') + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + store.setWorkspaceSession(makeHostSession('repo-b'), 'runtime:env-b') + + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') + + // Overwriting host A leaves host B and local untouched. + store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a2') + expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + }) + + it('patches a single host partition without touching the others', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-local'), 'local') + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + + store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a') + + expect(store.getWorkspaceSession('runtime:env-a').activeTabId).toBe('tab-a') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + // Local was never given that tab id. + expect(store.getWorkspaceSession('local').activeTabId).toBeNull() + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + }) + + it('defaults an omitted hostId to the local partition', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + + // No hostId → local, which is still empty/default and unaffected by host A. + store.setWorkspaceSession(makeHostSession('repo-local')) + expect(store.getWorkspaceSession().activeRepoId).toBe('repo-local') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + }) + + it('round-trips host partitions through disk', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + store.flush() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + }) + + it('drops a corrupt host partition to defaults without failing the others', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSessionsByHostId: { + 'runtime:good': makeHostSession('good-repo'), + // activeRepoId must be string|null; a number fails the zod parse. + 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') + // Bad partition collapses to defaults rather than poisoning the map. + expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b10da074e9d..550a38891a3 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -24,6 +24,7 @@ import type { AutomationPrecheckResult, AutomationRunOutputSnapshot, AutomationRun, + AutomationSchedulerOwner, AutomationRunTrigger, AutomationUpdateInput } from '../shared/automations-types' @@ -31,9 +32,19 @@ import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../shared/automation-schedules' +import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import type { PersistedState, + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + RepoProjectHostSetupMethod, Repo, ProjectGroup, FolderWorkspace, @@ -53,10 +64,16 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' +import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { + buildTaskSourceContextFromRepo, + buildWorkspaceRunContext +} from '../shared/task-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' +import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host' import { getDefaultPersistedState, getDefaultNotificationSettings, @@ -71,6 +88,13 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostOrder, + normalizeExecutionHostId, + normalizeVisibleExecutionHostIds, + type ExecutionHostId +} from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' import { isTerminalLeafId, @@ -97,11 +121,6 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' -import { - normalizeLeftSidebarAppearanceMode, - normalizeLeftSidebarTintColor, - normalizeLeftSidebarTintOpacity -} from '../shared/left-sidebar-appearance' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -243,6 +262,39 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc ) } +/** Normalize the persisted non-'local' host partitions. 'local' is intentionally + * dropped here — it is the legacy workspaceSession blob — so the two surfaces + * never diverge. Each partition is zod-validated independently: a corrupt host + * drops to defaults without taking out the others. Idempotent: re-running on an + * already-normalized map yields the same shape. */ +function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): Partial<Record<ExecutionHostId, WorkspaceSessionState>> { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {} + } + const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {} + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' belongs in workspaceSession; an invalid/local key here is + // legacy noise and must not shadow the canonical local partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSession(value) + if (!result.ok) { + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + partitions[hostId] = { ...defaults, ...result.value } + } + return partitions +} + function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } @@ -434,11 +486,6 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][ return getDefaultUIState().projectOrderBy } -import { - isExistingPersistedProfile, - resolveProjectOrderManualDefaultNoticeDismissed -} from '../shared/project-order-manual-default-notice' - function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || @@ -572,6 +619,116 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation { } } +function getAutomationContextsForRepo( + repo: Repo | undefined, + projectHostSetups: readonly ProjectHostSetup[] +): Pick<Automation, 'runContext' | 'sourceContext'> { + if (!repo) { + return { + runContext: null, + sourceContext: null + } + } + const projection = projectHostSetupProjectionFromRepos([repo]) + const projectedProject = projection.projects[0] + const projectedSetup = projection.setups[0] + const setup = + projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup + const runContext = setup + ? buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: repo.id, + path: setup.path + }) + : null + const providerIdentity = projectedProject?.providerIdentity + const sourceContext = providerIdentity + ? buildTaskSourceContextFromRepo({ + provider: providerIdentity.provider, + projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id, + repo, + projectHostSetupId: setup?.id, + providerIdentity + }) + : null + return { + runContext, + sourceContext + } +} + +function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner { + if (!repo) { + return 'local_host_service' + } + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (host?.kind === 'ssh') { + return 'ssh_bridge' + } + if (host?.kind === 'runtime') { + return 'remote_host_service' + } + return 'local_host_service' +} + +function backfillLegacyAutomationContexts( + state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'> +): { + state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'> + changed: boolean +} { + let changed = false + const contextsByAutomationId = new Map<string, Pick<Automation, 'runContext' | 'sourceContext'>>() + const automations = (state.automations ?? []).map((automation) => { + const contexts = getAutomationContextsForRepo( + state.repos.find((repo) => repo.id === getAutomationLegacyRepoId(automation)), + state.projectHostSetups ?? [] + ) + const next: Automation = { ...automation } + if (!Object.hasOwn(next, 'runContext')) { + // Why: pre-host-context automations only stored a repo id. Backfill the + // explicit run target once so dispatch/precheck no longer infer it later. + next.runContext = contexts.runContext + changed = true + } + if (!Object.hasOwn(next, 'sourceContext')) { + next.sourceContext = contexts.sourceContext + changed = true + } + contextsByAutomationId.set(next.id, { + runContext: next.runContext ?? null, + sourceContext: next.sourceContext ?? null + }) + return next + }) + const automationRuns = (state.automationRuns ?? []).map((run) => { + const automationContexts = contextsByAutomationId.get(run.automationId) + const next: AutomationRun = { ...run } + if (!Object.hasOwn(next, 'runContext')) { + next.runContext = automationContexts?.runContext ?? null + changed = true + } + if (!Object.hasOwn(next, 'sourceContext')) { + next.sourceContext = automationContexts?.sourceContext ?? null + changed = true + } + return next + }) + if (!changed) { + return { state, changed: false } + } + return { + state: { + ...state, + automations, + automationRuns + }, + changed: true + } +} + type LegacySshTarget = SshTarget & { remoteWorkspaceSyncEnabled?: unknown remoteWorkspaceSyncGracePeriodSeconds?: unknown @@ -809,8 +966,19 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { return owner && repo ? { owner, repo } : undefined } +function sanitizeRepoProjectHostSetupMethod( + value: unknown +): RepoProjectHostSetupMethod | undefined { + return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined +} + function sanitizeRepoUpdatesForPersistence< - T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>> + T extends Partial< + Pick< + Repo, + 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath' | 'projectHostSetupMethod' + > + > >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -845,6 +1013,14 @@ function sanitizeRepoUpdatesForPersistence< delete sanitized.worktreeBasePath } } + if ('projectHostSetupMethod' in sanitized) { + const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod) + if (setupMethod === undefined) { + delete sanitized.projectHostSetupMethod + } else { + sanitized.projectHostSetupMethod = setupMethod + } + } return sanitized } @@ -1636,6 +1812,74 @@ function migrationUnsupportedEntriesEqual( }) } +function projectHostSetupCompatibilityStateEqual( + state: Pick<PersistedState, 'projects' | 'projectHostSetups'>, + nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'> +): boolean { + return ( + JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && + JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) + ) +} + +function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet<string> +): boolean { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) +} + +function mergeProjectHostSetupCompatibilityState( + state: Pick<PersistedState, 'projects' | 'projectHostSetups'>, + repos: readonly Repo[] +): Pick<PersistedState, 'projects' | 'projectHostSetups'> { + const projection = projectHostSetupProjectionFromRepos(repos) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + // Why: legacy/repo-backed setup rows use the repo id as the setup id. Keep + // only independent setup rows here so repo deletion does not leave ghosts. + const independentSetups = (state.projectHostSetups ?? []).filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = (state.projects ?? []) + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) + return { + projects: [...projection.projects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] + } +} + +function makeProjectHostSetupId( + projectId: string, + hostId: ExecutionHostId, + existingIds: ReadonlySet<string>, + requestedId?: string +): string { + const baseId = requestedId?.trim() || `${projectId}::${hostId}` + if (!existingIds.has(baseId)) { + return baseId + } + let suffix = 2 + let candidate = `${baseId}::${suffix}` + while (existingIds.has(candidate)) { + suffix++ + candidate = `${baseId}::${suffix}` + } + return candidate +} + function createMinimalPersistedTerminalTab(args: { worktreeId: string tabId: string @@ -2234,14 +2478,6 @@ export class Store { parsed.settings?.disabledTuiAgents ) const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call( - parsed.settings ?? {}, - 'openLinksInApp' - ) - const migratedOpenLinksInAppPreferencePrompted = - typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean' - ? parsed.settings.openLinksInAppPreferencePrompted - : openLinksInAppWasPersisted if ( parsed.settings?.agentYoloDefaultsMigrated !== true || hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || @@ -2258,12 +2494,6 @@ export class Store { if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } - if ( - parsed.settings?.openLinksInAppPreferencePrompted !== - migratedOpenLinksInAppPreferencePrompted - ) { - this.loadNeedsSave = true - } const normalizedOnboarding = normalizeLoadedOnboardingState( parsed.onboarding, defaults.onboarding @@ -2327,15 +2557,6 @@ export class Store { terminalCustomThemes: normalizeTerminalCustomThemes( parsed.settings?.terminalCustomThemes ), - leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode( - parsed.settings?.leftSidebarAppearanceMode - ), - leftSidebarTintColor: normalizeLeftSidebarTintColor( - parsed.settings?.leftSidebarTintColor - ), - leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity( - parsed.settings?.leftSidebarTintOpacity - ), appIcon: normalizeAppIconId(parsed.settings?.appIcon), uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, @@ -2350,7 +2571,6 @@ export class Store { openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { seedDefaults: true }), - openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted, notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, // Why: new builds read sourceControlAi, but rollback builds still @@ -2491,28 +2711,12 @@ export class Store { parsed.ui?.setupGuideSidebarDismissed, normalizedOnboarding ) - const projectOrderManualDefaultNoticeDismissed = - resolveProjectOrderManualDefaultNoticeDismissed({ - rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed, - rawProjectOrderBy: parsed.ui?.projectOrderBy, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) if ( parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) ) { this.loadNeedsSave = true } - if ( - parsed.ui?.projectOrderManualDefaultNoticeDismissed !== - projectOrderManualDefaultNoticeDismissed - ) { - this.loadNeedsSave = true - } return { ...defaults.ui, ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), @@ -2521,7 +2725,6 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, - projectOrderManualDefaultNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated @@ -2569,6 +2772,15 @@ export class Store { } return { ...defaults.workspaceSession, ...result.value } })(), + // Why: per-host session partitions for non-'local' hosts. 'local' + // stays in workspaceSession (legacy field) so a downgrade still + // reads the user's workspace. Each entry is zod-validated the same + // way as the legacy blob — a corrupt partition drops to that host's + // defaults without poisoning the others. + workspaceSessionsByHostId: parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) @@ -2621,9 +2833,30 @@ export class Store { this.loadNeedsSave = true } + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.loadNeedsSave = true + } + result = { + ...result, + automations: automationContextMigration.state.automations, + automationRuns: automationContextMigration.state.automationRuns + } + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ ...result, - repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []), + repos, + ...projectHostSetupCompatibility, workspaceSession: migratedScrollback.session }) if (folderScopeConnectionMigration.changed) { @@ -2843,6 +3076,101 @@ export class Store { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } + getProjects(): Project[] { + return [...this.state.projects] + } + + getProjectHostSetups(): ProjectHostSetup[] { + return [...this.state.projectHostSetups] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + const project = this.state.projects.find((entry) => entry.id === args.projectId) + if (!project) { + return null + } + const hostId = normalizeExecutionHostId(args.hostId) + if (!hostId) { + throw new Error(`Invalid host ID: ${args.hostId}`) + } + const duplicateSetup = this.state.projectHostSetups.find( + (entry) => entry.projectId === project.id && entry.hostId === hostId + ) + if (duplicateSetup) { + throw new Error(`Project host setup already exists: ${duplicateSetup.id}`) + } + const now = Date.now() + const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id)) + const setup: ProjectHostSetup = { + id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId), + projectId: project.id, + hostId, + repoId: '', + path: args.path?.trim() ?? '', + displayName: args.displayName?.trim() || project.displayName, + ...(args.kind ? { kind: args.kind } : {}), + ...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}), + ...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}), + setupState: args.setupState ?? 'not-set-up', + setupMethod: args.setupMethod ?? 'provisioned', + createdAt: now, + updatedAt: now + } + // Why: this is the first non-repo-backed setup creation path; it must + // persist independently so future repo projection sync does not erase it. + this.state.projectHostSetups.push(setup) + this.scheduleSave() + return { project, setup } + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + const repo = setup.repoId + ? this.state.repos.find((entry) => entry.id === setup.repoId) + : undefined + if (repo) { + const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) + const updatedProject = updated + ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) + : undefined + return updated && updatedProject + ? { project: updatedProject, setup: updated.setup, repo: updated.repo } + : null + } + const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) + return { project, setup: updatedSetup } + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + const repo = setup.repoId + ? this.state.repos.find((entry) => entry.id === setup.repoId) + : undefined + if (repo) { + this.removeProject(repo.id) + return { project, setup, repo: this.hydrateRepo(repo) } + } + this.state.projectHostSetups = this.state.projectHostSetups.filter( + (entry) => entry.id !== setup.id + ) + this.scheduleSave() + return { project, setup } + } + /** * O(1) read of the persisted repo count. Use this when you only need the * count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and @@ -3110,6 +3438,7 @@ export class Store { addRepo(repo: Repo): void { this.state.repos.push(repo) + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() } @@ -3141,12 +3470,14 @@ export class Store { next.push(repo) } this.state.repos = next + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return true } removeProject(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) + this.syncProjectHostSetupCompatibilityState() // Why: presets are repo-scoped, so removing the repo means the presets // can never be referenced again — drop them with the parent. delete this.state.sparsePresetsByRepo[id] @@ -3184,6 +3515,7 @@ export class Store { | 'externalWorktreeVisibilityPromptDismissedAt' | 'projectGroupId' | 'projectGroupOrder' + | 'projectHostSetupMethod' > > & { sourceControlAi?: Repo['sourceControlAi'] | null } ): Repo | null { @@ -3252,20 +3584,109 @@ export class Store { } } Object.assign(repo, sanitizedUpdates) + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return this.hydrateRepo(repo) } + private syncProjectHostSetupCompatibilityState(): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) + this.state.projects = compatibilityState.projects + this.state.projectHostSetups = compatibilityState.projectHostSetups + } + + private updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + if (updates.path !== undefined && updates.path !== repo.path) { + throw new Error( + 'Repo-backed project host setup paths must be changed by re-importing the project.' + ) + } + if (updates.setupState !== undefined && updates.setupState !== 'ready') { + throw new Error('Repo-backed project host setups cannot be marked unavailable.') + } + const repoUpdates: Parameters<Store['updateRepo']>[1] = {} + if (updates.displayName !== undefined) { + repoUpdates.displayName = updates.displayName + } + if (updates.worktreeBasePath !== undefined) { + repoUpdates.worktreeBasePath = updates.worktreeBasePath + } + if (updates.kind !== undefined) { + repoUpdates.kind = updates.kind + } + if (updates.setupMethod === 'provisioned') { + throw new Error('Repo-backed project host setups cannot be marked provisioned.') + } + if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { + repoUpdates.projectHostSetupMethod = updates.setupMethod + } + const updatedRepo = + Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo + if (!updatedRepo) { + return null + } + return { + setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, + repo: updatedRepo + } + } + + private updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + if (updates.displayName !== undefined) { + setup.displayName = updates.displayName.trim() || setup.displayName + } + if (updates.path !== undefined) { + setup.path = updates.path.trim() || setup.path + } + if (updates.worktreeBasePath !== undefined) { + const worktreeBasePath = updates.worktreeBasePath.trim() + if (worktreeBasePath) { + setup.worktreeBasePath = worktreeBasePath + } else { + delete setup.worktreeBasePath + } + } + if (updates.kind !== undefined) { + setup.kind = updates.kind + } + if (updates.gitUsername !== undefined) { + const gitUsername = updates.gitUsername.trim() + if (gitUsername) { + setup.gitUsername = gitUsername + } else { + delete setup.gitUsername + } + } + if (updates.setupState !== undefined) { + setup.setupState = updates.setupState + } + if (updates.setupMethod !== undefined) { + setup.setupMethod = updates.setupMethod + } + setup.updatedAt = Date.now() + this.scheduleSave() + return setup + } + private hydrateRepo(repo: Repo): Repo { const { repoIcon: rawRepoIcon, upstream: rawUpstream, sourceControlAi: rawSourceControlAi, + projectHostSetupMethod: rawProjectHostSetupMethod, ...repoWithoutIcon } = repo const repoIcon = sanitizeRepoIcon(rawRepoIcon) const upstream = sanitizeRepoUpstream(rawUpstream) const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi) + const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) const gitUsername = isFolderRepo(repo) ? '' : (this.gitUsernameCache.get(repo.path) ?? @@ -3280,6 +3701,7 @@ export class Store { ...(repoIcon !== undefined ? { repoIcon } : {}), ...(upstream !== undefined ? { upstream } : {}), ...(sourceControlAi !== undefined ? { sourceControlAi } : {}), + ...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}), kind: isFolderRepo(repo) ? 'folder' : 'git', gitUsername, hookSettings: { @@ -3340,16 +3762,20 @@ export class Store { const repo = this.state.repos.find((entry) => entry.id === input.projectId) const now = Date.now() const executionTargetType = repo?.connectionId ? 'ssh' : 'local' + const schedulerOwner = getAutomationSchedulerOwner(repo) + const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) const automation: Automation = { id: randomUUID(), name: input.name.trim() || 'Untitled automation', prompt: input.prompt, precheck: normalizeAutomationPrecheck(input.precheck), agentId: input.agentId, + runContext: input.runContext ?? contexts.runContext, + sourceContext: input.sourceContext ?? contexts.sourceContext, projectId: input.projectId, executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', - schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service', + schedulerOwner, workspaceMode: input.workspaceMode, workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, @@ -3379,6 +3805,8 @@ export class Store { const repoId = updates.projectId ?? current.projectId const repo = this.state.repos.find((entry) => entry.id === repoId) const executionTargetType = repo?.connectionId ? 'ssh' : 'local' + const schedulerOwner = getAutomationSchedulerOwner(repo) + const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) const rrule = updates.rrule ?? current.rrule const dtstart = updates.dtstart ?? current.dtstart const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined @@ -3392,9 +3820,19 @@ export class Store { ? normalizeAutomationPrecheck(updates.precheck) : normalizeAutomationPrecheck(current.precheck), projectId: repoId, + runContext: Object.hasOwn(updates, 'runContext') + ? (updates.runContext ?? null) + : updates.projectId !== undefined + ? contexts.runContext + : (current.runContext ?? contexts.runContext), + sourceContext: Object.hasOwn(updates, 'sourceContext') + ? (updates.sourceContext ?? null) + : updates.projectId !== undefined + ? contexts.sourceContext + : (current.sourceContext ?? contexts.sourceContext), executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', - schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service', + schedulerOwner, workspaceMode, workspaceId: workspaceMode === 'existing' @@ -3450,6 +3888,8 @@ export class Store { const run: AutomationRun = { id: randomUUID(), automationId: automation.id, + runContext: automation.runContext ?? null, + sourceContext: automation.sourceContext ?? null, title: `${automation.name} run ${runNumber}`, scheduledFor, status: 'pending', @@ -3664,21 +4104,6 @@ export class Store { updates.terminalCustomThemes ) } - if ('leftSidebarAppearanceMode' in updates) { - sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode( - updates.leftSidebarAppearanceMode - ) - } - if ('leftSidebarTintColor' in updates) { - sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor( - updates.leftSidebarTintColor - ) - } - if ('leftSidebarTintOpacity' in updates) { - sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity( - updates.leftSidebarTintOpacity - ) - } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: @@ -3795,6 +4220,10 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), + visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( + this.state.ui?.visibleWorkspaceHostIds + ), + workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( this.state.ui?.browserDefaultZoomLevel ), @@ -3861,6 +4290,14 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + visibleWorkspaceHostIds: + updates.visibleWorkspaceHostIds !== undefined + ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) + : normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds), + workspaceHostOrder: + updates.workspaceHostOrder !== undefined + ? normalizeExecutionHostOrder(updates.workspaceHostOrder) + : normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel ), @@ -3985,8 +4422,19 @@ export class Store { // ── Workspace Session ───────────────────────────────────────────── - getWorkspaceSession(): PersistedState['workspaceSession'] { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() + /** Resolve an execution host argument to a canonical id. Unknown/empty + * values fall back to 'local' so legacy callers without a hostId keep + * reading and writing the local partition exactly as before. */ + private resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return this.state.workspaceSession ?? getDefaultWorkspaceSession() + } + return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } readTerminalScrollbackSnapshot(ref: string): string | null { @@ -3999,7 +4447,30 @@ export class Store { return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) } - setWorkspaceSession(session: PersistedState['workspaceSession']): void { + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.setLocalWorkspaceSession(session) + return + } + this.setHostWorkspaceSession(resolved, session) + } + + /** Persist a non-'local' host partition. The PTY-binding race protections in + * setLocalWorkspaceSession only apply to the local daemon, so remote hosts + * take the lighter prune-and-store path. */ + private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + ) + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: pruned + } + this.scheduleSave() + } + + private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void { session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) @@ -4148,22 +4619,30 @@ export class Store { this.scheduleSave() } - patchWorkspaceSession(patch: WorkspaceSessionPatch): void { + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) // Why: the renderer's debounced hot path sends only changed top-level // session slices. Scalar/UI patches avoid the terminal normalization path; // terminal topology/layout patches still reuse the stale-PTY protections. let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(), + ...this.getWorkspaceSession(resolved), ...patch } if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next) + this.setWorkspaceSession(next, resolved) return } if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } - this.state.workspaceSession = next + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = next + } else { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolved]: next + } + } this.scheduleSave() } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 35a0bc60b2c..289513ffc3f 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -6,6 +6,7 @@ type MockMultiplexer = { request: ReturnType<typeof vi.fn> notify: ReturnType<typeof vi.fn> onNotification: ReturnType<typeof vi.fn> + onNotificationByMethod: ReturnType<typeof vi.fn> dispose: ReturnType<typeof vi.fn> isDisposed: ReturnType<typeof vi.fn> } @@ -15,6 +16,7 @@ function createMockMux(): MockMultiplexer { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn(), onNotification: vi.fn(), + onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false) } @@ -79,6 +81,60 @@ describe('SshGitProvider', () => { expect(result).toEqual(['dist/bundle.js']) }) + it('clone sends git.clone request and forwards matching progress notifications', async () => { + const unsubscribe = vi.fn() + const onProgress = vi.fn() + mux.onNotificationByMethod.mockReturnValue(unsubscribe) + mux.request.mockImplementationOnce(async (_method, params) => { + const progressHandler = mux.onNotificationByMethod.mock.calls[0][1] + progressHandler({ + progressId: params.progressId, + phase: 'Receiving objects', + percent: 42 + }) + progressHandler({ + progressId: 'other-clone', + phase: 'Receiving objects', + percent: 99 + }) + return { stdout: '', stderr: '' } + }) + + await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', { + timeoutMs: 1000, + onProgress + }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.clone', + expect.objectContaining({ + args: ['clone', '--progress', '--', 'url', 'repo'], + cwd: '/home/user', + progressId: expect.stringMatching(/^clone-/) + }), + { signal: undefined, timeoutMs: 1000 } + ) + expect(mux.onNotificationByMethod).toHaveBeenCalledWith( + 'git.cloneProgress', + expect.any(Function) + ) + expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 }) + expect(onProgress).toHaveBeenCalledTimes(1) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('reports an actionable reconnect message when the relay does not support cloning', async () => { + const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number } + methodNotFound.code = -32601 + mux.request.mockRejectedValueOnce(methodNotFound) + + await expect( + provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user') + ).rejects.toThrow( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + }) + it('getHistory sends git.history request', async () => { const historyResult = { items: [], @@ -176,6 +232,32 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) }) + it('exec forwards abort and timeout options to the relay request', async () => { + const controller = new AbortController() + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + + await provider.exec( + ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], + '/home/user', + { + signal: controller.signal, + timeoutMs: 60_000 + } + ) + + expect(mux.request).toHaveBeenCalledWith( + 'git.exec', + { + args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], + cwd: '/home/user' + }, + { + signal: controller.signal, + timeoutMs: 60_000 + } + ) + }) + it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => { mux.request.mockImplementation(async (method, payload) => { expect(method).toBe('git.exec') diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 477555df9e6..acbbf3a9be1 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -21,6 +21,7 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { describeMaxBufferOverflowError, isMaxBufferOverflowError @@ -60,7 +61,11 @@ export class SshGitProvider implements IGitProvider { private nonInteractiveExecQueues = new Map<string, NonInteractiveExecQueueEntry[]>() private loggedWorktreeIsCleanFallback = false - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) { this.connectionId = connectionId this.mux = mux } @@ -69,6 +74,10 @@ export class SshGitProvider implements IGitProvider { return this.connectionId } + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + async getStatus( worktreePath: string, options?: { includeIgnored?: boolean } @@ -540,13 +549,64 @@ export class SshGitProvider implements IGitProvider { await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) } - async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { - return (await this.mux.request('git.exec', { args, cwd })) as { + async exec( + args: string[], + cwd: string, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise<{ stdout: string; stderr: string }> { + const result = options + ? await this.mux.request('git.exec', { args, cwd }, options) + : await this.mux.request('git.exec', { args, cwd }) + return result as { stdout: string stderr: string } } + async clone( + args: string[], + cwd: string, + options?: { + signal?: AbortSignal + timeoutMs?: number + onProgress?: (progress: { phase: string; percent: number }) => void + } + ): Promise<{ stdout: string; stderr: string }> { + const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}` + const unsubscribe = options?.onProgress + ? this.mux.onNotificationByMethod('git.cloneProgress', (params) => { + if (params.progressId !== progressId) { + return + } + const phase = params.phase + const percent = params.percent + if (typeof phase === 'string' && typeof percent === 'number') { + options.onProgress?.({ phase, percent }) + } + }) + : undefined + try { + const result = await this.mux.request( + 'git.clone', + { args, cwd, progressId }, + { signal: options?.signal, timeoutMs: options?.timeoutMs } + ) + return result as { + stdout: string + stderr: string + } + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } finally { + unsubscribe?.() + } + } + async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { return (await this.mux.request('git.isGitRepo', { dirPath })) as { isRepo: boolean diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d699600350c..5be9472f60f 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -217,7 +217,11 @@ export type IGitProvider = { renameCurrentBranch?(worktreePath: string, newBranch: string): Promise<void> isGitRepo(path: string): boolean isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> - exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + exec( + args: string[], + cwd: string, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise<{ stdout: string; stderr: string }> getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise<string | null> worktreeIsClean( worktreePath: string, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5460a02efe9..29e038ad544 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -950,12 +950,23 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') + expect(status.capabilities).toContain('project-host-setup.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) }) + it('advertises browser screencast only when a renderer window is available', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -9848,6 +9859,47 @@ describe('OrcaRuntimeService', () => { ) }) + it('publishes headless mobile session agent identity with synthesized PTY status', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {} + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'claude' + }) + runtime.onPtyData('pty-agent', '\x1b]0;✳ Claude Code\x07', Date.now()) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(created.tab).toMatchObject({ + type: 'terminal', + launchAgent: 'claude' + }) + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'claude' + }) + }) + ]) + }) + it('rejects disabled mobile session agent launches before spawning', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fe0722d6fda..774e1c567e7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25,6 +25,7 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' +import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { createHash, randomUUID } from 'crypto' import { homedir } from 'os' import { isAbsolute, join, resolve } from 'path' @@ -51,6 +52,17 @@ import type { GitHubOwnerRepo, GlobalSettings, PersistedUIState, + Project, + ProjectHostSetup, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, Repo, RemoveWorktreeResult, StatsSummary, @@ -106,6 +118,10 @@ import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear-agent-access' import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' +import { + getProjectHostSetupForRepo, + getProjectHostSetupWorktreeMeta +} from '../../shared/project-host-setup-projection' import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' @@ -586,6 +602,11 @@ type RuntimeStore = { getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] + getProjects?: Store['getProjects'] + getProjectHostSetups?: Store['getProjectHostSetups'] + createProjectHostSetup?: Store['createProjectHostSetup'] + updateProjectHostSetup?: Store['updateProjectHostSetup'] + deleteProjectHostSetup?: Store['deleteProjectHostSetup'] getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] updateProjectGroup?: Store['updateProjectGroup'] @@ -1041,6 +1062,11 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: repo.path, head: '', branch: '', @@ -1898,6 +1924,8 @@ export class OrcaRuntimeService { prompt: input.prompt, precheck: input.precheck, agentId: input.agentId, + runContext: input.runContext, + sourceContext: input.sourceContext, projectId: target.projectId, workspaceMode: target.workspaceMode, workspaceId: target.workspaceId, @@ -1929,6 +1957,12 @@ export class OrcaRuntimeService { if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { patch.agentId = updates.agentId } + if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) { + patch.runContext = updates.runContext + } + if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) { + patch.sourceContext = updates.sourceContext + } if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { patch.baseBranch = updates.baseBranch } @@ -2069,6 +2103,9 @@ export class OrcaRuntimeService { } getStatus(): RuntimeStatus { + const capabilities = this.getAvailableAuthoritativeWindow() + ? [...RUNTIME_CAPABILITIES] + : RUNTIME_CAPABILITIES.filter((capability) => capability !== 'browser.screencast.v1') return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -2078,7 +2115,9 @@ export class OrcaRuntimeService { liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: [...RUNTIME_CAPABILITIES], + // Why: headless orca serve cannot create/stream BrowserViews, so clients + // must not treat browser panes as supported just because runtime RPC is up. + capabilities, hostPlatform: process.platform, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION @@ -2846,8 +2885,6 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } - let activatedTab: RuntimeMobileSessionSnapshotTab = tab - if (tab.type === 'terminal') { const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find( (candidate) => candidate.type === 'terminal' && candidate.id === tab.id @@ -2862,11 +2899,18 @@ export class OrcaRuntimeService { if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined try { - await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, { - tabId: tab.parentTabId, - leafId: tab.leafId, - sessionId - }) + await this.createHeadlessMobileSessionTerminal( + worktreeId, + true, + undefined, + undefined, + { + tabId: tab.parentTabId, + leafId: tab.leafId, + sessionId + }, + tab.launchAgent + ) } catch (err) { if (sessionId && parseAppSshPtyId(sessionId)) { // Why: an expired SSH reattach clears durable bindings in the store, @@ -2887,8 +2931,16 @@ export class OrcaRuntimeService { candidate.isActive ) const targetTab = activeSibling ?? tab + if (!this.notifier?.focusTerminal) { + if ( + !targetTab.isActive && + this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab) + ) { + this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab) + } + return this.getMobileSessionTabsForWorktree(worktreeId) + } this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId) - activatedTab = targetTab } else if (tab.type === 'browser') { // Why: browser mobile tabs are renderer-owned unified tabs; focusing the // session tab keeps desktop tab order/group state authoritative. @@ -2896,56 +2948,9 @@ export class OrcaRuntimeService { } else { this.notifier?.focusEditorTab?.(tab.id, worktreeId) } - - // Why: serve/headless snapshots have no renderer to re-publish focus, but - // merged epochs can still contain renderer-owned group state. - if ( - !this.getAvailableAuthoritativeWindow() && - this.isPureHeadlessMobileSessionPublication(snapshot!.publicationEpoch) - ) { - this.persistHeadlessMobileSessionActiveTab(worktreeId, snapshot!, activatedTab) - } return this.getMobileSessionTabsForWorktree(worktreeId) } - private persistHeadlessMobileSessionActiveTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - activeTab: RuntimeMobileSessionSnapshotTab - ): void { - const alreadyActive = - snapshot.activeTabId === activeTab.id && - snapshot.activeTabType === activeTab.type && - snapshot.tabs.every((candidate) => candidate.isActive === (candidate.id === activeTab.id)) - if (alreadyActive) { - // Why: re-activating the already-active tab must not bump snapshotVersion, - // or every redundant activation would force a remote re-render. - return - } - const tabs = snapshot.tabs.map((candidate) => ({ - ...candidate, - isActive: candidate.id === activeTab.id - })) - const terminalTabs = tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' - ) - const next: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: activeTab.id, - activeTabType: activeTab.type, - tabGroups: this.buildHeadlessMobileSessionTabGroups( - worktreeId, - terminalTabs, - activeTab.type === 'terminal' ? activeTab : null, - snapshot.tabGroups - ), - tabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, next) - this.notifyMobileSessionTabsChanged(worktreeId) - } - private shouldMaterializeHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab @@ -2956,6 +2961,79 @@ export class OrcaRuntimeService { ) } + private shouldPersistHeadlessMobileSessionActivation( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + if (snapshot.publicationEpoch.includes(':headless-merge:')) { + return false + } + if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') { + return false + } + return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab) + } + + private activateHeadlessMobileSessionTerminalTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + activeTab: RuntimeMobileSessionTerminalTab + ): void { + const tabs = snapshot.tabs.map((candidate) => ({ + ...candidate, + isActive: candidate.id === activeTab.id + })) + const terminalTabs = tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' + ) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: activeTab.id, + activeTabType: 'terminal', + tabGroups: this.buildHeadlessMobileSessionTabGroups( + worktreeId, + terminalTabs, + activeTab, + snapshot.tabGroups + ), + tabs + } + this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private persistHeadlessTerminalActiveLeaf( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId] + const nextLayouts = existingLayout + ? { + ...session.terminalLayoutsByTabId, + [tab.parentTabId]: { + ...this.cloneTerminalLayoutSnapshot(existingLayout), + activeLeafId: tab.leafId + } + } + : session.terminalLayoutsByTabId + this.store.setWorkspaceSession({ + ...session, + activeTabId: tab.parentTabId, + activeTabIdByWorktree: { + ...session.activeTabIdByWorktree, + [worktreeId]: tab.parentTabId + }, + terminalLayoutsByTabId: nextLayouts + }) + } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -6632,6 +6710,106 @@ export class OrcaRuntimeService { return this.store?.getRepos() ?? [] } + listProjects(): Project[] { + return this.store?.getProjects?.() ?? [] + } + + listProjectHostSetups(): ProjectHostSetup[] { + return this.store?.getProjectHostSetups?.() ?? [] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult { + if (!this.store?.createProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.createProjectHostSetup(args) + if (!result) { + throw new Error(`Project not found: ${args.projectId}`) + } + return result + } + + async setupProjectExistingFolder( + args: ProjectHostSetupExistingFolderArgs + ): Promise<ProjectHostSetupResult> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const existingProject = this.listProjects().find((project) => project.id === args.projectId) + if (!existingProject) { + throw new Error(`Project not found: ${args.projectId}`) + } + let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git') + let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + if (setup.projectId !== args.projectId) { + if ( + !existingProject.providerIdentity || + existingProject.providerIdentity.provider !== 'github' + ) { + throw new Error('Imported folder does not match the selected project identity.') + } + const updated = this.store.updateRepo(repo.id, { + upstream: { + owner: existingProject.providerIdentity.owner, + repo: existingProject.providerIdentity.repo + } + }) + if (!updated) { + throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) + } + repo = updated + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + } + const setupMethod = args.setupMethod ?? 'imported-existing-folder' + const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) + if (!updated) { + throw new Error( + `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` + ) + } + repo = updated + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + const project = this.listProjects().find((entry) => entry.id === setup.projectId) + if (!project) { + throw new Error(`Project setup was created without a project record: ${setup.projectId}`) + } + return { project, setup, repo } + } + + async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> { + const repo = await this.cloneRepo(args.url, args.destination) + return await this.setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName, + setupMethod: 'cloned' + }) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { + if (!this.store?.updateProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.updateProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + return result + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult { + if (!this.store?.deleteProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.deleteProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + return result + } + listProjectGroups(): ProjectGroup[] { return this.store?.getProjectGroups?.() ?? [] } @@ -7237,8 +7415,7 @@ export class OrcaRuntimeService { } else if (code === 0) { resolve() } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) + reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)) } } proc.on('error', (error) => { @@ -9314,6 +9491,7 @@ export class OrcaRuntimeService { const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) const meta = this.store.setWorktreeMeta(worktreeId, { instanceId, + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), displayName: args.displayName?.trim() || args.name, lastActivityAt: now, createdAt: now, @@ -9696,6 +9874,7 @@ export class OrcaRuntimeService { // and later recreated, creation must mint a fresh instance identity so // stale lineage records tied to the old occupant fail validation. instanceId: randomUUID(), + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't @@ -9815,6 +9994,7 @@ export class OrcaRuntimeService { let didSpawnStartup = false let didSpawnSetup = false let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null if (effectiveStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane @@ -9837,6 +10017,7 @@ export class OrcaRuntimeService { } didSpawnStartup = true startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -9956,6 +10137,16 @@ export class OrcaRuntimeService { : {}), ...(addResult.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } + : {}), + ...(didSpawnStartup && startupTerminalHandle + ? { + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + surface: 'background' as const + } + } : {}) } } @@ -10047,6 +10238,7 @@ export class OrcaRuntimeService { let didSpawnStartup = false let didSpawnSetup = false let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null if (args.startup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent @@ -10070,6 +10262,7 @@ export class OrcaRuntimeService { } didSpawnStartup = true startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -10171,7 +10364,20 @@ export class OrcaRuntimeService { } } - return warning ? { ...result, warning } : result + const resultWithStartupTerminal = + didSpawnStartup && startupTerminalHandle + ? { + ...result, + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + surface: 'background' as const + } + } + : result + + return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } /** @@ -11525,7 +11731,7 @@ export class OrcaRuntimeService { console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) } } - return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface } + return { handle, tabId, worktreeId: workspace.id, title: opts.title ?? null, surface } } this.assertGraphReady() @@ -11575,7 +11781,36 @@ export class OrcaRuntimeService { // populates this.leaves may not have arrived yet. Wait for the leaf to // appear so we can return a valid handle the caller can use right away. const handle = await this.waitForTerminalHandle(reply.tabId) - return { handle, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' } + return { + handle, + tabId: reply.tabId, + worktreeId: worktreeId ?? '', + title: reply.title, + surface: 'visible' + } + } + + async launchAgentTerminal( + worktreeSelector: string, + opts: { agent: TuiAgent; prompt: string; title?: string } + ): Promise<RuntimeTerminalCreate> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('Repository for the selected workspace is no longer available.') + } + const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + if (repo.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) + } else { + this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) + } + return await this.createTerminal(`id:${worktree.id}`, { + command: startup.startup.command, + env: startup.startup.env, + telemetry: startup.startup.telemetry, + title: opts.title + }) } async createMobileSessionTerminal( @@ -11609,7 +11844,9 @@ export class OrcaRuntimeService { worktreeId, opts.activate !== false, opts.afterTabId, - command + command, + undefined, + opts.agent ) } const requestId = randomUUID() @@ -11694,7 +11931,8 @@ export class OrcaRuntimeService { activate: boolean, afterTabId?: string, command?: string, - identity?: { tabId: string; leafId: string; sessionId?: string } + identity?: { tabId: string; leafId: string; sessionId?: string }, + launchAgent?: TuiAgent ): Promise<RuntimeMobileSessionCreateTerminalResult> { const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) const repo = this.store?.getRepo(worktree.repoId) @@ -11742,6 +11980,7 @@ export class OrcaRuntimeService { leafId, ptyId: livePty.pty.ptyId, title: terminal.title ?? livePty.pty.title ?? 'Terminal', + ...(launchAgent ? { launchAgent } : {}), parentLayout, isActive: activate } @@ -13445,15 +13684,10 @@ export class OrcaRuntimeService { ) } - private isPureHeadlessMobileSessionPublication(publicationEpoch: string): boolean { - return ( - publicationEpoch.startsWith('headless:') || publicationEpoch.startsWith('headless-hydrated:') - ) - } - private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { return ( - this.isPureHeadlessMobileSessionPublication(publicationEpoch) || + publicationEpoch.startsWith('headless:') || + publicationEpoch.startsWith('headless-hydrated:') || publicationEpoch.includes(':headless-merge:') ) } @@ -13718,6 +13952,7 @@ export class OrcaRuntimeService { title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), + ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), isActive: tab.isActive, @@ -13795,6 +14030,7 @@ export class OrcaRuntimeService { stateStartedAt: now, paneKey: this.getMobileTerminalPaneKey(tab), ...(terminalHandle ? { terminalHandle } : {}), + ...(tab.launchAgent ? { agentType: tab.launchAgent } : {}), worktreeId: pty.worktreeId, tabId: tab.parentTabId, terminalTitle: getLatestPtyTitle(pty) ?? tab.title, @@ -15103,6 +15339,72 @@ export class OrcaRuntimeService { return link } + private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> { + const currentPath = resolve(cwd) + let best: ResolvedWorktree | null = null + for (const candidate of await this.listResolvedWorktrees()) { + if (!isPathInsideOrEqual(candidate.path, currentPath)) { + continue + } + if (!best || candidate.path.length > best.path.length) { + best = candidate + } + } + return best + } + + linearListIssues( + filter?: LinearListFilter, + limit = 20, + workspaceId?: LinearWorkspaceSelection, + teamId?: string + ): ReturnType<typeof listLinearIssues> { + return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId) + } + + linearCreateIssue( + teamId: string, + title: string, + description?: string, + workspaceId?: string, + parentIssueId?: string, + projectId?: string | null, + options?: { + stateId?: string + priority?: number + estimate?: number | null + dueDate?: string | null + assigneeId?: string | null + labelIds?: string[] + } + ): ReturnType<typeof createLinearIssue> { + return createLinearIssue(teamId, title, description, workspaceId, { + parentId: parentIssueId, + projectId, + ...options + }) + } + + linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> { + return getLinearIssue(id, workspaceId) + } + + linearUpdateIssue( + id: string, + updates: LinearIssueUpdate, + workspaceId?: string + ): ReturnType<typeof updateLinearIssue> { + return updateLinearIssue(id, updates, workspaceId) + } + + linearAddIssueComment( + issueId: string, + body: string, + workspaceId?: string + ): ReturnType<typeof addLinearIssueComment> { + return addLinearIssueComment(issueId, body, workspaceId) + } + async linearIssueSetState(params: { input?: string current?: boolean @@ -16473,72 +16775,6 @@ export class OrcaRuntimeService { } } - private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> { - const currentPath = resolve(cwd) - let best: ResolvedWorktree | null = null - for (const candidate of await this.listResolvedWorktrees()) { - if (!isPathInsideOrEqual(candidate.path, currentPath)) { - continue - } - if (!best || candidate.path.length > best.path.length) { - best = candidate - } - } - return best - } - - linearListIssues( - filter?: LinearListFilter, - limit = 20, - workspaceId?: LinearWorkspaceSelection, - teamId?: string - ): ReturnType<typeof listLinearIssues> { - return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId) - } - - linearCreateIssue( - teamId: string, - title: string, - description?: string, - workspaceId?: string, - parentIssueId?: string, - projectId?: string | null, - options?: { - stateId?: string - priority?: number - estimate?: number | null - dueDate?: string | null - assigneeId?: string | null - labelIds?: string[] - } - ): ReturnType<typeof createLinearIssue> { - return createLinearIssue(teamId, title, description, workspaceId, { - parentId: parentIssueId, - projectId, - ...options - }) - } - - linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> { - return getLinearIssue(id, workspaceId) - } - - linearUpdateIssue( - id: string, - updates: LinearIssueUpdate, - workspaceId?: string - ): ReturnType<typeof updateLinearIssue> { - return updateLinearIssue(id, updates, workspaceId) - } - - linearAddIssueComment( - issueId: string, - body: string, - workspaceId?: string - ): ReturnType<typeof addLinearIssueComment> { - return addLinearIssueComment(issueId, body, workspaceId) - } - linearIssueComments( issueId: string, workspaceId?: string diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index c4143fb32b8..fe918875d22 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -30,6 +30,23 @@ describe('automation RPC methods', () => { prompt: 'Review changes', precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + }, + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'setup-local', + repoId: 'repo-local', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }, repo: 'repo-1', reuseSession: true, rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', @@ -59,6 +76,8 @@ describe('automation RPC methods', () => { prompt: 'Review changes', precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', + runContext: expect.objectContaining({ hostId: 'runtime:gpu' }), + sourceContext: expect.objectContaining({ hostId: 'local' }), repo: 'repo-1', reuseSession: true }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 20824d8f2f8..858e80596d0 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -4,6 +4,8 @@ import { MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, normalizeAutomationPrecheckTimeoutSeconds } from '../../../../shared/automation-precheck' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { defineMethod, type RpcMethod } from '../core' import { @@ -20,6 +22,14 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { }) const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() +const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId +}) const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { message: 'Invalid automation trigger' @@ -43,6 +53,43 @@ const OptionalNullablePlainString = z .pipe(z.union([z.string(), z.null(), z.undefined()])) .optional() +const TaskProviderIdentity = z + .custom<SharedTaskProviderIdentity>( + (value) => + value !== null && + typeof value === 'object' && + 'provider' in value && + ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) + ) + .optional() + .nullable() + +const TaskSourceContext = z + .object({ + kind: z.literal('task-source'), + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + projectId: requiredString('Missing source project id'), + hostId: ExecutionHostId, + projectHostSetupId: OptionalNullablePlainString, + repoId: OptionalNullablePlainString, + providerIdentity: TaskProviderIdentity, + accountLabel: OptionalNullablePlainString + }) + .optional() + .nullable() + +const WorkspaceRunContext = z + .object({ + kind: z.literal('workspace-run'), + projectId: requiredString('Missing run project id'), + hostId: ExecutionHostId, + projectHostSetupId: requiredString('Missing project host setup id'), + repoId: requiredString('Missing repo id'), + path: requiredString('Missing run path') + }) + .optional() + .nullable() + const AutomationId = z.object({ id: requiredString('Missing automation id') }) @@ -56,6 +103,8 @@ const AutomationCreate = z.object({ prompt: requiredString('Missing automation prompt'), precheck: AutomationPrecheck, agentId: TuiAgent, + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, repo: OptionalString, workspace: OptionalString, workspaceMode: AutomationWorkspaceMode, @@ -73,6 +122,8 @@ const AutomationUpdateFields = z.object({ prompt: OptionalString, precheck: AutomationPrecheck, agentId: TuiAgent.optional(), + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, repo: OptionalString, workspace: OptionalString, workspaceMode: AutomationWorkspaceMode, diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index f607f801cf3..c65eed036de 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -158,6 +158,9 @@ const UiUpdate = z hideSleepingWorkspaces: z.boolean().optional(), showSleepingWorkspaces: z.boolean().optional(), showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + workspaceHostOrder: z.array(z.string()).optional(), hideDefaultBranchWorkspace: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts new file mode 100644 index 00000000000..ba874fda2da --- /dev/null +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -0,0 +1,122 @@ +import { z } from 'zod' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' + +const ProjectHostSetupExistingFolder = z.object({ + projectId: requiredString('Missing project ID'), + hostId: requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return hostId + }), + path: requiredString('Missing project path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() +}) + +const ProjectHostSetupClone = z.object({ + projectId: requiredString('Missing project ID'), + hostId: requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return hostId + }), + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination'), + displayName: OptionalString +}) + +const ProjectHostSetupCreate = z.object({ + projectId: requiredString('Missing project ID'), + hostId: requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return hostId + }), + setupId: OptionalString, + path: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + worktreeBasePath: OptionalString, + gitUsername: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() +}) + +const ProjectHostSetupUpdate = z.object({ + setupId: requiredString('Missing setup ID'), + updates: z.object({ + displayName: OptionalString, + path: OptionalString, + worktreeBasePath: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z + .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) + .optional(), + gitUsername: OptionalString, + kind: z.enum(['git', 'folder']).optional() + }) +}) + +const ProjectHostSetupDelete = z.object({ + setupId: requiredString('Missing setup ID') +}) + +export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'project.list', + params: null, + handler: (_params, { runtime }) => ({ projects: runtime.listProjects() }) + }), + defineMethod({ + name: 'projectHostSetup.list', + params: null, + handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() }) + }), + defineMethod({ + name: 'projectHostSetup.create', + params: ProjectHostSetupCreate, + handler: (params, { runtime }) => ({ + result: runtime.createProjectHostSetup(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.setupExistingFolder', + params: ProjectHostSetupExistingFolder, + handler: async (params, { runtime }) => ({ + result: await runtime.setupProjectExistingFolder(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.clone', + params: ProjectHostSetupClone, + handler: async (params, { runtime }) => ({ + result: await runtime.setupProjectClone(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.update', + params: ProjectHostSetupUpdate, + handler: (params, { runtime }) => ({ + result: runtime.updateProjectHostSetup(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.delete', + params: ProjectHostSetupDelete, + handler: (params, { runtime }) => ({ + result: runtime.deleteProjectHostSetup(params) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 531e0b8589b..a9932985a98 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' const RepoSelector = z.object({ @@ -158,6 +159,7 @@ export const REPO_METHODS: RpcMethod[] = [ params: null, handler: (_params, { runtime }) => ({ repos: runtime.listRepos() }) }), + ...PROJECT_RUNTIME_METHODS, defineMethod({ name: 'projectGroup.list', params: null, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 1a1eab5f084..31ee86e7b9c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -529,7 +529,11 @@ export class SshRelaySession { ) registerSshFilesystemProvider(this.targetId, fsProvider) - const gitProvider = new SshGitProvider(this.targetId, mux) + const gitProvider = new SshGitProvider( + this.targetId, + mux, + this.remoteCliBridgeEnv?.hostPlatform ?? null + ) registerSshGitProvider(this.targetId, gitProvider) this.wireUpPtyEvents(ptyProvider) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0816720ce0..82c873366cf 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,6 +10,7 @@ import type { import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' +import type { TaskSourceContext } from '../shared/task-source-context' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -124,8 +125,18 @@ import type { PRComment, PRInfo, PRRefreshOutcome, + Project, Repo, ProjectGroup, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, FolderWorkspace, ProjectGroupImportResult, ProjectGroupImportMode, @@ -150,6 +161,18 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' + +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type GitHubRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} import type { WarpThemeImportPreview, WarpThemeImportSource @@ -159,6 +182,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { ExecutionHostId } from '../shared/execution-host' import type { FeatureInteractionId } from '../shared/feature-interactions' import type { AddIssueCommentBySlugArgs, @@ -760,6 +784,13 @@ export type PreloadApi = { pickFolder: () => Promise<string | null> pickDirectory: () => Promise<string | null> clone: (args: { url: string; destination: string }) => Promise<Repo> + cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo> + createRemote: (args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> cloneAbort: () => Promise<void> // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. addRemote: (args: { @@ -787,6 +818,16 @@ export type PreloadApi = { }) => Promise<BaseRefSearchResult[]> onChanged: (callback: () => void) => () => void } + projects: { + list: () => Promise<Project[]> + listHostSetups: () => Promise<ProjectHostSetup[]> + createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise<ProjectHostSetupCreateResult> + setupExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise<ProjectHostSetupResult> + updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise<ProjectHostSetupUpdateResult> + deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise<ProjectHostSetupDeleteResult> + } projectGroups: { list: () => Promise<ProjectGroup[]> create: (args: { @@ -1095,11 +1136,13 @@ export type PreloadApi = { issue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number }) => Promise<IssueInfo | null> workItem: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }) => Promise<Omit<GitHubWorkItem, 'repoId'> | null> @@ -1111,22 +1154,22 @@ export type PreloadApi = { number: number type: 'issue' | 'pr' }) => Promise<Omit<GitHubWorkItem, 'repoId'> | null> - workItemDetails: (args: { - repoPath: string - repoId?: string - number: number - type?: 'issue' | 'pr' - }) => Promise<GitHubWorkItemDetails | null> - prFileContents: (args: { - repoPath: string - repoId?: string - prNumber: number - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - }) => Promise<GitHubPRFileContents> + workItemDetails: ( + args: GitHubRepoSelectorArgs & { + number: number + type?: 'issue' | 'pr' + } + ) => Promise<GitHubWorkItemDetails | null> + prFileContents: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => Promise<GitHubPRFileContents> listIssues: (args: { repoPath: string repoId?: string @@ -1135,6 +1178,7 @@ export type PreloadApi = { createIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null title: string body: string labels?: string[] @@ -1149,33 +1193,35 @@ export type PreloadApi = { before?: string noCache?: boolean }) => Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>> - prChecks: (args: { - repoPath: string - repoId?: string - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }) => Promise<PRCheckDetail[]> + prChecks: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => Promise<PRCheckDetail[]> prCheckDetails: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null }) => Promise<PRCheckRunDetails | null> - rerunPRChecks: (args: { - repoPath: string - repoId?: string - prNumber: number - headSha?: string - failedOnly?: boolean - }) => Promise<{ ok: true; count: number } | { ok: false; error: string }> + rerunPRChecks: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + } + ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> prComments: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean @@ -1183,17 +1229,18 @@ export type PreloadApi = { resolveReviewThread: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null threadId: string resolve: boolean }) => Promise<boolean> - setPRFileViewed: (args: { - repoPath: string - repoId?: string - prNumber: number - pullRequestId: string - path: string - viewed: boolean - }) => Promise<boolean> + setPRFileViewed: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + pullRequestId: string + path: string + viewed: boolean + } + ) => Promise<boolean> updatePRTitle: (args: { repoPath: string repoId?: string @@ -1201,75 +1248,83 @@ export type PreloadApi = { title: string prRepo?: GitHubOwnerRepo | null }) => Promise<boolean> - mergePR: (args: { - repoPath: string - repoId?: string - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }) => Promise<{ ok: true } | { ok: false; error: string }> - setPRAutoMerge: (args: { - repoPath: string - repoId?: string - prNumber: number - enabled: boolean - prRepo?: GitHubOwnerRepo | null - }) => Promise<{ ok: true } | { ok: false; error: string }> - updatePRState: (args: { - repoPath: string - repoId?: string - prNumber: number - updates: { state: 'open' | 'closed' } - }) => Promise<{ ok: true } | { ok: false; error: string }> - requestPRReviewers: (args: { - repoPath: string - repoId?: string - prNumber: number - reviewers: string[] - }) => Promise<{ ok: true } | { ok: false; error: string }> - removePRReviewers: (args: { - repoPath: string - repoId?: string - prNumber: number - reviewers: string[] - }) => Promise<{ ok: true } | { ok: false; error: string }> - updateIssue: (args: { - repoPath: string - repoId?: string - number: number - updates: GitHubIssueUpdate - }) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: (args: { - repoPath: string - repoId?: string - number: number - body: string - /** Why: GitHub stores PR conversation comments under `/issues/N/comments` - * too, so the IPC and `gh` call paths are identical. The renderer cache - * key is keyed by the drawer's `type`, so callers pass it through to - * scope the cross-window invalidation broadcast correctly and avoid - * evicting an unrelated PR/issue that happens to share the number. */ - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - }) => Promise<GitHubCommentResult> - addPRReviewCommentReply: (args: { - repoPath: string - repoId?: string - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - }) => Promise<GitHubCommentResult> - addPRReviewComment: ( - args: GitHubPRReviewCommentInput & { repoId?: string } + mergePR: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + setPRAutoMerge: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updatePRState: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + updates: { state: 'open' | 'closed' } + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + requestPRReviewers: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + reviewers: string[] + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + removePRReviewers: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + reviewers: string[] + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updateIssue: ( + args: GitHubRepoSelectorArgs & { + number: number + updates: GitHubIssueUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: ( + args: GitHubRepoSelectorArgs & { + number: number + body: string + /** Why: GitHub stores PR conversation comments under `/issues/N/comments` + * too, so the IPC and `gh` call paths are identical. The renderer cache + * key is keyed by the drawer's `type`, so callers pass it through to + * scope the cross-window invalidation broadcast correctly and avoid + * evicting an unrelated PR/issue that happens to share the number. */ + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } ) => Promise<GitHubCommentResult> - listLabels: (args: { repoPath: string; repoId?: string }) => Promise<string[]> + addPRReviewCommentReply: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<GitHubCommentResult> + addPRReviewComment: ( + args: GitHubPRReviewCommentInput & { + repoId?: string + sourceContext?: TaskSourceContext | null + } + ) => Promise<GitHubCommentResult> + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }) => Promise<string[]> listAssignableUsers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null }) => Promise<GitHubAssignableUser[]> /** * Subscribe to local-mutation broadcasts. Used by the work-item-drawer @@ -1350,117 +1405,136 @@ export type PreloadApi = { force?: boolean host?: string | null }) => Promise<GetGitLabRateLimitResult> - projectSlug: (args: { repoPath: string }) => Promise<GitLabProjectRef | null> - mrForBranch: (args: { - repoPath: string - branch: string - linkedMRIid?: number | null - }) => Promise<MRInfo | null> - mr: (args: { repoPath: string; iid: number }) => Promise<MRInfo | null> - listMRs: (args: { - repoPath: string - state?: MRListState - page?: number - perPage?: number - }) => Promise<ListMergeRequestsResult> + projectSlug: (args: GitLabRepoSelectorArgs) => Promise<GitLabProjectRef | null> + mrForBranch: ( + args: GitLabRepoSelectorArgs & { + branch: string + linkedMRIid?: number | null + } + ) => Promise<MRInfo | null> + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => Promise<MRInfo | null> + listMRs: ( + args: GitLabRepoSelectorArgs & { + state?: MRListState + page?: number + perPage?: number + } + ) => Promise<ListMergeRequestsResult> /** Combined MR + issue list filtered by state. Issues are skipped * when state is 'merged' (issues don't merge). */ - listWorkItems: (args: { - repoPath: string - state?: MRListState - page?: number - perPage?: number - }) => Promise<ListMergeRequestsResult> - issue: (args: { repoPath: string; number: number }) => Promise<GitLabIssueInfo | null> - listIssues: (args: { - repoPath: string - state?: 'opened' | 'closed' | 'all' - assignee?: string - limit?: number - }) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }> - createIssue: (args: { - repoPath: string - title: string - body: string - }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> - updateIssue: (args: { - repoPath: string - number: number - updates: GitLabIssueUpdate - }) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: (args: { - repoPath: string - number: number - body: string - }) => Promise<GitLabCommentResult> - listLabels: (args: { repoPath: string }) => Promise<string[]> - listAssignableUsers: (args: { repoPath: string }) => Promise<GitLabAssignableUser[]> + listWorkItems: ( + args: GitLabRepoSelectorArgs & { + state?: MRListState + page?: number + perPage?: number + } + ) => Promise<ListMergeRequestsResult> + issue: (args: GitLabRepoSelectorArgs & { number: number }) => Promise<GitLabIssueInfo | null> + listIssues: ( + args: GitLabRepoSelectorArgs & { + state?: 'opened' | 'closed' | 'all' + assignee?: string + limit?: number + } + ) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }> + createIssue: ( + args: GitLabRepoSelectorArgs & { + title: string + body: string + } + ) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + updateIssue: ( + args: GitLabRepoSelectorArgs & { + number: number + updates: GitLabIssueUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: ( + args: GitLabRepoSelectorArgs & { + number: number + body: string + } + ) => Promise<GitLabCommentResult> + listLabels: (args: GitLabRepoSelectorArgs) => Promise<string[]> + listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise<GitLabAssignableUser[]> /** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */ - todos: (args: { repoPath: string }) => Promise<GitLabTodo[]> + todos: (args: GitLabRepoSelectorArgs) => Promise<GitLabTodo[]> /** Aggregated dialog payload — body + discussions + pipeline jobs. */ - workItemDetails: (args: { - repoPath: string - iid: number - type: 'issue' | 'mr' - }) => Promise<GitLabWorkItemDetails | null> - closeMR: (args: { - repoPath: string - iid: number - }) => Promise<{ ok: true } | { ok: false; error: string }> - reopenMR: (args: { - repoPath: string - iid: number - }) => Promise<{ ok: true } | { ok: false; error: string }> - mergeMR: (args: { - repoPath: string - iid: number - method?: 'merge' | 'squash' | 'rebase' - }) => Promise<{ ok: true } | { ok: false; error: string }> - updateMR: (args: { - repoPath: string - iid: number - updates: GitLabMRUpdate - }) => Promise<{ ok: true } | { ok: false; error: string }> - updateMRReviewers: (args: { - repoPath: string - iid: number - reviewerIds: number[] - projectRef?: GitLabProjectRef | null - }) => Promise<GitLabMRReviewersUpdateResult> - addMRComment: (args: { - repoPath: string - iid: number - body: string - }) => Promise<GitLabCommentResult> - addMRInlineComment: (args: { - repoPath: string - iid: number - input: GitLabMRInlineCommentInput - projectRef?: GitLabProjectRef | null - }) => Promise<GitLabCommentResult> - resolveMRDiscussion: (args: { - repoPath: string - iid: number - discussionId: string - resolved: boolean - }) => Promise<GitLabDiscussionResolveResult> - jobTrace: (args: { - repoPath: string - jobId: number - projectRef?: GitLabProjectRef | null - }) => Promise<GitLabJobTraceResult> - retryJob: (args: { - repoPath: string - jobId: number - projectRef?: GitLabProjectRef | null - }) => Promise<GitLabRetryJobResult> - workItemByPath: (args: { - repoPath: string - host: string - path: string - iid: number - type: 'issue' | 'mr' - }) => Promise<Omit<GitLabWorkItem, 'repoId'> | null> + workItemDetails: ( + args: GitLabRepoSelectorArgs & { + iid: number + type: 'issue' | 'mr' + } + ) => Promise<GitLabWorkItemDetails | null> + closeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + reopenMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + mergeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + method?: 'merge' | 'squash' | 'rebase' + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updateMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + updates: GitLabMRUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updateMRReviewers: ( + args: GitLabRepoSelectorArgs & { + iid: number + reviewerIds: number[] + projectRef?: GitLabProjectRef | null + } + ) => Promise<GitLabMRReviewersUpdateResult> + addMRComment: ( + args: GitLabRepoSelectorArgs & { + iid: number + body: string + } + ) => Promise<GitLabCommentResult> + addMRInlineComment: ( + args: GitLabRepoSelectorArgs & { + iid: number + input: GitLabMRInlineCommentInput + projectRef?: GitLabProjectRef | null + } + ) => Promise<GitLabCommentResult> + resolveMRDiscussion: ( + args: GitLabRepoSelectorArgs & { + iid: number + discussionId: string + resolved: boolean + } + ) => Promise<GitLabDiscussionResolveResult> + jobTrace: ( + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: GitLabProjectRef | null + } + ) => Promise<GitLabJobTraceResult> + retryJob: ( + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: GitLabProjectRef | null + } + ) => Promise<GitLabRetryJobResult> + workItemByPath: ( + args: GitLabRepoSelectorArgs & { + host: string + path: string + iid: number + type: 'issue' | 'mr' + } + ) => Promise<Omit<GitLabWorkItem, 'repoId'> | null> } linear: { connect: (args: { @@ -1627,8 +1701,6 @@ export type PreloadApi = { dismiss: () => Promise<void> complete: () => Promise<void> disable: () => Promise<void> - openWeb: () => Promise<void> - starOrca: () => Promise<boolean> forceShow: () => Promise<void> } /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — @@ -1841,11 +1913,13 @@ export type PreloadApi = { }) => Promise<void> } session: { - get: () => Promise<WorkspaceSessionState> - set: (args: WorkspaceSessionState) => Promise<void> - patch: (args: WorkspaceSessionPatch) => Promise<void> + // hostId is optional and defaults to the 'local' partition on the main + // side, so existing callers that omit it behave exactly as before. + get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState> + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise<void> + patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise<void> readTerminalScrollback: (args: { ref: string }) => string | null - setSync: (args: WorkspaceSessionState) => void + setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } remoteWorkspace: { get: (args: { targetId: string }) => Promise<RemoteWorkspaceSnapshot | null> @@ -2402,6 +2476,9 @@ export type PreloadApi = { }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment> remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + disconnect: (args: { + selector: string + }) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }> getStatus: (args: { selector: string timeoutMs?: number diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 809545816e5..bc14907d74a 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -3,6 +3,13 @@ conflict on every upstream sync of the much larger central preload file. Composed back into `api.gl` from `index.ts`. */ import { ipcRenderer } from 'electron' +import type { TaskSourceContext } from '../shared/task-source-context' + +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} export const glApi = { viewer: (): Promise<unknown> => ipcRenderer.invoke('gitlab:viewer'), @@ -10,136 +17,154 @@ export const glApi = { rateLimit: (args?: { force?: boolean; host?: string | null }): Promise<unknown> => ipcRenderer.invoke('gitlab:rateLimit', args), - projectSlug: (args: { repoPath: string }): Promise<unknown> => + projectSlug: (args: GitLabRepoSelectorArgs): Promise<unknown> => ipcRenderer.invoke('gitlab:projectSlug', args), - mrForBranch: (args: { - repoPath: string - branch: string - linkedMRIid?: number | null - }): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args), + mrForBranch: ( + args: GitLabRepoSelectorArgs & { + branch: string + linkedMRIid?: number | null + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args), - mr: (args: { repoPath: string; iid: number }): Promise<unknown> => + mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise<unknown> => ipcRenderer.invoke('gitlab:mr', args), - listMRs: (args: { - repoPath: string - state?: 'opened' | 'merged' | 'closed' | 'all' - page?: number - perPage?: number - }): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args), + listMRs: ( + args: GitLabRepoSelectorArgs & { + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args), - listWorkItems: (args: { - repoPath: string - state?: 'opened' | 'merged' | 'closed' | 'all' - page?: number - perPage?: number - }): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args), + listWorkItems: ( + args: GitLabRepoSelectorArgs & { + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args), - issue: (args: { repoPath: string; number: number }): Promise<unknown> => + issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise<unknown> => ipcRenderer.invoke('gitlab:issue', args), - listIssues: (args: { - repoPath: string - state?: 'opened' | 'closed' | 'all' - assignee?: string - limit?: number - }): Promise<{ items: unknown[]; error?: unknown }> => + listIssues: ( + args: GitLabRepoSelectorArgs & { + state?: 'opened' | 'closed' | 'all' + assignee?: string + limit?: number + } + ): Promise<{ items: unknown[]; error?: unknown }> => ipcRenderer.invoke('gitlab:listIssues', args), - createIssue: (args: { - repoPath: string - title: string - body: string - }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => + createIssue: ( + args: GitLabRepoSelectorArgs & { + title: string + body: string + } + ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:createIssue', args), - updateIssue: (args: { - repoPath: string - number: number - updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => + updateIssue: ( + args: GitLabRepoSelectorArgs & { + number: number + updates: unknown + } + ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateIssue', args), - addIssueComment: (args: { repoPath: string; number: number; body: string }): Promise<unknown> => - ipcRenderer.invoke('gitlab:addIssueComment', args), + addIssueComment: ( + args: GitLabRepoSelectorArgs & { number: number; body: string } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:addIssueComment', args), - listLabels: (args: { repoPath: string }): Promise<string[]> => + listLabels: (args: GitLabRepoSelectorArgs): Promise<string[]> => ipcRenderer.invoke('gitlab:listLabels', args), - listAssignableUsers: (args: { repoPath: string }): Promise<unknown[]> => + listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise<unknown[]> => ipcRenderer.invoke('gitlab:listAssignableUsers', args), - todos: (args: { repoPath: string }): Promise<unknown[]> => + todos: (args: GitLabRepoSelectorArgs): Promise<unknown[]> => ipcRenderer.invoke('gitlab:todos', args), - workItemDetails: (args: { - repoPath: string - iid: number - type: 'issue' | 'mr' - }): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args), + workItemDetails: ( + args: GitLabRepoSelectorArgs & { + iid: number + type: 'issue' | 'mr' + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args), - closeMR: (args: { - repoPath: string - iid: number - }): Promise<{ ok: true } | { ok: false; error: string }> => + closeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:closeMR', args), - reopenMR: (args: { - repoPath: string - iid: number - }): Promise<{ ok: true } | { ok: false; error: string }> => + reopenMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:reopenMR', args), - mergeMR: (args: { - repoPath: string - iid: number - method?: 'merge' | 'squash' | 'rebase' - }): Promise<{ ok: true } | { ok: false; error: string }> => + mergeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + method?: 'merge' | 'squash' | 'rebase' + } + ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:mergeMR', args), - updateMR: (args: { - repoPath: string - iid: number - updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => + updateMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + updates: unknown + } + ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateMR', args), - updateMRReviewers: (args: { - repoPath: string - iid: number - reviewerIds: number[] - projectRef?: unknown - }): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + updateMRReviewers: ( + args: GitLabRepoSelectorArgs & { + iid: number + reviewerIds: number[] + projectRef?: unknown + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args), - addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise<unknown> => + addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRComment', args), - addMRInlineComment: (args: { - repoPath: string - iid: number - input: unknown - projectRef?: unknown - }): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + addMRInlineComment: ( + args: GitLabRepoSelectorArgs & { + iid: number + input: unknown + projectRef?: unknown + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args), - resolveMRDiscussion: (args: { - repoPath: string - iid: number - discussionId: string - resolved: boolean - }): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + resolveMRDiscussion: ( + args: GitLabRepoSelectorArgs & { + iid: number + discussionId: string + resolved: boolean + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), - jobTrace: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> => - ipcRenderer.invoke('gitlab:jobTrace', args), + jobTrace: ( + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:jobTrace', args), - retryJob: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> => - ipcRenderer.invoke('gitlab:retryJob', args), + retryJob: ( + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:retryJob', args), - workItemByPath: (args: { - repoPath: string - host: string - path: string - iid: number - type: 'issue' | 'mr' - }): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args) + workItemByPath: ( + args: GitLabRepoSelectorArgs & { + host: string + path: string + iid: number + type: 'issue' | 'mr' + } + ): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c181eed8d0..3bb66ac0f73 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,6 +70,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limi import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' +import type { TaskSourceContext } from '../shared/task-source-context' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, @@ -478,6 +479,10 @@ const api = { clone: (args) => ipcRenderer.invoke('repos:clone', args), + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + + createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), onCloneProgress: ( @@ -513,6 +518,16 @@ const api = { } } satisfies PreloadApi['repos'], + projects: { + list: () => ipcRenderer.invoke('projects:list'), + listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), + createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => + ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) + } satisfies PreloadApi['projects'], + projectGroups: { list: () => ipcRenderer.invoke('projectGroups:list'), create: (args) => ipcRenderer.invoke('projectGroups:create', args), @@ -925,12 +940,17 @@ const api = { return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) }, - issue: (args: { repoPath: string; repoId?: string; number: number }): Promise<unknown> => - ipcRenderer.invoke('gh:issue', args), + issue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + }): Promise<unknown> => ipcRenderer.invoke('gh:issue', args), workItem: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args), @@ -947,6 +967,7 @@ const api = { workItemDetails: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args), @@ -954,6 +975,7 @@ const api = { prFileContents: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number path: string oldPath?: string @@ -968,6 +990,7 @@ const api = { createIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null title: string body: string labels?: string[] @@ -994,6 +1017,7 @@ const api = { prChecks: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: { owner: string; repo: string } | null @@ -1003,6 +1027,7 @@ const api = { prCheckDetails: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string @@ -1013,6 +1038,7 @@ const api = { rerunPRChecks: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string failedOnly?: boolean @@ -1022,6 +1048,7 @@ const api = { prComments: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: { owner: string; repo: string } | null noCache?: boolean @@ -1030,6 +1057,7 @@ const api = { resolveReviewThread: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null threadId: string resolve: boolean }): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args), @@ -1037,6 +1065,7 @@ const api = { setPRFileViewed: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number pullRequestId: string path: string @@ -1054,6 +1083,7 @@ const api = { mergePR: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: { owner: string; repo: string } | null @@ -1063,6 +1093,7 @@ const api = { setPRAutoMerge: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean prRepo?: { owner: string; repo: string } | null @@ -1072,6 +1103,7 @@ const api = { updatePRState: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1080,6 +1112,7 @@ const api = { requestPRReviewers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1088,6 +1121,7 @@ const api = { removePRReviewers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1096,6 +1130,7 @@ const api = { updateIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number updates: unknown }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1104,6 +1139,7 @@ const api = { addIssueComment: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -1113,6 +1149,7 @@ const api = { addPRReviewCommentReply: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -1125,6 +1162,7 @@ const api = { addPRReviewComment: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commitId: string path: string @@ -1133,12 +1171,16 @@ const api = { body: string }): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewComment', args), - listLabels: (args: { repoPath: string; repoId?: string }): Promise<string[]> => - ipcRenderer.invoke('gh:listLabels', args), + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise<string[]> => ipcRenderer.invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null }): Promise<GitHubAssignableUser[]> => ipcRenderer.invoke('gh:listAssignableUsers', args), // Why: every renderer subscribes to local mutation broadcasts so each @@ -1475,8 +1517,6 @@ const api = { dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'), complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'), disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'), - openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'), - starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'), forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow') }, @@ -2222,14 +2262,16 @@ const api = { } satisfies PreloadApi['cache'], session: { - get: () => ipcRenderer.invoke('session:get'), - set: (args) => ipcRenderer.invoke('session:set', args), - patch: (args) => ipcRenderer.invoke('session:patch', args), + // hostId is optional and defaults to 'local' on the main side, so existing + // call sites that omit it keep targeting the local session partition. + get: (hostId) => ipcRenderer.invoke('session:get', hostId), + set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), + patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args) => { - ipcRenderer.sendSync('session:set-sync', args) + setSync: (args, hostId) => { + ipcRenderer.sendSync('session:set-sync', args, hostId) } } satisfies PreloadApi['session'], @@ -3333,6 +3375,10 @@ const api = { ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => ipcRenderer.invoke('runtimeEnvironments:remove', args), + disconnect: (args: { + selector: string + }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:disconnect', args), getStatus: (args: { selector: string timeoutMs?: number diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index 9f12c83d1f4..529371c72a4 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -54,7 +54,6 @@ describe('validateGitExecArgs', () => { it.each([ 'push', 'pull', - 'commit', 'checkout', 'reset', 'rebase', @@ -207,4 +206,41 @@ describe('validateGitExecArgs', () => { expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed') }) }) + + describe('git clone', () => { + it('allows only the project setup clone shape', () => { + expectAllowed(['clone', '--', 'https://github.com/stablyai/orca.git', 'orca']) + expectAllowed(['clone', '--progress', '--', 'git@github.com:stablyai/orca.git', 'orca']) + }) + + it.each([ + [['clone', 'https://github.com/stablyai/orca.git']], + [['clone', 'https://github.com/stablyai/orca.git', 'orca']], + [['clone', '--depth=1', '--', 'https://github.com/stablyai/orca.git', 'orca']], + [['clone', '--', 'https://github.com/stablyai/orca.git', '.']], + [['clone', '--', 'https://github.com/stablyai/orca.git', '..']], + [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested/orca']], + [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested\\orca']] + ])('rejects unsafe clone args %j', (args) => { + expectBlocked(args, 'git clone') + }) + }) + + describe('git init and empty commit', () => { + it('allows only the SSH create-project init and empty commit shapes', () => { + expectAllowed(['init']) + expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit']) + }) + + it.each([ + [['init', '--bare']], + [['init', '/tmp/other']], + [['commit']], + [['commit', '-am', 'message']], + [['commit', '--allow-empty']], + [['commit', '--allow-empty', '-m', '']] + ])('rejects unsafe create-project write args %j', (args) => { + expectBlocked(args, 'via exec is restricted') + }) + }) }) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index aa373957081..546f92afc93 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -5,8 +5,9 @@ * Extracted from git-handler-ops.ts to keep both files under the limit. */ -// Why: only read-only git subcommands are allowed via exec. config is restricted -// to read-only flags; branch rejects destructive flags; fetch/worktree removed. +// Why: only read-only git subcommands are allowed via exec, except for the +// exact init/empty-commit shapes used by SSH Create Project after the parent +// directory has already been validated by main. const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'rev-parse', 'branch', @@ -18,6 +19,9 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'merge-base', 'diff', 'ls-files', + 'clone', + 'init', + 'commit', 'for-each-ref', 'check-ref-format', 'config' @@ -81,6 +85,38 @@ const DIFF_ALLOWED_FLAGS = new Set([ '--no-ext-diff' ]) +function validateCloneArgs(args: string[]): void { + // Why: project-host setup needs remote clone, but git.exec must not become a + // general write surface. Permit only `git clone [--progress] -- <url> <dir>`. + const allowed = args[1] === '--progress' ? args.slice(2) : args.slice(1) + if (allowed.length !== 3 || allowed[0] !== '--') { + throw new Error('git clone via exec is restricted to clone [--progress] -- <url> <dir>') + } + const targetDir = allowed[2] + if ( + !targetDir || + targetDir === '.' || + targetDir === '..' || + targetDir.includes('/') || + targetDir.includes('\\') || + targetDir.includes('\0') + ) { + throw new Error('git clone target directory must be a single safe path segment') + } +} + +function validateInitArgs(args: string[]): void { + if (args.length !== 1) { + throw new Error('git init via exec is restricted to init with no arguments') + } +} + +function validateCommitArgs(args: string[]): void { + if (args.length !== 4 || args[1] !== '--allow-empty' || args[2] !== '-m' || !args[3]) { + throw new Error('git commit via exec is restricted to commit --allow-empty -m <message>') + } +} + // Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), // which bypasses exact-match Set.has() checks. This helper catches both forms. function matchesDeniedFlag(arg: string, denySet: Set<string>): boolean { @@ -124,6 +160,12 @@ export function validateGitExecArgs(args: string[]): void { throw new Error('git config write operations are not allowed via exec') } } + if (subcommand === 'init') { + validateInitArgs(args) + } + if (subcommand === 'commit') { + validateCommitArgs(args) + } if (subcommand === 'branch') { if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) { throw new Error('Destructive git branch flags are not allowed via exec') @@ -156,4 +198,7 @@ export function validateGitExecArgs(args: string[]): void { throw new Error(`git diff flag not allowed via exec: ${unsupportedArg}`) } } + if (subcommand === 'clone') { + validateCloneArgs(args) + } } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 79d3d763a2b..db443ec89a2 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -90,6 +90,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') + expect(methods).toContain('git.clone') expect(methods).toContain('git.isGitRepo') }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 1ae336b81e7..74883a6aa34 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' import { promisify } from 'util' import * as path from 'path' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' import { @@ -44,6 +44,7 @@ import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../shared/git-discard-path-safety' +import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -93,14 +94,15 @@ export class GitHandler { this.refreshLocalBaseRefForWorktreeCreate(p) ) this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p)) - this.dispatcher.onRequest('git.exec', (p) => this.exec(p)) + this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context)) + this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) } private async git( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean } + opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; signal?: AbortSignal } ): Promise<{ stdout: string; stderr: string }> { const env = buildRelayCommandEnv() if (opts?.disableOptionalLocks) { @@ -110,7 +112,8 @@ export class GitHandler { cwd: expandTilde(cwd), env, encoding: 'utf-8', - maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER + maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, + signal: opts?.signal }) } @@ -585,15 +588,95 @@ export class GitHandler { }) } - private async exec(params: Record<string, unknown>) { + private async exec(params: Record<string, unknown>, context?: RequestContext) { const args = params.args as string[] const cwd = params.cwd as string validateGitExecArgs(args) - const { stdout, stderr } = await this.git(args, cwd) + const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) return { stdout, stderr } } + private async clone(params: Record<string, unknown>, context?: RequestContext) { + const args = params.args as string[] + const cwd = params.cwd as string + const progressId = params.progressId + validateGitExecArgs(args) + if (typeof progressId !== 'string' || progressId.length === 0) { + throw new Error('Missing clone progress id.') + } + if (args[0] !== 'clone') { + throw new Error('git.clone only supports clone commands.') + } + return await this.spawnClone(args, cwd, progressId, context) + } + + private async spawnClone( + args: string[], + cwd: string, + progressId: string, + context?: RequestContext + ): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd: expandTilde(cwd), + env: buildRelayCommandEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + let settled = false + const cleanup = (): void => { + context?.signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + child.kill() + } + context?.signal?.addEventListener('abort', onAbort, { once: true }) + child.stdout?.on('data', (chunk: Buffer) => { + stdout = (stdout + chunk.toString('utf-8')).slice(-4096) + }) + child.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf-8') + stderr = (stderr + text).slice(-4096) + for (const line of text.split(/[\r\n]+/)) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match) { + this.dispatcher.notify('git.cloneProgress', { + progressId, + phase: match[1].trim(), + percent: parseInt(match[2], 10) + }) + } + } + }) + child.on('error', (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + }) + child.on('close', (code, signal) => { + if (settled) { + return + } + settled = true + cleanup() + if (context?.signal?.aborted) { + reject(new Error('Clone aborted')) + return + } + if (code === 0 && !signal) { + resolve({ stdout, stderr }) + return + } + reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`)) + }) + }) + } + private async renameCurrentBranch(params: Record<string, unknown>) { const worktreePath = params.worktreePath const newBranch = params.newBranch diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ea9d0ea2194..e19001d5c8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -92,6 +92,11 @@ import { shouldPersistWorkspaceSession } from './lib/workspace-session' import { createSessionWriteSubscriber } from './lib/session-write-subscriber' +import { + fetchWorkspaceSessionFromHosts, + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHostSync +} from './lib/workspace-session-host-persistence' import { getStartupErrorFallbackUI, hydratePersistedUIAfterStartupRead @@ -759,7 +764,14 @@ function App(): React.JSX.Element { cancelled, hydratePersistedUI: actions.hydratePersistedUI }) - const session = await window.api.session.get() + // Why: runtime-owned worktree slices live in per-host partitions. + // Repos were fetched above, so the known runtime hosts are derivable + // here; merge their slices into the unified session the hydrators + // expect. An unreadable host partition is skipped (fail-soft). + const session = await fetchWorkspaceSessionFromHosts( + window.api.session, + useAppStore.getState().repos + ) await actions.fetchKeybindings() if (!cancelled) { actions.hydrateWorkspaceSession(session) @@ -1051,9 +1063,12 @@ function App(): React.JSX.Element { store: useAppStore, shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), persist: ({ patch }) => { - const localWrite = window.api.session.patch(patch) - void localWrite const state = useAppStore.getState() + // Why: route each runtime host's worktree-scoped slice to its own + // partition; the returned promise is the local write so the + // remote-workspace upload chain below keeps its ordering. + const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter( (targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict' ) @@ -1113,7 +1128,11 @@ function App(): React.JSX.Element { // into the store via Zustand setters. The earlier read is only for the // gating flags and would miss those updates. const freshState = useAppStore.getState() - window.api.session.setSync(buildWorkspaceSessionPayload(freshState)) + persistWorkspaceSessionByHostSync( + window.api.session, + buildWorkspaceSessionPayload(freshState), + freshState + ) shutdownBuffersCaptured = true } window.addEventListener('beforeunload', captureAndFlush) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c4735589d49..c5773669e44 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -11,6 +11,7 @@ import React, { useSyncExternalStore } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' +import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' import { ArrowDown, @@ -37,6 +38,8 @@ import { Pencil, Plus, RefreshCw, + Send, + Settings, UndoDot, Users, Wrench, @@ -83,11 +86,6 @@ import { import type { DiffSection } from '@/components/editor/diff-section-types' import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { - REVIEW_ACTION_MERGE_BUTTON_CLASS, - REVIEW_ACTION_STATE_BUTTON_CLASS, - RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS -} from '@/components/right-sidebar/right-sidebar-primary-action-layout' import { createGitHubChecksTabState, resolveGitHubChecksTabState, @@ -137,9 +135,6 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' -import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' -import { GitHubIssueCommentComposer } from '@/components/github/GitHubIssueCommentComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, @@ -172,12 +167,15 @@ import type { PRCheckDetail, PRComment } from '../../../shared/types' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../shared/task-source-context' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' const IS_MAC = navigator.userAgent.includes('Mac') -const IS_WINDOWS = !IS_MAC && navigator.userAgent.includes('Windows') -const WINDOWS_WINDOW_CONTROLS_WIDTH = '138px' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -272,6 +270,7 @@ type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null initialTab?: ItemDialogTab variant?: 'sheet' | 'page' backLabel?: string @@ -436,11 +435,13 @@ function PRReviewersPanel({ item, loading, repoPath, + sourceContext, onReviewersRequested }: { item: GitHubWorkItem loading: boolean repoPath: string | null + sourceContext?: TaskSourceContext | null onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -458,7 +459,19 @@ function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) const reviewerPanelMountedRef = useRef(true) @@ -533,11 +546,12 @@ function PRReviewersPanel({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + sourceSettings ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null + open && !reviewSlug ? item.repoId : null, + sourceSettings ) const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -636,7 +650,8 @@ function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' const measureReviewerPickerPlacement = useCallback(() => { const rect = reviewerInputRef.current?.getBoundingClientRect() @@ -679,7 +694,7 @@ function PRReviewersPanel({ ) return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -702,6 +717,7 @@ function PRReviewersPanel({ : await window.api.gh.requestPRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -721,7 +737,9 @@ function PRReviewersPanel({ localReviewRequests ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) onReviewersRequested(nextReviewRequests) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') @@ -754,7 +772,7 @@ function PRReviewersPanel({ if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -777,6 +795,7 @@ function PRReviewersPanel({ : await window.api.gh.removePRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -795,7 +814,9 @@ function PRReviewersPanel({ (reviewer) => !removed.has(reviewer.login.toLowerCase()) ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) onReviewersRequested(nextReviewRequests) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') @@ -1410,6 +1431,7 @@ function getPRFileContentCacheKey(args: { function loadPRFileContents(args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number file: GitHubPRFile headSha: string @@ -1425,6 +1447,7 @@ function loadPRFileContents(args: { .prFileContents({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, path: args.file.path, oldPath: args.file.oldPath, @@ -1447,6 +1470,7 @@ function loadPRFileContents(args: { function addIssueCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -1454,6 +1478,7 @@ function addIssueCommentForRepo(args: { return window.api.gh.addIssueComment({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, number: args.number, body: args.body, type: args.type @@ -1463,6 +1488,7 @@ function addIssueCommentForRepo(args: { function addPRReviewCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commitId: string path: string @@ -1473,6 +1499,7 @@ function addPRReviewCommentForRepo(args: { return window.api.gh.addPRReviewComment({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, commitId: args.commitId, path: args.path, @@ -1485,6 +1512,7 @@ function addPRReviewCommentForRepo(args: { function addPRReviewCommentReplyForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -1495,6 +1523,7 @@ function addPRReviewCommentReplyForRepo(args: { return window.api.gh.addPRReviewCommentReply({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, commentId: args.commentId, body: args.body, @@ -1507,6 +1536,7 @@ function addPRReviewCommentReplyForRepo(args: { function setPRFileViewedForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number pullRequestId: string path: string @@ -1515,6 +1545,7 @@ function setPRFileViewedForRepo(args: { return window.api.gh.setPRFileViewed({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, pullRequestId: args.pullRequestId, path: args.path, @@ -1525,12 +1556,14 @@ function setPRFileViewedForRepo(args: { function getWorkItemDetailsForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number type: 'issue' | 'pr' }): Promise<GitHubWorkItemDetails | null> { return window.api.gh.workItemDetails({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, number: args.number, type: args.type }) @@ -1663,6 +1696,7 @@ type PRFilesCombinedDiffViewerProps = { comments: PRComment[] repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number prUrl: string headSha: string | undefined @@ -1677,6 +1711,7 @@ function PRFilesCombinedDiffViewer({ comments, repoPath, repoId, + sourceContext, prNumber, prUrl, headSha, @@ -1843,6 +1878,7 @@ function PRFilesCombinedDiffViewer({ const contents = await loadPRFileContents({ repoPath, repoId, + sourceContext, prNumber, file, headSha, @@ -1884,7 +1920,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath] + [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] ) const retrySection = useCallback( @@ -2023,6 +2059,7 @@ function PRFilesCombinedDiffViewer({ const result = await addPRReviewCommentForRepo({ repoPath, repoId, + sourceContext, prNumber, commitId: headSha, path: section.path, @@ -2046,7 +2083,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath] + [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2192,6 +2229,7 @@ function CommentCodeContext({ comment, repoPath, repoId, + sourceContext, prNumber, files, headSha, @@ -2200,6 +2238,7 @@ function CommentCodeContext({ comment: PRComment repoPath: string | null repoId: string + sourceContext?: TaskSourceContext | null prNumber: number files: GitHubPRFile[] headSha: string | undefined @@ -2224,7 +2263,7 @@ function CommentCodeContext({ return } let cancelled = false - loadPRFileContents({ repoPath, repoId, prNumber, file, headSha, baseSha }) + loadPRFileContents({ repoPath, repoId, sourceContext, prNumber, file, headSha, baseSha }) .then((result) => { if (!cancelled) { setContents(result) @@ -2238,7 +2277,7 @@ function CommentCodeContext({ return () => { cancelled = true } - }, [baseSha, file, headSha, line, prNumber, repoId, repoPath]) + }, [baseSha, file, headSha, line, prNumber, repoId, repoPath, sourceContext]) const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( contextExpansionState, @@ -2492,6 +2531,7 @@ function CommentCodeContext({ function ConversationTab({ item, repoPath, + sourceContext, body, comments, files, @@ -2512,6 +2552,7 @@ function ConversationTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null body: string comments: PRComment[] files: GitHubPRFile[] @@ -2575,6 +2616,7 @@ function ConversationTab({ await runWorkItemBodyUpdate({ item, repoPath, + sourceContext, projectOrigin, body: resolvedBodyDraft, parsedSlug: bodySlug @@ -2605,7 +2647,8 @@ function ConversationTab({ item, onBodyUpdated, projectOrigin, - repoPath + repoPath, + sourceContext ]) const handleReply = useCallback( @@ -2624,6 +2667,7 @@ function ConversationTab({ ? await addPRReviewCommentReplyForRepo({ repoPath, repoId: item.repoId, + sourceContext, prNumber: item.number, commentId: comment.id, body: replyBody, @@ -2634,6 +2678,7 @@ function ConversationTab({ : await addIssueCommentForRepo({ repoPath, repoId: item.repoId, + sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, type: item.type @@ -2651,7 +2696,7 @@ function ConversationTab({ toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) return true }, - [item.number, item.repoId, item.type, onCommentAdded, repoPath] + [item.number, item.repoId, item.type, onCommentAdded, repoPath, sourceContext] ) const rightPanel = @@ -2661,6 +2706,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} onStateChange={onStateChange} @@ -2670,6 +2716,7 @@ function ConversationTab({ item={item} loading={loading} repoPath={repoPath} + sourceContext={sourceContext} onReviewersRequested={onReviewersRequested} /> <aside className="overflow-hidden rounded-lg border border-border/50 bg-card/50 shadow-xs"> @@ -2677,6 +2724,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} headSha={headSha} checks={checks} loading={loading || !detailsLoaded} @@ -2690,12 +2738,12 @@ function ConversationTab({ <div key={comment.id} className={cn( - 'min-w-0 overflow-hidden rounded-md border border-border/60 bg-card shadow-xs', - isReply && 'ml-8 max-w-[calc(100%-2rem)] border-l-2 border-l-border/80', + 'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card/50 shadow-xs', + isReply && 'ml-6 max-w-[calc(100%-1.5rem)]', comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS )} > - <div className="flex min-w-0 items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-2"> + <div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2"> {comment.authorAvatarUrl ? ( <img src={comment.authorAvatarUrl} @@ -2782,6 +2830,7 @@ function ConversationTab({ comment={comment} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} prNumber={item.number} files={files} headSha={headSha} @@ -2973,9 +3022,9 @@ function ConversationTab({ {detailsLoaded ? ( <> - <div className="flex items-center gap-2 border-b border-border/40 pb-2 pt-1"> + <div className="flex items-center gap-2 pt-1"> <MessageSquare className="size-4 text-muted-foreground" /> - <span className="text-[13px] font-semibold text-foreground"> + <span className="text-[13px] font-medium text-foreground"> {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} </span> {comments.length > 0 && ( @@ -3009,13 +3058,13 @@ function ConversationTab({ )} {comments.length === 0 ? ( - <p className="px-1 py-2 text-[13px] text-muted-foreground"> + <div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground"> {translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} - </p> + </div> ) : visibleComments.length === 0 ? ( - <p className="px-1 py-2 text-center text-[13px] text-muted-foreground"> + <div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground"> {getPRCommentAudienceEmptyLabel(commentFilter)} - </p> + </div> ) : ( <div className="flex min-w-0 flex-col gap-3"> {visibleCommentGroups.map(renderCommentGroup)} @@ -3025,19 +3074,14 @@ function ConversationTab({ ) : null} {detailsLoaded && repoPath && ( - <GitHubIssueCommentComposer - className="mt-2" + <GHCommentComposer + className="mt-1" repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} issueNumber={item.number} itemType={item.type} - itemState={localState} - itemId={item.id} - projectOrigin={projectOrigin} - previewGithubRepo={markdownGitHubRepo} onCommentAdded={onCommentAdded} - onStateChange={onStateChange} - onMutated={onMutated} /> )} </div> @@ -3051,6 +3095,7 @@ function PRActionsPanel({ item, repoPath, repoId, + sourceContext, projectOrigin, localState, onStateChange, @@ -3059,6 +3104,7 @@ function PRActionsPanel({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] onStateChange: (state: GitHubWorkItem['state']) => void @@ -3089,10 +3135,10 @@ function PRActionsPanel({ const applyStatePatch = useCallback( (state: GitHubWorkItem['state']) => { onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId) + patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) patchProjectRowIfNeeded(state) }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem] + [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] ) const handleStateChange = async (): Promise<void> => { @@ -3129,6 +3175,7 @@ function PRActionsPanel({ await runPullRequestStateUpdate({ repoPath, repoId, + sourceContext, projectOrigin, number: item.number, updates: { state: nextState } @@ -3179,6 +3226,7 @@ function PRActionsPanel({ const result = await window.api.gh.mergePR({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, method, prRepo: item.prRepo ?? null @@ -3210,6 +3258,7 @@ function PRActionsPanel({ const result = await window.api.gh.setPRAutoMerge({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, enabled, prRepo: item.prRepo ?? null @@ -3248,7 +3297,7 @@ function PRActionsPanel({ <WorkItemStateBadge item={actionItem} /> </div> - <div className="grid gap-2 justify-items-start"> + <div className="grid gap-2"> <DropdownMenu modal={false}> <Tooltip> <TooltipTrigger asChild> @@ -3257,8 +3306,7 @@ function PRActionsPanel({ type="button" size="sm" className={cn( - REVIEW_ACTION_MERGE_BUTTON_CLASS, - 'gap-2 bg-green-600 text-white hover:bg-green-700', + 'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700', 'disabled:cursor-not-allowed disabled:opacity-50' )} > @@ -3267,13 +3315,11 @@ function PRActionsPanel({ ) : ( <GitMerge className="size-3.5" /> )} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {mergePresentation.autoMergeAction?.label ?? - (mergePresentation.directMergeAvailable - ? mergeMethods.defaultLabel - : mergePresentation.label)} - </span> - <ChevronDown className="size-3 shrink-0 opacity-60" /> + {mergePresentation.autoMergeAction?.label ?? + (mergePresentation.directMergeAvailable + ? mergeMethods.defaultLabel + : mergePresentation.label)} + <ChevronDown className="size-3 opacity-60" /> </Button> </DropdownMenuTrigger> </TooltipTrigger> @@ -3319,8 +3365,7 @@ function PRActionsPanel({ variant={nextState === 'closed' ? 'outline' : 'secondary'} size="sm" className={cn( - REVIEW_ACTION_STATE_BUTTON_CLASS, - 'gap-2', + 'w-full justify-center gap-2', nextState === 'closed' && 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50' )} @@ -3334,11 +3379,9 @@ function PRActionsPanel({ ) : ( <CircleDot className="size-3.5" /> )} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {nextState === 'closed' - ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request') - : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')} - </span> + {nextState === 'closed' + ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request') + : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')} </Button> </div> </aside> @@ -3551,6 +3594,7 @@ function ChecksTab({ item, repoPath, repoId, + sourceContext, headSha, checks, loading, @@ -3560,6 +3604,7 @@ function ChecksTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null headSha: string | undefined checks: GitHubWorkItemDetails['checks'] loading: boolean @@ -3621,6 +3666,7 @@ function ChecksTab({ const nextChecks = (await window.api.gh.prChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, noCache: true @@ -3638,7 +3684,7 @@ function ChecksTab({ } finally { setRefreshing(false) } - }, [headSha, item.number, onChecksUpdated, repoId, repoPath]) + }, [headSha, item.number, onChecksUpdated, repoId, repoPath, sourceContext]) const handleRerun = useCallback( async (failedOnly: boolean): Promise<void> => { @@ -3650,6 +3696,7 @@ function ChecksTab({ const result = await window.api.gh.rerunPRChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, failedOnly @@ -3674,7 +3721,7 @@ function ChecksTab({ setRerunning(false) } }, - [handleRefresh, headSha, item.number, rerunning, repoId, repoPath] + [handleRefresh, headSha, item.number, rerunning, repoId, repoPath, sourceContext] ) const handleFixBrokenChecks = useCallback(async (): Promise<void> => { @@ -4288,15 +4335,23 @@ function ChecksTab({ // repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt // to a thrown rejection so the existing `useImmediateMutation` flow // (which expects throws on failure) continues to work unchanged. +function getGitHubMutationSettings(repoId: string | null | undefined) { + const state = useAppStore.getState() + // Why: project-origin mutations are slug-addressed, but when we know the + // backing repo id they must still execute on that repo's owner host. + return getSettingsForRepoRuntimeOwner(state, repoId ?? null) +} + async function runIssueUpdate(args: { repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined number: number updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates'] }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4323,6 +4378,7 @@ async function runIssueUpdate(args: { const res = await window.api.gh.updateIssue({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, number: args.number, updates: args.updates }) @@ -4334,6 +4390,7 @@ async function runIssueUpdate(args: { async function runWorkItemBodyUpdate(args: { item: GitHubWorkItem repoPath: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined body: string parsedSlug: GitHubOwnerRepo | null @@ -4345,7 +4402,7 @@ async function runWorkItemBodyUpdate(args: { if (!targetSlug) { throw new Error('No GitHub repository context available for this pull request.') } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId)) const updateArgs = { owner: targetSlug.owner, repo: targetSlug.repo, @@ -4370,6 +4427,7 @@ async function runWorkItemBodyUpdate(args: { await runIssueUpdate({ repoPath: args.repoPath, repoId: args.item.repoId, + sourceContext: args.sourceContext, projectOrigin: args.projectOrigin, number: args.item.number, updates: { body: args.body } @@ -4379,12 +4437,13 @@ async function runWorkItemBodyUpdate(args: { async function runPullRequestStateUpdate(args: { repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined number: number updates: { state: 'open' | 'closed' } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4411,6 +4470,7 @@ async function runPullRequestStateUpdate(args: { const res = await window.api.gh.updatePRState({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, prNumber: args.number, updates: args.updates }) @@ -4419,10 +4479,44 @@ async function runPullRequestStateUpdate(args: { } } +function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( + <div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}> + <button + type="button" + onClick={() => { + onOpen?.() + void window.api.shell.openUrl(url) + }} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground" + > + <Settings className="size-3.5 shrink-0" /> + <span className="min-w-0 flex-1 text-left"> + {translate('auto.components.GitHubItemDialog.2aa9acdf34', 'Edit labels on GitHub')} + </span> + <ExternalLink className="size-3 shrink-0 opacity-70" /> + </button> + </div> + ) +} + function GHEditSection({ item, repoPath, repoId, + sourceContext, projectOrigin, localState, localLabels, @@ -4438,6 +4532,7 @@ function GHEditSection({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] localLabels: string[] @@ -4463,6 +4558,19 @@ function GHEditSection({ const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const { isPending, run } = useImmediateMutation() // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside @@ -4486,16 +4594,18 @@ function GHEditSection({ const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + sourceSettings ) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + sourceSettings ) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined @@ -4528,23 +4638,24 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { state: newState } }), onOptimistic: () => { onStateChange(newState) - patchWorkItem(item.id, { state: newState }, item.repoId) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId) + patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: prevState }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(item.id, { state: newState }, item.repoId) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) onMutated() }, @@ -4557,6 +4668,7 @@ function GHEditSection({ item.repoId, localState, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4578,13 +4690,14 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId) + patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: newLabels }) }, onSuccess: () => { @@ -4593,7 +4706,7 @@ function GHEditSection({ }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId) + patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onError: (err) => toast.error(err) @@ -4604,18 +4717,19 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId) + patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId) + patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => { @@ -4632,6 +4746,7 @@ function GHEditSection({ item.repoId, localLabels, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4658,6 +4773,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeAssignees: [login] } @@ -4682,6 +4798,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addAssignees: [login] } @@ -4707,6 +4824,7 @@ function GHEditSection({ item.repoId, assigneesItemKey, repoPath, + sourceContext, projectOrigin, localAssignees, patchProjectRowIfNeeded, @@ -4719,6 +4837,18 @@ function GHEditSection({ return null } + const checkIcon = ( + <svg className="size-2.5" viewBox="0 0 12 12" fill="none"> + <path + d="M2 6l3 3 5-5" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ) + if (layout === 'sidebar') { return ( <aside className="flex flex-col gap-5 text-[13px]"> @@ -4800,14 +4930,41 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - <GitHubWorkItemAssigneePopoverContent - open={assigneePopoverOpen} - assignees={repoAssignees.data} - selectedLogins={localAssignees} - error={repoAssignees.error} - loading={repoAssignees.loading} - onToggleAssignee={handleAssigneeToggle} - /> + {repoAssignees.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoAssignees.error} + </div> + ) : ( + <div> + {repoAssignees.data.map((user) => ( + <button + key={user.login} + type="button" + onClick={() => handleAssigneeToggle(user.login)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localAssignees.includes(user.login) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localAssignees.includes(user.login) && checkIcon} + </span> + <span className="min-w-0 flex-1 text-left"> + <span className="block truncate">{user.login}</span> + {user.name && ( + <span className="block truncate text-[11px] text-muted-foreground"> + {user.name} + </span> + )} + </span> + </button> + ))} + </div> + )} </PopoverContent> </Popover> </div> @@ -4866,15 +5023,39 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - <GitHubWorkItemLabelPopoverContent - open={labelPopoverOpen} - labels={repoLabels.data} - selectedLabels={localLabels} - error={repoLabels.error} - loading={repoLabels.loading} - repositoryLabelsUrl={repositoryLabelsUrl} - onToggleLabel={handleLabelToggle} - onOpenSettingsLink={() => setLabelPopoverOpen(false)} + {repoLabels.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoLabels.error} + </div> + ) : null} + {!repoLabels.error ? ( + <div> + {repoLabels.data.map((label) => ( + <button + key={label} + type="button" + onClick={() => handleLabelToggle(label)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localLabels.includes(label) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localLabels.includes(label) && checkIcon} + </span> + {label} + </button> + ))} + </div> + ) : null} + <GitHubLabelsSettingsLink + url={repositoryLabelsUrl} + separated={!repoLabels.error && repoLabels.data.length > 0} + onOpen={() => setLabelPopoverOpen(false)} /> </PopoverContent> </Popover> @@ -4909,12 +5090,12 @@ function GHEditSection({ ) : null} {hasAttachedWorkspace ? ( <DropdownMenu modal={false}> - <ButtonGroup className="inline-flex w-auto max-w-full"> + <ButtonGroup className="w-full"> <Button type="button" size="sm" onClick={handleOpenOrUseWorkspace} - className="gap-1.5" + className="flex-1 gap-1.5" aria-label={translate( 'auto.components.GitHubItemDialog.84855fedd0', 'Open workspace attached to issue' @@ -5035,15 +5216,39 @@ function GHEditSection({ </button> </PopoverTrigger> <PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start"> - <GitHubWorkItemLabelPopoverContent - open={labelPopoverOpen} - labels={repoLabels.data} - selectedLabels={localLabels} - error={repoLabels.error} - loading={repoLabels.loading} - repositoryLabelsUrl={repositoryLabelsUrl} - onToggleLabel={handleLabelToggle} - onOpenSettingsLink={() => setLabelPopoverOpen(false)} + {repoLabels.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoLabels.error} + </div> + ) : null} + {!repoLabels.error ? ( + <div> + {repoLabels.data.map((label) => ( + <button + key={label} + type="button" + onClick={() => handleLabelToggle(label)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localLabels.includes(label) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localLabels.includes(label) && checkIcon} + </span> + {label} + </button> + ))} + </div> + ) : null} + <GitHubLabelsSettingsLink + url={repositoryLabelsUrl} + separated={!repoLabels.error && repoLabels.data.length > 0} + onOpen={() => setLabelPopoverOpen(false)} /> </PopoverContent> </Popover> @@ -5075,14 +5280,41 @@ function GHEditSection({ </button> </PopoverTrigger> <PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start"> - <GitHubWorkItemAssigneePopoverContent - open={assigneePopoverOpen} - assignees={repoAssignees.data} - selectedLogins={localAssignees} - error={repoAssignees.error} - loading={repoAssignees.loading} - onToggleAssignee={handleAssigneeToggle} - /> + {repoAssignees.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoAssignees.error} + </div> + ) : ( + <div> + {repoAssignees.data.map((user) => ( + <button + key={user.login} + type="button" + onClick={() => handleAssigneeToggle(user.login)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localAssignees.includes(user.login) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localAssignees.includes(user.login) && checkIcon} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate">{user.login}</span> + {user.name && ( + <span className="block truncate text-[11px] text-muted-foreground"> + {user.name} + </span> + )} + </span> + </button> + ))} + </div> + )} </PopoverContent> </Popover> @@ -5149,6 +5381,99 @@ function GHEditSection({ ) } +function GHCommentComposer({ + className, + repoPath, + repoId, + sourceContext, + issueNumber, + itemType, + onCommentAdded +}: { + className?: string + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + issueNumber: number + itemType: 'issue' | 'pr' + onCommentAdded: (comment: PRComment) => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const mountedRef = useMountedRef() + + const handleSubmit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + sourceContext, + number: issueNumber, + body: trimmed, + type: itemType + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + // Why: use the comment returned by GitHub so the optimistic row shows + // the real login/avatar immediately instead of waiting for a reopen. + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, mountedRef, repoPath, repoId, sourceContext, issueNumber, itemType, onCommentAdded]) + + return ( + <div className={cn('flex flex-col items-start gap-2', className)}> + <GitHubMarkdownComposer + value={body} + onChange={setBody} + placeholder={translate('auto.components.GitHubItemDialog.c5c117270e', 'Add a comment…')} + disabled={submitting} + minHeightClassName="min-h-28" + className="w-full" + onSubmitShortcut={() => void handleSubmit()} + /> + <Button + onClick={handleSubmit} + disabled={!body.trim() || submitting} + className="gap-2" + aria-label={translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + > + {submitting ? ( + <LoaderCircle className="size-3.5 animate-spin" /> + ) : ( + <Send className="size-3.5" /> + )} + {translate('auto.components.GitHubItemDialog.bf43425540', 'Comment')} + </Button> + </div> + ) +} + // Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's // list cache carries, so we reach into workItemsCache to recover it. We scope // the lookup to the dialog's own `repoPath` via the public @@ -5166,10 +5491,12 @@ function GHEditSection({ // doc §1 rule: hide when either side is unknown rather than guessing. function WorkItemIssueSourceIndicator({ url, - repoId + repoId, + repoPath }: { url: string repoId: string | null + repoPath?: string | null }): React.JSX.Element | null { // Why: subscribe to a single store-side selector that returns the resolved // sources for this repo — either the primary `(repoPath, PER_REPO_FETCH_LIMIT, '')` @@ -5183,7 +5510,7 @@ function WorkItemIssueSourceIndicator({ // indicator is small and the cache rewrite rate is bounded by user-initiated // refresh/search actions. const sources = useAppStore((s) => - s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT) + s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT, repoPath ?? undefined) ) const issues = useMemo<GitHubOwnerRepo | null>(() => { const fromUrl = parseOwnerRepoFromItemUrl(url) @@ -5215,6 +5542,7 @@ export default function GitHubItemDialog({ workItem, repoPath, repoId, + sourceContext, initialTab, variant = 'sheet', backLabel = 'Back', @@ -5451,6 +5779,7 @@ export default function GitHubItemDialog({ getWorkItemDetailsForRepo({ repoPath, repoId: effectiveRepoId ?? undefined, + sourceContext, number: workItem.number, type: workItem.type }) @@ -5511,7 +5840,7 @@ export default function GitHubItemDialog({ error: message }) }) - }, [repoPath, effectiveRepoId, workItem, detailsCacheKey, initialTab, refetchTick]) + }, [repoPath, effectiveRepoId, sourceContext, workItem, detailsCacheKey, initialTab, refetchTick]) const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot const displayWorkItem = useMemo<GitHubWorkItem | null>(() => { @@ -5645,6 +5974,7 @@ export default function GitHubItemDialog({ const ok = await setPRFileViewedForRepo({ repoId: workItem.repoId, repoPath, + sourceContext, prNumber: workItem.number, pullRequestId: details.pullRequestId, path, @@ -5671,20 +6001,13 @@ export default function GitHubItemDialog({ }) } }, - [details?.pullRequestId, detailsCacheKey, repoPath, workItem] + [details?.pullRequestId, detailsCacheKey, repoPath, sourceContext, workItem] ) const isIssuePage = variant === 'page' && workItem?.type === 'issue' const ownerRepo = workItem ? parseOwnerRepoFromItemUrl(workItem.url) : null const issueStateBadgeTone = localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' - const sheetHeaderStyle = useMemo( - () => - variant === 'sheet' && IS_WINDOWS - ? ({ paddingRight: `calc(1rem + ${WINDOWS_WINDOW_CONTROLS_WIDTH})` } as const) - : undefined, - [variant] - ) const content = workItem ? ( <div className="flex h-full min-h-0 flex-col"> @@ -5870,7 +6193,11 @@ export default function GitHubItemDialog({ {formatRelativeTime(workItem.updatedAt)} </span> </span> - <WorkItemIssueSourceIndicator url={workItem.url} repoId={effectiveRepoId} /> + <WorkItemIssueSourceIndicator + url={workItem.url} + repoId={effectiveRepoId} + repoPath={repoPath} + /> {issueAttachedWorkspaceLabel ? ( <span className="inline-flex min-w-0 items-center gap-1.5"> <FolderKanban className="size-3.5 shrink-0" /> @@ -5881,12 +6208,7 @@ export default function GitHubItemDialog({ </div> </> ) : ( - <div - className="flex-none border-b border-border/60 bg-card/80 px-4 py-3 shadow-xs backdrop-blur supports-[backdrop-filter]:bg-card/70" - // Why: this sheet portals outside the app root, so it cannot inherit - // the Windows titlebar inset variable that keeps header buttons clear. - style={sheetHeaderStyle} - > + <div className="flex-none border-b border-border/60 bg-card/80 px-4 py-3 shadow-xs backdrop-blur supports-[backdrop-filter]:bg-card/70"> <div className="flex items-start gap-3"> {variant === 'page' ? ( <Button @@ -5939,7 +6261,11 @@ export default function GitHubItemDialog({ ) : null} </div> {workItem.type === 'issue' && ( - <WorkItemIssueSourceIndicator url={workItem.url} repoId={effectiveRepoId} /> + <WorkItemIssueSourceIndicator + url={workItem.url} + repoId={effectiveRepoId} + repoPath={repoPath} + /> )} </div> <div className="flex shrink-0 items-center justify-end gap-1"> @@ -6035,6 +6361,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6073,6 +6400,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6123,6 +6451,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6191,6 +6520,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6242,6 +6572,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} headSha={details?.headSha} checks={checks} loading={loading || !detailsLoaded} @@ -6272,6 +6603,7 @@ export default function GitHubItemDialog({ comments={comments} repoPath={repoPath ?? ''} repoId={effectiveRepoId ?? ''} + sourceContext={sourceContext} prNumber={workItem.number} prUrl={workItem.url} headSha={details?.headSha} diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index efd9848a0df..9d425d9cc54 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -10,7 +10,7 @@ close/reopen, merge, and a top-level comment composer. Files / inline review-comment positioning / approvals are deferred to v1.5 since they mirror substantial GitHub-side surface area. */ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Check, CircleDot, @@ -40,15 +40,24 @@ import type { GitLabWorkItemDetails, MRComment } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type Props = { item: GitLabWorkItem | null repoPath: string | null + repoId?: string | null + sourceContext?: TaskSourceContext | null onClose: () => void onCreateWorkspace?: (item: GitLabWorkItem) => void } +type GitLabDialogRepoSelector = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + type JobTraceState = { loading: boolean trace?: string @@ -311,6 +320,8 @@ function PipelineJobRow({ export default function GitLabItemDialog({ item, repoPath, + repoId, + sourceContext, onClose, onCreateWorkspace }: Props): React.JSX.Element { @@ -351,6 +362,16 @@ export default function GitLabItemDialog({ const [retryingJobId, setRetryingJobId] = useState<number | null>(null) const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null) const mountedRef = useMountedRef() + const repoSelector = useMemo<GitLabDialogRepoSelector | null>(() => { + if (!repoPath) { + return null + } + return { + repoPath, + ...(repoId ? { repoId } : {}), + ...(sourceContext ? { sourceContext } : {}) + } + }, [repoId, repoPath, sourceContext]) const updateCommentDraft = useCallback( (value: string): void => { setCommentDraftState({ itemId, value }) @@ -359,7 +380,7 @@ export default function GitLabItemDialog({ ) useEffect(() => { - if (!item || !repoPath) { + if (!item || !repoSelector) { setDetails(null) setLoading(false) setError(null) @@ -370,7 +391,7 @@ export default function GitLabItemDialog({ setLoading(true) setError(null) void window.api.gl - .workItemDetails({ repoPath, iid: item.number, type: item.type }) + .workItemDetails({ ...repoSelector, iid: item.number, type: item.type }) .then((data) => { if (stale) { return @@ -394,7 +415,7 @@ export default function GitLabItemDialog({ return () => { stale = true } - }, [item, repoPath, refreshNonce]) + }, [item, repoSelector, refreshNonce]) // Why: clear item-scoped dialog state when the sheet target changes. The // top-level comment draft is reconciled during render so it cannot flash stale. @@ -423,12 +444,12 @@ export default function GitLabItemDialog({ }, []) const loadGitLabLabelOptions = useCallback(async (): Promise<void> => { - if (!repoPath || labelOptions !== null || labelOptionsLoading) { + if (!repoSelector || labelOptions !== null || labelOptionsLoading) { return } setLabelOptionsLoading(true) try { - const labels = await window.api.gl.listLabels({ repoPath }) + const labels = await window.api.gl.listLabels(repoSelector) if (mountedRef.current) { setLabelOptions(normalizeGitLabLabels(labels)) } @@ -441,15 +462,15 @@ export default function GitLabItemDialog({ setLabelOptionsLoading(false) } } - }, [labelOptions, labelOptionsLoading, mountedRef, repoPath]) + }, [labelOptions, labelOptionsLoading, mountedRef, repoSelector]) const loadGitLabReviewerOptions = useCallback(async (): Promise<void> => { - if (!repoPath || reviewerOptions !== null || reviewerOptionsLoading) { + if (!repoSelector || reviewerOptions !== null || reviewerOptionsLoading) { return } setReviewerOptionsLoading(true) try { - const users = await window.api.gl.listAssignableUsers({ repoPath }) + const users = await window.api.gl.listAssignableUsers(repoSelector) if (mountedRef.current) { setReviewerOptions(dedupeGitLabUsers(users)) } @@ -462,7 +483,7 @@ export default function GitLabItemDialog({ setReviewerOptionsLoading(false) } } - }, [mountedRef, repoPath, reviewerOptions, reviewerOptionsLoading]) + }, [mountedRef, repoSelector, reviewerOptions, reviewerOptionsLoading]) const handleStartDetailsEdit = useCallback((): void => { if (!item || !details || item.type !== 'mr') { @@ -483,7 +504,7 @@ export default function GitLabItemDialog({ }, []) const handleSaveDetails = useCallback(async (): Promise<void> => { - if (!item || !details || !repoPath || item.type !== 'mr') { + if (!item || !details || !repoSelector || item.type !== 'mr') { return } const currentTitle = details.item.title || item.title @@ -521,7 +542,7 @@ export default function GitLabItemDialog({ setDetailsSaving(true) try { - const res = await window.api.gl.updateMR({ repoPath, iid: item.number, updates }) + const res = await window.api.gl.updateMR({ ...repoSelector, iid: item.number, updates }) if (res.ok) { if (mountedRef.current) { setDetails((current) => @@ -557,7 +578,7 @@ export default function GitLabItemDialog({ item, labelDraft, mountedRef, - repoPath, + repoSelector, titleDraft ]) @@ -568,7 +589,7 @@ export default function GitLabItemDialog({ return } setExpandedJobId(job.id) - if (!repoPath || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) { + if (!repoSelector || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) { return } setJobTraceById((current) => ({ @@ -577,7 +598,7 @@ export default function GitLabItemDialog({ })) try { const result = await window.api.gl.jobTrace({ - repoPath, + ...repoSelector, jobId: job.id, projectRef: details?.item.projectRef ?? item.projectRef ?? null }) @@ -602,18 +623,18 @@ export default function GitLabItemDialog({ } } }, - [details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoPath] + [details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoSelector] ) const handleRetryJob = useCallback( async (job: GitLabPipelineJob): Promise<void> => { - if (!repoPath || !item) { + if (!repoSelector || !item) { return } setRetryingJobId(job.id) try { const result = await window.api.gl.retryJob({ - repoPath, + ...repoSelector, jobId: job.id, projectRef: details?.item.projectRef ?? item.projectRef ?? null }) @@ -648,12 +669,12 @@ export default function GitLabItemDialog({ } } }, - [details?.item.projectRef, handleRefresh, item, mountedRef, repoPath] + [details?.item.projectRef, handleRefresh, item, mountedRef, repoSelector] ) const handleSetReviewers = useCallback( async (nextReviewers: GitLabAssignableUser[]): Promise<void> => { - if (!repoPath || !item || !details || item.type !== 'mr') { + if (!repoSelector || !item || !details || item.type !== 'mr') { return } const reviewerIds = nextReviewers @@ -671,7 +692,7 @@ export default function GitLabItemDialog({ setReviewerUpdating(true) try { const result = await window.api.gl.updateMRReviewers({ - repoPath, + ...repoSelector, iid: item.number, reviewerIds, projectRef: details.item.projectRef ?? item.projectRef ?? null @@ -697,11 +718,11 @@ export default function GitLabItemDialog({ } } }, - [details, item, mountedRef, repoPath] + [details, item, mountedRef, repoSelector] ) const handleSubmitInlineComment = useCallback(async (): Promise<void> => { - if (!repoPath || !item || !details || item.type !== 'mr') { + if (!repoSelector || !item || !details || item.type !== 'mr') { return } const file = (details.files ?? []).find((row) => row.path === inlineCommentFilePath) @@ -728,7 +749,7 @@ export default function GitLabItemDialog({ setInlineCommentSubmitting(true) try { const result = await window.api.gl.addMRInlineComment({ - repoPath, + ...repoSelector, iid: item.number, projectRef: details.item.projectRef ?? item.projectRef ?? null, input: { @@ -768,16 +789,16 @@ export default function GitLabItemDialog({ inlineCommentLine, item, mountedRef, - repoPath + repoSelector ]) const handleClose = useCallback(async (): Promise<void> => { - if (!item || !repoPath || item.type !== 'mr') { + if (!item || !repoSelector || item.type !== 'mr') { return } setActionInFlight('close') try { - const res = await window.api.gl.closeMR({ repoPath, iid: item.number }) + const res = await window.api.gl.closeMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -798,15 +819,15 @@ export default function GitLabItemDialog({ setActionInFlight(null) } } - }, [item, repoPath, mountedRef, handleRefresh]) + }, [item, repoSelector, mountedRef, handleRefresh]) const handleReopen = useCallback(async (): Promise<void> => { - if (!item || !repoPath || item.type !== 'mr') { + if (!item || !repoSelector || item.type !== 'mr') { return } setActionInFlight('reopen') try { - const res = await window.api.gl.reopenMR({ repoPath, iid: item.number }) + const res = await window.api.gl.reopenMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -827,15 +848,15 @@ export default function GitLabItemDialog({ setActionInFlight(null) } } - }, [item, repoPath, mountedRef, handleRefresh]) + }, [item, repoSelector, mountedRef, handleRefresh]) const handleMerge = useCallback(async (): Promise<void> => { - if (!item || !repoPath || item.type !== 'mr') { + if (!item || !repoSelector || item.type !== 'mr') { return } setActionInFlight('merge') try { - const res = await window.api.gl.mergeMR({ repoPath, iid: item.number }) + const res = await window.api.gl.mergeMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -856,11 +877,11 @@ export default function GitLabItemDialog({ setActionInFlight(null) } } - }, [item, repoPath, mountedRef, handleRefresh]) + }, [item, repoSelector, mountedRef, handleRefresh]) const handleSubmitComment = useCallback(async (): Promise<void> => { const body = commentDraft.trim() - if (!body || !item || !repoPath) { + if (!body || !item || !repoSelector) { return } setCommentSubmitting(true) @@ -869,8 +890,8 @@ export default function GitLabItemDialog({ // Branch on the item type to hit the right channel. const res = item.type === 'mr' - ? await window.api.gl.addMRComment({ repoPath, iid: item.number, body }) - : await window.api.gl.addIssueComment({ repoPath, number: item.number, body }) + ? await window.api.gl.addMRComment({ ...repoSelector, iid: item.number, body }) + : await window.api.gl.addIssueComment({ ...repoSelector, number: item.number, body }) if (res.ok) { if (mountedRef.current) { setCommentDraftState((current) => @@ -889,17 +910,17 @@ export default function GitLabItemDialog({ setCommentSubmitting(false) } } - }, [commentDraft, item, itemId, repoPath, mountedRef, handleRefresh]) + }, [commentDraft, item, itemId, repoSelector, mountedRef, handleRefresh]) const handleResolveDiscussion = useCallback( async (threadId: string, resolved: boolean): Promise<void> => { - if (!item || !repoPath || item.type !== 'mr') { + if (!item || !repoSelector || item.type !== 'mr') { return } setResolvingThreadId(threadId) try { const res = await window.api.gl.resolveMRDiscussion({ - repoPath, + ...repoSelector, iid: item.number, discussionId: threadId, resolved @@ -927,7 +948,7 @@ export default function GitLabItemDialog({ } } }, - [item, repoPath, mountedRef] + [item, repoSelector, mountedRef] ) // Why: GitMerge for MRs visually disambiguates from GitBranch (and diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 4be68a77b56..56ffbc802fb 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -42,12 +42,14 @@ import type { JiraTransition, JiraUser } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type JiraIssueWorkspaceProps = { issue: JiraIssue | null onUse: (issue: JiraIssue) => void onClose: () => void + sourceContext?: TaskSourceContext | null } const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) @@ -111,9 +113,11 @@ async function copyTextToClipboard(text: string, label: string): Promise<void> { export default function JiraIssueWorkspace({ issue, onUse, - onClose + onClose, + sourceContext }: JiraIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchJiraIssue = useAppStore((s) => s.patchJiraIssue) const [fullIssue, setFullIssue] = useState<JiraIssue | null>(null) const [issueLoading, setIssueLoading] = useState(false) @@ -139,7 +143,7 @@ export default function JiraIssueWorkspace({ setCommentsLoading(true) setCommentsError(null) try { - let fetched = await jiraIssueComments(settings, targetIssue.key, targetIssue.siteId) + let fetched = await jiraIssueComments(providerSettings, targetIssue.key, targetIssue.siteId) if (requestId !== requestIdRef.current) { return } @@ -159,7 +163,7 @@ export default function JiraIssueWorkspace({ } } }, - [settings] + [providerSettings] ) useEffect(() => { @@ -186,7 +190,7 @@ export default function JiraIssueWorkspace({ setCommentsError(null) setIssueLoading(true) - void jiraGetIssue(settings, issue.key, issue.siteId) + void jiraGetIssue(providerSettings, issue.key, issue.siteId) .then((result) => { if (requestId !== requestIdRef.current) { return @@ -205,9 +209,9 @@ export default function JiraIssueWorkspace({ }) void Promise.all([ - jiraListTransitions(settings, issue.key, issue.siteId), - jiraListPriorities(settings, issue.siteId), - jiraListAssignableUsers(settings, issue.key, undefined, issue.siteId) + jiraListTransitions(providerSettings, issue.key, issue.siteId), + jiraListPriorities(providerSettings, issue.siteId), + jiraListAssignableUsers(providerSettings, issue.key, undefined, issue.siteId) ]) .then(([nextTransitions, nextPriorities, nextUsers]) => { if (requestId !== requestIdRef.current) { @@ -220,22 +224,22 @@ export default function JiraIssueWorkspace({ .catch(() => {}) void loadComments(issue, requestId) - }, [issue, loadComments, settings]) + }, [issue, loadComments, providerSettings]) const refreshIssue = useCallback(async (): Promise<void> => { if (!displayed) { return } try { - const latest = await jiraGetIssue(settings, displayed.key, displayed.siteId) + const latest = await jiraGetIssue(providerSettings, displayed.key, displayed.siteId) if (latest) { setFullIssue(latest) - patchJiraIssue(latest.key, latest) + patchJiraIssue(latest.key, latest, { sourceContext }) } } catch { // Keep the visible issue snapshot if refresh fails. } - }, [displayed, patchJiraIssue, settings]) + }, [displayed, patchJiraIssue, providerSettings, sourceContext]) const mutateIssue = useCallback( async ( @@ -251,16 +255,16 @@ export default function JiraIssueWorkspace({ try { if (optimistic) { setFullIssue({ ...displayed, ...optimistic }) - patchJiraIssue(displayed.key, optimistic) + patchJiraIssue(displayed.key, optimistic, { sourceContext }) } - const result = await jiraUpdateIssue(settings, displayed.key, updates, siteId) + const result = await jiraUpdateIssue(providerSettings, displayed.key, updates, siteId) if (!result.ok) { throw new Error(result.error) } await refreshIssue() } catch (error) { setFullIssue(previous) - patchJiraIssue(previous.key, previous) + patchJiraIssue(previous.key, previous, { sourceContext }) toast.error( error instanceof Error ? error.message @@ -273,7 +277,7 @@ export default function JiraIssueWorkspace({ setPendingField(null) } }, - [displayed, patchJiraIssue, pendingField, refreshIssue, settings, siteId] + [displayed, patchJiraIssue, pendingField, refreshIssue, providerSettings, siteId, sourceContext] ) const handleSaveTitle = useCallback(() => { @@ -309,7 +313,12 @@ export default function JiraIssueWorkspace({ } setCommentSubmitting(true) try { - const result = await jiraAddIssueComment(settings, displayed.key, body, displayed.siteId) + const result = await jiraAddIssueComment( + providerSettings, + displayed.key, + body, + displayed.siteId + ) if (!result.ok) { throw new Error(result.error) } @@ -331,7 +340,7 @@ export default function JiraIssueWorkspace({ } finally { setCommentSubmitting(false) } - }, [commentDraft, commentSubmitting, displayed, settings]) + }, [commentDraft, commentSubmitting, displayed, providerSettings]) const actionItems = useMemo(() => { if (!displayed) { diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 3e97489e8df..473fa75d3c3 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import type { LinearIssue } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { getLinearIssueTextSavePlan, type LinearIssueTextField @@ -24,6 +25,7 @@ type LinearIssueTextEditorProps = { onIssueChange: (patch: Pick<LinearIssue, 'title'> | Pick<LinearIssue, 'description'>) => void density?: 'page' | 'drawer' fields?: 'all' | 'title' | 'description' + sourceContext?: TaskSourceContext | null } function useAutosizeTextArea(value: string): React.RefObject<HTMLTextAreaElement | null> { @@ -45,9 +47,11 @@ export function LinearIssueTextEditor({ issue, onIssueChange, density = 'page', - fields = 'all' + fields = 'all', + sourceContext }: LinearIssueTextEditorProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const [draftState, setDraftState] = useState(() => createLinearIssueTextDraftState(issue)) const [savingField, setSavingField] = useState<LinearIssueTextField | null>(null) @@ -111,7 +115,7 @@ export function LinearIssueTextEditor({ onIssueChange(patch) patchLinearIssue(issue.id, patch) try { - const result = await linearUpdateIssue(settings, issue.id, patch, issue.workspaceId) + const result = await linearUpdateIssue(providerSettings, issue.id, patch, issue.workspaceId) if (!result.ok) { throw new Error(result.error) } @@ -156,7 +160,7 @@ export function LinearIssueTextEditor({ mountedRef, onIssueChange, patchLinearIssue, - settings, + providerSettings, titleDraft, updateDescriptionDraft, updateTitleDraft diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index 0272093ee01..d3090782380 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -57,6 +57,7 @@ import type { LinearIssueChildSummary, LinearProjectSummary } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type LinearIssueWorkspaceProps = { @@ -66,6 +67,7 @@ type LinearIssueWorkspaceProps = { onClose: () => void variant?: 'sheet' | 'page' backLabel?: string + sourceContext?: TaskSourceContext | null } async function copyTextToClipboard(text: string, label: string): Promise<void> { @@ -111,12 +113,15 @@ function LinearIssueAvatar({ function LinearIssueSubIssueButton({ issue, - onOpenIssue + onOpenIssue, + sourceContext }: { issue: LinearIssue onOpenIssue: (issue: LinearIssue) => void + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) const [open, setOpen] = useState(false) const [title, setTitle] = useState('') @@ -145,7 +150,9 @@ function LinearIssueSubIssueButton({ async (subIssue: LinearIssueChildSummary) => { setOpeningSubIssueId(subIssue.id) try { - const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId) + const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId, { + sourceContext + }) if (!mountedRef.current) { return } @@ -173,7 +180,7 @@ function LinearIssueSubIssueButton({ } } }, - [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue] + [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue, sourceContext] ) const handleCreate = useCallback(async () => { @@ -183,7 +190,7 @@ function LinearIssueSubIssueButton({ } setSubmitting(true) try { - const result = await linearCreateSubIssue(settings, { + const result = await linearCreateSubIssue(providerSettings, { parentIssueId: issue.id, teamId: issue.team.id, title: trimmed, @@ -235,7 +242,7 @@ function LinearIssueSubIssueButton({ issue.subIssues, issue.team.id, issue.workspaceId, - settings, + providerSettings, title ]) @@ -311,12 +318,15 @@ function LinearIssueSubIssueButton({ function LinearIssueSidebarProjectCard({ issue, - onProjectChanged + onProjectChanged, + sourceContext }: { issue: LinearIssue onProjectChanged: (project: LinearProjectSummary) => void + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const [open, setOpen] = useState(false) const [query, setQuery] = useState('') @@ -331,7 +341,7 @@ function LinearIssueSidebarProjectCard({ let cancelled = false const timeout = window.setTimeout(() => { setLoading(true) - void linearListProjects(settings, query, 20, issue.workspaceId) + void linearListProjects(providerSettings, query, 20, issue.workspaceId) .then((result) => { if (!cancelled) { setProjects(result.items) @@ -359,21 +369,21 @@ function LinearIssueSidebarProjectCard({ cancelled = true window.clearTimeout(timeout) } - }, [issue.workspaceId, open, query, settings]) + }, [issue.workspaceId, open, providerSettings, query]) const handleSelectProject = useCallback( async (project: LinearProjectSummary) => { setSavingProjectId(project.id) try { const result = await linearUpdateIssue( - settings, + providerSettings, issue.id, { projectId: project.id }, issue.workspaceId ) if (result.ok) { onProjectChanged(project) - patchLinearIssue(issue.id, { project }) + patchLinearIssue(issue.id, { project }, { sourceContext }) toast.success( translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated') ) @@ -394,7 +404,14 @@ function LinearIssueSidebarProjectCard({ setSavingProjectId(null) } }, - [issue.id, issue.workspaceId, onProjectChanged, patchLinearIssue, settings] + [ + issue.id, + issue.workspaceId, + onProjectChanged, + patchLinearIssue, + providerSettings, + sourceContext + ] ) return ( @@ -482,9 +499,11 @@ export default function LinearIssueWorkspace({ onOpenIssue, onClose, variant = 'sheet', - backLabel = 'Back' + backLabel = 'Back', + sourceContext }: LinearIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null) const [issueLoading, setIssueLoading] = useState(false) const [comments, setComments] = useState<LinearComment[]>([]) @@ -519,7 +538,7 @@ export default function LinearIssueWorkspace({ } try { let fetched = (await linearIssueComments( - settings, + providerSettings, targetIssue.id, targetIssue.workspaceId )) as LinearComment[] @@ -542,7 +561,7 @@ export default function LinearIssueWorkspace({ } } }, - [mountedRef, settings] + [mountedRef, providerSettings] ) useEffect(() => { @@ -558,7 +577,7 @@ export default function LinearIssueWorkspace({ return } - const issueKey = `${settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` + const issueKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` if (hydratedIssueKeyRef.current === issueKey) { return } @@ -576,7 +595,7 @@ export default function LinearIssueWorkspace({ // Why: issue hydration and comments are separate surfaces; a comments // failure should not blank the issue detail the user selected. - void linearGetIssue(settings, issue.id, issue.workspaceId) + void linearGetIssue(providerSettings, issue.id, issue.workspaceId) .then((issueResult) => { if (!mountedRef.current || requestId !== requestIdRef.current) { return @@ -616,7 +635,7 @@ export default function LinearIssueWorkspace({ }) void loadComments(issue, requestId) - }, [issue, loadComments, mountedRef, settings]) + }, [issue, loadComments, mountedRef, providerSettings, settings, sourceContext?.hostId]) const displayed = fullIssue ?? issue @@ -796,9 +815,17 @@ export default function LinearIssueWorkspace({ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"> <div className="mx-auto grid w-full grid-cols-1 gap-10 px-7 py-10 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-10 xl:px-12"> <main className="min-w-0"> - <LinearIssueTextEditor issue={displayed} onIssueChange={handleIssueTextChange} /> + <LinearIssueTextEditor + issue={displayed} + onIssueChange={handleIssueTextChange} + sourceContext={sourceContext} + /> - <LinearIssueSubIssueButton issue={displayed} onOpenIssue={onOpenIssue} /> + <LinearIssueSubIssueButton + issue={displayed} + onOpenIssue={onOpenIssue} + sourceContext={sourceContext} + /> <section className="mt-12 border-t border-border/60 pt-9"> <div className="mb-8 flex items-center justify-between gap-3"> @@ -894,6 +921,7 @@ export default function LinearIssueWorkspace({ workspaceId={displayed.workspaceId} onCommentAdded={handleCommentAdded} variant="linear-page" + sourceContext={sourceContext} /> </section> </main> @@ -905,11 +933,13 @@ export default function LinearIssueWorkspace({ editState={editState} onEditStateChange={handleEditStateChange} layout="properties" + sourceContext={sourceContext} /> ) : null} <LinearIssueSidebarProjectCard issue={displayed} onProjectChanged={handleProjectChanged} + sourceContext={sourceContext} /> <section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs"> <div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground"> diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 501cd5c2b8e..d123808f227 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -38,6 +38,7 @@ import { } from '@/components/linear-state-pill-style' import { LinearPriorityIcon } from '@/components/linear-priority-icon' import type { LinearIssue, LinearComment } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { linearAddIssueComment, linearGetIssue, @@ -118,6 +119,7 @@ type LinearItemDrawerProps = { issue: LinearIssue | null onUse: (issue: LinearIssue) => void onClose: () => void + sourceContext?: TaskSourceContext | null } export type LinearEditState = { @@ -134,18 +136,21 @@ type EditSectionProps = { editState: LinearEditState onEditStateChange: (patch: Partial<LinearEditState>) => void layout?: 'chips' | 'properties' + sourceContext?: TaskSourceContext | null } export function LinearIssueEditSection({ issue, editState, onEditStateChange, - layout = 'chips' + layout = 'chips', + sourceContext }: EditSectionProps): React.JSX.Element { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false) const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const { isPending, run } = useImmediateMutation() const { @@ -159,9 +164,9 @@ export function LinearIssueEditSection({ const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate)) const teamId = issue.team?.id || null - const states = useTeamStates(teamId, settings, issue.workspaceId) - const labels = useTeamLabels(teamId, settings, issue.workspaceId) - const members = useTeamMembers(teamId, settings, issue.workspaceId) + const states = useTeamStates(teamId, providerSettings, issue.workspaceId) + const labels = useTeamLabels(teamId, providerSettings, issue.workspaceId) + const members = useTeamMembers(teamId, providerSettings, issue.workspaceId) const handleEstimatePopoverOpenChange = useCallback( (open: boolean) => { @@ -184,14 +189,14 @@ export function LinearIssueEditSection({ const stateValue = { name: newState.name, type: newState.type, color: newState.color } run('state', { - mutate: () => linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId), + mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ state: stateValue }) - patchLinearIssue(issue.id, { state: stateValue }) + patchLinearIssue(issue.id, { state: stateValue }, { sourceContext }) }, onRevert: () => { onEditStateChange({ state: prevState }) - patchLinearIssue(issue.id, { state: prevState }) + patchLinearIssue(issue.id, { state: prevState }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -203,11 +208,12 @@ export function LinearIssueEditSection({ issue.id, issue.workspaceId, localState, - settings, + providerSettings, states.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -216,14 +222,15 @@ export function LinearIssueEditSection({ const priority = parseInt(value, 10) const prevPriority = localPriority run('priority', { - mutate: () => linearUpdateIssue(settings, issue.id, { priority }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ priority }) - patchLinearIssue(issue.id, { priority }) + patchLinearIssue(issue.id, { priority }, { sourceContext }) }, onRevert: () => { onEditStateChange({ priority: prevPriority }) - patchLinearIssue(issue.id, { priority: prevPriority }) + patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -231,22 +238,32 @@ export function LinearIssueEditSection({ onError: (err) => toast.error(err) }) }, - [issue.id, issue.workspaceId, localPriority, settings, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localPriority, + providerSettings, + patchLinearIssue, + run, + onEditStateChange, + sourceContext + ] ) const handleEstimateChange = useCallback( (estimate: number | null) => { const prevEstimate = localEstimate run('estimate', { - mutate: () => linearUpdateIssue(settings, issue.id, { estimate }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ estimate }) - patchLinearIssue(issue.id, { estimate }) + patchLinearIssue(issue.id, { estimate }, { sourceContext }) setEstimatePopoverOpen(false) }, onRevert: () => { onEditStateChange({ estimate: prevEstimate }) - patchLinearIssue(issue.id, { estimate: prevEstimate }) + patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -254,7 +271,16 @@ export function LinearIssueEditSection({ onError: (err) => toast.error(err) }) }, - [issue.id, issue.workspaceId, localEstimate, settings, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localEstimate, + providerSettings, + patchLinearIssue, + run, + onEditStateChange, + sourceContext + ] ) const handleEstimateSubmit = useCallback(() => { @@ -287,14 +313,15 @@ export function LinearIssueEditSection({ ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } : undefined run('assignee', { - mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) - patchLinearIssue(issue.id, { assignee: newAssignee }) + patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext }) }, onRevert: () => { onEditStateChange({ assignee: prevAssignee }) - patchLinearIssue(issue.id, { assignee: prevAssignee }) + patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -306,11 +333,12 @@ export function LinearIssueEditSection({ issue.id, issue.workspaceId, localAssignee, - settings, + providerSettings, members.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -328,14 +356,27 @@ export function LinearIssueEditSection({ run('labels', { mutate: () => - linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }, issue.workspaceId), + linearUpdateIssue( + providerSettings, + issue.id, + { labelIds: newLabelIds }, + issue.workspaceId + ), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) - patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) + patchLinearIssue( + issue.id, + { labelIds: newLabelIds, labels: newLabels }, + { sourceContext } + ) }, onRevert: () => { onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels }) - patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels }) + patchLinearIssue( + issue.id, + { labelIds: prevLabelIds, labels: prevLabels }, + { sourceContext } + ) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -348,11 +389,12 @@ export function LinearIssueEditSection({ issue.workspaceId, localLabelIds, localLabels, - settings, + providerSettings, labels.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -994,14 +1036,17 @@ export function LinearIssueCommentFooter({ issueId, workspaceId, onCommentAdded, - variant = 'compact' + variant = 'compact', + sourceContext }: { issueId: string workspaceId?: string | null onCommentAdded: (comment: LinearLocalComment) => void variant?: 'compact' | 'linear-page' + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const submitShortcutLabel = getScreenSubmitShortcutLabel() const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) @@ -1030,7 +1075,7 @@ export function LinearIssueCommentFooter({ } setSubmitting(true) try { - const result = await linearAddIssueComment(settings, issueId, trimmed, workspaceId) + const result = await linearAddIssueComment(providerSettings, issueId, trimmed, workspaceId) const typed = result as { ok: boolean; id?: string; error?: string } if (!mountedRef.current) { return @@ -1062,7 +1107,7 @@ export function LinearIssueCommentFooter({ setSubmitting(false) } } - }, [body, issueId, onCommentAdded, settings, workspaceId]) + }, [body, issueId, onCommentAdded, providerSettings, workspaceId]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -1168,7 +1213,8 @@ export function initLinearIssueEditState(issue: LinearIssue): LinearEditState { export default function LinearItemDrawer({ issue, onUse, - onClose + onClose, + sourceContext }: LinearItemDrawerProps): React.JSX.Element { const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null) const [comments, setComments] = useState<LinearComment[]>([]) @@ -1178,6 +1224,7 @@ export default function LinearItemDrawer({ const hasEditedRef = useRef(false) const optimisticCommentsRef = useRef<LinearComment[]>([]) const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const handleEditStateChange = useCallback((patch: Partial<LinearEditState>) => { hasEditedRef.current = true @@ -1214,7 +1261,7 @@ export default function LinearItemDrawer({ // Why: fetch issue and comments independently so a transient comments // failure doesn't discard the successfully-fetched issue data. - linearGetIssue(settings, issue.id, issue.workspaceId) + linearGetIssue(providerSettings, issue.id, issue.workspaceId) .then((issueResult) => { if (requestId !== requestIdRef.current) { return @@ -1231,7 +1278,7 @@ export default function LinearItemDrawer({ }) .catch(() => {}) - linearIssueComments(settings, issue.id, issue.workspaceId) + linearIssueComments(providerSettings, issue.id, issue.workspaceId) .then((commentsResult) => { if (requestId !== requestIdRef.current) { return @@ -1256,7 +1303,7 @@ export default function LinearItemDrawer({ } }) // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [issue?.id, issue?.workspaceId, settings]) + }, [issue?.id, issue?.workspaceId, providerSettings]) // Why: same pointer-events fix as GitHubItemDialog — Radix may leave // pointer-events: none on body when overlays transition. @@ -1343,6 +1390,7 @@ export default function LinearItemDrawer({ onIssueChange={handleIssueTextChange} density="drawer" fields="title" + sourceContext={sourceContext} /> </div> <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground"> @@ -1400,6 +1448,7 @@ export default function LinearItemDrawer({ issue={displayed} editState={editState} onEditStateChange={handleEditStateChange} + sourceContext={sourceContext} /> )} @@ -1411,6 +1460,7 @@ export default function LinearItemDrawer({ onIssueChange={handleIssueTextChange} density="drawer" fields="description" + sourceContext={sourceContext} /> </div> @@ -1472,6 +1522,7 @@ export default function LinearItemDrawer({ issueId={displayed.id} workspaceId={displayed.workspaceId} onCommentAdded={handleCommentAdded} + sourceContext={sourceContext} /> <div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3"> <Button diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 70b29ba4cf6..66c9c5d35b1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import RepoCombobox from '@/components/repo/RepoCombobox' +import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' @@ -35,12 +35,18 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' +import ProjectHostSetupCombobox from '@/components/new-workspace/ProjectHostSetupCombobox' import type { SetupConfig } from '@/lib/new-workspace' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number] +const EMPTY_PROJECT_HOST_SETUP_OPTIONS: ProjectHostSetupOption[] = [] +const EMPTY_PROJECT_OPTIONS: NewWorkspaceProjectOption[] = [] type NewWorkspaceComposerCardProps = { contextualTourSource?: string @@ -52,8 +58,14 @@ type NewWorkspaceComposerCardProps = { onQuickAgentChange: (agent: TuiAgent | null) => void eligibleRepos: RepoOption[] repoId: string + projectOptions?: NewWorkspaceProjectOption[] + selectedProjectId?: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void + projectHostSetupOptions?: ProjectHostSetupOption[] + selectedProjectHostSetupId?: string | null + onProjectHostSetupChange?: (setupId: string) => void primaryActionLabel: string projectLabel?: string projectPlaceholder?: string @@ -274,8 +286,14 @@ export default function NewWorkspaceComposerCard({ onQuickAgentChange, eligibleRepos, repoId, + projectOptions = EMPTY_PROJECT_OPTIONS, + selectedProjectId = null, selectedRepoIsGit, onRepoChange, + onProjectChange, + projectHostSetupOptions = EMPTY_PROJECT_HOST_SETUP_OPTIONS, + selectedProjectHostSetupId = null, + onProjectHostSetupChange, primaryActionLabel, projectLabel, projectPlaceholder, @@ -425,6 +443,16 @@ export default function NewWorkspaceComposerCard({ openModal('add-repo') }, [openModal]) const projectDescriptionId = React.useId() + const readyProjectHostSetupOptions = React.useMemo( + () => projectHostSetupOptions.filter((option) => option.kind === 'ready'), + [projectHostSetupOptions] + ) + const handleProjectHostSetupChange = React.useCallback( + (setupId: string): void => { + onProjectHostSetupChange?.(setupId) + }, + [onProjectHostSetupChange] + ) useContextualTour( 'workspace-creation', eligibleRepos.length > 0 && Boolean(repoId), @@ -481,10 +509,10 @@ export default function NewWorkspaceComposerCard({ </Tooltip> ) : null} </div> - <RepoCombobox - repos={eligibleRepos} - value={repoId} - onValueChange={onRepoChange} + <ProjectCombobox + options={projectOptions} + value={selectedProjectId} + onValueChange={onProjectChange} onValueSelected={focusNameInput} placeholder={ projectPlaceholder ?? @@ -497,7 +525,6 @@ export default function NewWorkspaceComposerCard({ // paints the familiar field ring instead of leaving no visible // focus state. triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50" - showStandaloneAddButton={false} invalid={Boolean(projectError)} describedBy={projectDescriptionId} /> @@ -514,6 +541,18 @@ export default function NewWorkspaceComposerCard({ )} </p> ) : null} + {readyProjectHostSetupOptions.length > 1 ? ( + <div className="space-y-1"> + <label className="block min-w-0 truncate text-xs font-medium text-muted-foreground"> + {translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')} + </label> + <ProjectHostSetupCombobox + options={readyProjectHostSetupOptions} + value={selectedProjectHostSetupId ?? null} + onValueChange={handleProjectHostSetupChange} + /> + </div> + ) : null} {selectedRepoRequiresConnection && selectedRepoConnectionId ? ( <div role="status" diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index a5b68b7395e..a41857ab1ef 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -22,12 +22,14 @@ import type { WorkspaceCreateTelemetrySource, WorkspaceStatus } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type ComposerModalData = { prefilledName?: string initialRepoId?: string linkedWorkItem?: LinkedWorkItemSummary | null + taskSourceContext?: TaskSourceContext | null initialBaseBranch?: string initialWorkspaceStatus?: WorkspaceStatus /** Telemetry surface that opened the composer. Set by each @@ -124,6 +126,7 @@ function QuickTabBody({ // intentionally ignored even if older callers still send it. initialPrompt: '', initialLinkedWorkItem: modalData.linkedWorkItem ?? null, + initialTaskSourceContext: modalData.taskSourceContext ?? null, initialRepoId: modalData.initialRepoId, initialWorkspaceStatus: modalData.initialWorkspaceStatus, ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 6b4c706afa0..159e9148b5d 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -11,6 +11,7 @@ import React, { useSyncExternalStore } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' +import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' import { ArrowDown, @@ -84,11 +85,6 @@ import { import type { DiffSection } from '@/components/editor/diff-section-types' import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { - REVIEW_ACTION_MERGE_BUTTON_CLASS, - REVIEW_ACTION_STATE_BUTTON_CLASS, - RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS -} from '@/components/right-sidebar/right-sidebar-primary-action-layout' import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' import { createGitHubChecksTabState, @@ -138,8 +134,6 @@ import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' -import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins @@ -184,6 +178,7 @@ import type { PRComment } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -207,25 +202,6 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { } } -function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { - try { - const parsed = new URL(itemUrl) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return null - } - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 2) { - return null - } - parsed.pathname = `/${segments[0]}/${segments[1]}/labels` - parsed.search = '' - parsed.hash = '' - return parsed.toString() - } catch { - return null - } -} - const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) export type ItemDialogTab = 'conversation' | 'checks' | 'files' @@ -547,7 +523,9 @@ function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) const reviewerPanelMountedRef = useRef(true) @@ -622,11 +600,12 @@ function PRReviewersPanel({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + repoOwnerSettings ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null + open && !reviewSlug ? item.repoId : null, + repoOwnerSettings ) const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -725,7 +704,8 @@ function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment' const measureReviewerPickerPlacement = useCallback(() => { const rect = reviewerInputRef.current?.getBoundingClientRect() @@ -768,7 +748,7 @@ function PRReviewersPanel({ ) return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(repoOwnerSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -842,7 +822,7 @@ function PRReviewersPanel({ if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(repoOwnerSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -2702,6 +2682,7 @@ function CommentCodeContext({ function ConversationTab({ item, repoPath, + repoId, body, comments, files, @@ -2749,7 +2730,10 @@ function ConversationTab({ const [bodySaving, setBodySaving] = useState(false) const bodyTextareaRef = useRef<HTMLTextAreaElement>(null) const bodyTextareaFocusFrameRef = useRef<number | null>(null) - const repoAssignees = useRepoAssignees(repoPath, item.repoId) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) + ) + const repoAssignees = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), @@ -3488,7 +3472,7 @@ function PRActionsPanel({ <WorkItemStateBadge item={actionItem} /> </div> - <div className="grid gap-2 justify-items-start"> + <div className="grid gap-2"> <DropdownMenu modal={false}> <Tooltip> <TooltipTrigger asChild> @@ -3497,8 +3481,7 @@ function PRActionsPanel({ type="button" size="sm" className={cn( - REVIEW_ACTION_MERGE_BUTTON_CLASS, - 'gap-2 bg-green-600 text-white hover:bg-green-700', + 'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700', 'disabled:cursor-not-allowed disabled:opacity-50' )} > @@ -3507,13 +3490,11 @@ function PRActionsPanel({ ) : ( <GitMerge className="size-3.5" /> )} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {mergePresentation.autoMergeAction?.label ?? - (mergePresentation.directMergeAvailable - ? mergeMethods.defaultLabel - : mergePresentation.label)} - </span> - <ChevronDown className="size-3 shrink-0 opacity-60" /> + {mergePresentation.autoMergeAction?.label ?? + (mergePresentation.directMergeAvailable + ? mergeMethods.defaultLabel + : mergePresentation.label)} + <ChevronDown className="size-3 opacity-60" /> </Button> </DropdownMenuTrigger> </TooltipTrigger> @@ -3559,8 +3540,7 @@ function PRActionsPanel({ variant={nextState === 'closed' ? 'outline' : 'secondary'} size="sm" className={cn( - REVIEW_ACTION_STATE_BUTTON_CLASS, - 'gap-2', + 'w-full justify-center gap-2', nextState === 'closed' && 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50' )} @@ -3574,11 +3554,9 @@ function PRActionsPanel({ ) : ( <CircleDot className="size-3.5" /> )} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {nextState === 'closed' - ? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request') - : translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')} - </span> + {nextState === 'closed' + ? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request') + : translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')} </Button> </div> </aside> @@ -4813,6 +4791,13 @@ function MentionTextarea({ // repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt // to a thrown rejection so the existing `useImmediateMutation` flow // (which expects throws on failure) continues to work unchanged. +function getGitHubMutationSettings(repoId: string | null | undefined) { + const state = useAppStore.getState() + // Why: project-origin mutations are slug-addressed, but when we know the + // backing repo id they must still execute on that repo's owner host. + return getSettingsForRepoRuntimeOwner(state, repoId ?? null) +} + async function runIssueUpdate(args: { repoPath: string | null repoId?: string | null @@ -4821,7 +4806,7 @@ async function runIssueUpdate(args: { updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates'] }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4870,7 +4855,7 @@ async function runWorkItemBodyUpdate(args: { if (!targetSlug) { throw new Error('No GitHub repository context available for this pull request.') } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId)) const updateArgs = { owner: targetSlug.owner, repo: targetSlug.repo, @@ -4909,7 +4894,7 @@ async function runPullRequestStateUpdate(args: { updates: { state: 'open' | 'closed' } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4979,8 +4964,10 @@ function GHEditSection({ const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) + ) const { isPending, run } = useImmediateMutation() - const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside // `workItemsCache` — `patchWorkItem` only walks the latter, so without this @@ -5003,15 +4990,22 @@ function GHEditSection({ const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + repoOwnerSettings ) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, repoOwnerSettings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + repoOwnerSettings + ) + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assignees, + repoOwnerSettings ) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch @@ -5221,6 +5215,18 @@ function GHEditSection({ return null } + const checkIcon = ( + <svg className="size-2.5" viewBox="0 0 12 12" fill="none"> + <path + d="M2 6l3 3 5-5" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ) + return ( <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5"> {/* State */} @@ -5290,16 +5296,34 @@ function GHEditSection({ </button> </PopoverTrigger> <PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start"> - <GitHubWorkItemLabelPopoverContent - open={labelPopoverOpen} - labels={repoLabels.data} - selectedLabels={localLabels} - error={repoLabels.error} - loading={repoLabels.loading} - repositoryLabelsUrl={repositoryLabelsUrl} - onToggleLabel={handleLabelToggle} - onOpenSettingsLink={() => setLabelPopoverOpen(false)} - /> + {repoLabels.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoLabels.error} + </div> + ) : ( + <div> + {repoLabels.data.map((label) => ( + <button + key={label} + type="button" + onClick={() => handleLabelToggle(label)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localLabels.includes(label) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localLabels.includes(label) && checkIcon} + </span> + {label} + </button> + ))} + </div> + )} </PopoverContent> </Popover> @@ -5330,14 +5354,41 @@ function GHEditSection({ </button> </PopoverTrigger> <PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start"> - <GitHubWorkItemAssigneePopoverContent - open={assigneePopoverOpen} - assignees={repoAssignees.data} - selectedLogins={localAssignees} - error={repoAssignees.error} - loading={repoAssignees.loading} - onToggleAssignee={handleAssigneeToggle} - /> + {repoAssignees.error ? ( + <div className="px-2 py-3 text-center text-[12px] text-destructive"> + {repoAssignees.error} + </div> + ) : ( + <div> + {repoAssignees.data.map((user) => ( + <button + key={user.login} + type="button" + onClick={() => handleAssigneeToggle(user.login)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent" + > + <span + className={cn( + 'flex size-3.5 items-center justify-center rounded-sm border', + localAssignees.includes(user.login) + ? 'border-primary bg-primary text-primary-foreground' + : 'border-input' + )} + > + {localAssignees.includes(user.login) && checkIcon} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate">{user.login}</span> + {user.name && ( + <span className="block truncate text-[11px] text-muted-foreground"> + {user.name} + </span> + )} + </span> + </button> + ))} + </div> + )} </PopoverContent> </Popover> diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index f127caf344d..4f6a4f9eaf4 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { ExternalLink, Star, X } from 'lucide-react' import { Card } from './ui/card' import { Button } from './ui/button' @@ -39,24 +39,18 @@ export function StarNagCard(): React.JSX.Element | null { }) }, []) - const handleClose = useCallback((): void => { - if (busy) { - return - } + const handleClose = (): void => { setVisible(false) // Why: fire-and-forget. If persisting the dismissal fails the worst case // is we re-fire the same threshold on next launch — not worth blocking // the close animation on. void window.api.starNag.dismiss() - }, [busy]) + } - const handleDisable = useCallback((): void => { - if (busy) { - return - } + const handleDisable = (): void => { setVisible(false) void window.api.starNag.disable() - }, [busy]) + } useEffect(() => { if (!visible) { @@ -69,7 +63,9 @@ export function StarNagCard(): React.JSX.Element | null { } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [handleClose, visible]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes + // over stable refs; re-binding on each render is unnecessary. + }, [visible]) if (!visible) { return null @@ -82,7 +78,7 @@ export function StarNagCard(): React.JSX.Element | null { if (mode === 'web') { setBusy(true) await window.api.shell.openUrl(ORCA_STARGAZERS_URL) - await window.api.starNag.openWeb() + await window.api.starNag.disable() if (mountedRef.current) { setBusy(false) setVisible(false) @@ -90,7 +86,7 @@ export function StarNagCard(): React.JSX.Element | null { return } setBusy(true) - const ok = await window.api.starNag.starOrca() + const ok = await window.api.gh.starOrca('star_nag') if (mountedRef.current) { setBusy(false) } @@ -100,6 +96,7 @@ export function StarNagCard(): React.JSX.Element | null { } return } + await window.api.starNag.complete() if (mountedRef.current) { setVisible(false) } @@ -129,7 +126,6 @@ export function StarNagCard(): React.JSX.Element | null { size="icon" className="size-7 shrink-0" onClick={handleClose} - disabled={busy} aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')} > <X className="size-3.5" /> @@ -160,22 +156,10 @@ export function StarNagCard(): React.JSX.Element | null { : translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')} </Button> <div className="flex items-center justify-between gap-2"> - <Button - variant="ghost" - size="sm" - className="h-7 px-2" - onClick={handleClose} - disabled={busy} - > + <Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}> {translate('auto.components.StarNagCard.8c967b4d15', 'Not now')} </Button> - <Button - variant="ghost" - size="sm" - className="h-7 px-2" - onClick={handleDisable} - disabled={busy} - > + <Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}> {translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")} </Button> </div> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d89d314868e..a52c55b3a0b 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,10 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { + getSettingsFocusedExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' @@ -81,7 +85,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' +import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -89,6 +93,14 @@ import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/I import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import { LinearPriorityIcon } from '@/components/linear-priority-icon' import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' +import { + getTaskSourceAvailabilityNotice, + getTaskSourceContextSummary +} from './task-source-context-summary' +import type { + TaskSourceAvailabilityNotice, + TaskSourceHostAvailability +} from './task-source-context-summary' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, @@ -118,6 +130,12 @@ import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDia import PullRequestPage from '@/components/PullRequestPage' import GitLabItemDialog from '@/components/GitLabItemDialog' import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + buildExecutionHostRegistry, + type ExecutionHostRegistryEntry +} from '../../../shared/execution-host-registry' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import LinearIssueWorkspace from '@/components/LinearIssueWorkspace' import { LinearCollectionNotice, @@ -138,6 +156,15 @@ import { import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { isGitRepoKind } from '../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + normalizeTaskSourceContext, + type TaskSourceContext +} from '../../../shared/task-source-context' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { buildTaskPageRepoSourceState, @@ -154,6 +181,16 @@ import { } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' import { findTaskPageJiraIssue } from '@/components/task-page-jira-cache-selectors' +import { getRepoBackedTaskEmptyState } from '@/components/task-page-empty-state' +import { + getDefaultTaskRepoSelection, + getTaskProjectPickerGroups, + normalizeTaskRepoSelection +} from '@/components/task-page-default-repo-selection' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '@/components/task-source-provider-availability' import { createTaskPageGitHubStatusStateDraft, resolveTaskPageGitHubStatusStateDraft, @@ -189,6 +226,8 @@ import type { TaskProvider, TaskViewPresetId } from '../../../shared/types' +import type { PreflightStatus } from '../../../preload/api-types' +import type { GitLabProjectRef } from '../../../shared/gitlab-types' import { LINEAR_ISSUE_LIST_MAX, clampLinearIssueListLimit @@ -300,6 +339,96 @@ function getJiraIssueWorkspaceSeed(issue: JiraIssue): string { ) } +function getTaskPageRepoSourceContext( + repo: Repo | null | undefined, + provider: 'github' | 'gitlab', + gitlabProjectRef?: GitLabProjectRef | null +): TaskSourceContext | null { + if (!repo) { + return null + } + const projection = projectHostSetupProjectionFromRepos([repo]) + const project = projection.projects[0] + const setup = projection.setups[0] + const providerIdentity = + provider === 'github' && project?.providerIdentity?.provider === 'github' + ? project.providerIdentity + : provider === 'gitlab' && gitlabProjectRef + ? buildGitLabProviderIdentity(gitlabProjectRef) + : null + return normalizeTaskSourceContext({ + provider, + projectId: setup?.projectId ?? project?.id ?? repo.id, + hostId: setup?.hostId ?? getRepoExecutionHostId(repo), + projectHostSetupId: setup?.id, + repoId: repo.id, + providerIdentity + }) +} + +function buildGitLabProviderIdentity(projectRef: GitLabProjectRef) { + const pathParts = projectRef.path + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + const projectName = pathParts.at(-1) ?? null + const namespace = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : null + return { + provider: 'gitlab' as const, + projectId: projectRef.path, + namespace, + project: projectName, + webUrl: `https://${projectRef.host}/${projectRef.path}` + } +} + +function getTaskSourceHostAvailabilityForHost( + host: ExecutionHostRegistryEntry | null | undefined, + hostId: TaskSourceContext['hostId'] +): TaskSourceHostAvailability | null { + if (!host) { + return null + } + if (host.kind === 'runtime') { + if (!host.capabilities) { + return { + hostId, + reason: 'checking-task-source-capability' + } + } + if (!host.capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { + hostId, + reason: 'missing-task-source-capability' + } + } + } + if (host.health === 'local' || host.health === 'available') { + return null + } + return { + hostId, + health: host.health, + status: host.connectionStatus + } +} + +function getTaskPageRepoCacheInput(repo: Repo): { + id: string + path: string + executionHostId?: string | null + sourceCacheScope?: string | null +} { + const sourceContext = getTaskPageRepoSourceContext(repo, 'github') + return { + id: repo.id, + path: repo.path, + executionHostId: repo.executionHostId, + sourceCacheScope: + sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null + } +} + // Why: the row's px-3 left padding leaves a 12px gap between the scroll-viewport // edge and the sticky ID column; without a covering ::before, scrolled cell text // bleeds through that strip. Same trick as the title column for its 8px gap. @@ -445,14 +574,17 @@ function findLinearWorkflowStateForStatus( function LinearStateCell({ issue, - className + className, + sourceContext }: { issue: LinearIssue className?: string + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) - const states = useTeamStates(issue.team.id, settings, issue.workspaceId) + const states = useTeamStates(issue.team.id, providerSettings, issue.workspaceId) const [open, setOpen] = useState(false) const [pending, setPending] = useState(false) const reqRef = useRef(0) @@ -479,7 +611,7 @@ function LinearStateCell({ setPending(true) patchLinearIssue(issue.id, { state: nextState }) - void linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId) + void linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId) .then((result) => { if (reqId !== reqRef.current) { return @@ -516,7 +648,7 @@ function LinearStateCell({ issue.workspaceId, patchLinearIssue, pending, - settings, + providerSettings, states.data ] ) @@ -692,6 +824,10 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet<LinearDisplay return columns.join(' ') } +function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)) +} + function getJiraStatusTone(categoryKey: string): string { if (categoryKey === 'done') { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' @@ -839,12 +975,27 @@ function buildJiraCreateCustomFields( function GHStatusCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const [statusStateDraft, setStatusStateDraft] = useState(() => createTaskPageGitHubStatusStateDraft(item) ) @@ -875,19 +1026,22 @@ function GHStatusCell({ reqRef.current += 1 const reqId = reqRef.current updateLocalState(newState) - patchWorkItem(item.id, { state: newState }, item.repoId) - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const updatePromise = target.kind === 'environment' ? callRuntimeRpc<{ ok?: boolean; error?: string }>( target, 'github.updateIssue', - { repo: repo.id, number: item.number, updates: { state: newState } }, + { repo: runtimeRepoId, number: item.number, updates: { state: newState } }, { timeoutMs: 30_000 } ) : window.api.gh.updateIssue({ repoPath: repo.path, repoId: repo.id, + sourceContext, number: item.number, updates: { state: newState } }) @@ -902,7 +1056,8 @@ function GHStatusCell({ patchWorkItem( item.id, { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId + item.repoId, + { sourceContext } ) toast.error( typed.error ?? @@ -917,11 +1072,18 @@ function GHStatusCell({ return } updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem(item.id, { state: newState === 'closed' ? 'open' : 'closed' }, item.repoId) + patchWorkItem( + item.id, + { state: newState === 'closed' ? 'open' : 'closed' }, + item.repoId, + { + sourceContext + } + ) toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) }, - [item, localState, repo, patchWorkItem, updateLocalState] + [item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState] ) if (item.type !== 'issue' || !repo) { @@ -1270,13 +1432,27 @@ function GitHubIssueAssigneeSelector({ function GHAssigneesCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const [open, setOpen] = useState(false) const [pendingLogin, setPendingLogin] = useState<string | null>(null) const assignees = useMemo(() => item.assignees ?? [], [item.assignees]) @@ -1295,7 +1471,7 @@ function GHAssigneesCell({ open ? owner : null, open ? repoName : null, seedLogins, - settings + sourceSettings ) const toggleAssignee = useCallback( @@ -1310,11 +1486,11 @@ function GHAssigneesCell({ ? assignees.filter((a) => a.login.toLowerCase() !== userLoginKey) : [...assignees, user] setPendingLogin(user.login) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) try { const updates = isOn ? { removeAssignees: [user.login] } : { addAssignees: [user.login] } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (owner && repoName) { const args = { owner, @@ -1335,17 +1511,20 @@ function GHAssigneesCell({ throw new Error(res.error.message) } } else if (repo) { + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const res = target.kind === 'environment' ? await callRuntimeRpc<{ ok?: boolean; error?: string }>( target, 'github.updateIssue', - { repo: repo.id, number: item.number, updates }, + { repo: runtimeRepoId, number: item.number, updates }, { timeoutMs: 30_000 } ) : await window.api.gh.updateIssue({ repoPath: repo.path, repoId: repo.id, + sourceContext, number: item.number, updates }) @@ -1357,7 +1536,7 @@ function GHAssigneesCell({ } useAppStore.getState().recordFeatureInteraction('github-tasks') } catch (err) { - patchWorkItem(item.id, { assignees: previousAssignees }, item.repoId) + patchWorkItem(item.id, { assignees: previousAssignees }, item.repoId, { sourceContext }) toast.error( err instanceof Error ? err.message @@ -1378,7 +1557,8 @@ function GHAssigneesCell({ pendingLogin, repo, repoName, - settings + sourceContext, + sourceSettings ] ) @@ -1583,10 +1763,12 @@ function buildRequestedReviewUsers( function PRReviewCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') @@ -1601,7 +1783,19 @@ function PRReviewCell({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 }) const [submitting, setSubmitting] = useState(false) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) @@ -1668,7 +1862,7 @@ function PRReviewCell({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + sourceSettings ) const authorLogin = item.author?.toLowerCase() ?? null @@ -1796,18 +1990,21 @@ function PRReviewCell({ } setSubmitting(true) try { - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const result = target.kind === 'environment' ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.requestPRReviewers', - { repo: repo.id, prNumber: item.number, reviewers: logins }, + { repo: runtimeRepoId, prNumber: item.number, reviewers: logins }, { timeoutMs: 30_000 } ) : await window.api.gh.requestPRReviewers({ repoPath: repo.path, repoId: repo.id, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -1819,7 +2016,9 @@ function PRReviewCell({ localReviewRequests ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') } else { @@ -1845,18 +2044,21 @@ function PRReviewCell({ } setSubmitting(true) try { - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const result = target.kind === 'environment' ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.removePRReviewers', - { repo: repo.id, prNumber: item.number, reviewers: logins }, + { repo: runtimeRepoId, prNumber: item.number, reviewers: logins }, { timeoutMs: 30_000 } ) : await window.api.gh.removePRReviewers({ repoPath: repo.path, repoId: repo.id, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -1871,7 +2073,9 @@ function PRReviewCell({ (reviewer) => !removed.has(reviewer.login.toLowerCase()) ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) setReviewerInput('') } else { toast.error(result.error) @@ -2160,14 +2364,29 @@ function PRChecksCell({ function PRMergeCell({ item, repo, + sourceContext, onRefresh }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null onRefresh: () => void }): React.JSX.Element { const [merging, setMerging] = useState(false) const confirm = useConfirmationDialog() + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) if (item.type !== 'pr') { return ( <span className="text-[11px] text-muted-foreground"> @@ -2200,13 +2419,30 @@ function PRMergeCell({ } setMerging(true) try { - const result = await window.api.gh.mergePR({ - repoPath: repo.path, - repoId: repo.id, - prNumber: item.number, - method, - prRepo: item.prRepo ?? null - }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.mergePR', + { + repo: runtimeRepoId, + prNumber: item.number, + method, + prRepo: item.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.mergePR({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + method, + prRepo: item.prRepo ?? null + }) if (result.ok) { useAppStore.getState().recordFeatureInteraction('github-tasks') toast.success(translate('auto.components.TaskPage.a161925adc', 'Pull request merged')) @@ -2228,13 +2464,30 @@ function PRMergeCell({ const enabled = mergePresentation.autoMergeAction.kind === 'enable' setMerging(true) try { - const result = await window.api.gh.setPRAutoMerge({ - repoPath: repo.path, - repoId: repo.id, - prNumber: item.number, - enabled, - prRepo: item.prRepo ?? null - }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.setPRAutoMerge', + { + repo: runtimeRepoId, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRAutoMerge({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }) if (result.ok) { useAppStore.getState().recordFeatureInteraction('github-tasks') toast.success( @@ -2451,6 +2704,10 @@ export default function TaskPage(): React.JSX.Element { const closeTaskPage = useAppStore((s) => s.closeTaskPage) const activeModal = useAppStore((s) => s.activeModal) const repos = useAppStore((s) => s.repos) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const repoMap = useRepoMap() const allWorktrees = useAllWorktrees() const openModal = useAppStore((s) => s.openModal) @@ -2526,27 +2783,33 @@ export default function TaskPage(): React.JSX.Element { if (Array.isArray(persisted)) { const filtered = persisted.filter((id) => eligibleRepos.some((r) => r.id === id)) if (filtered.length > 0) { - return new Set(filtered) + return normalizeTaskRepoSelection(eligibleRepos, new Set(filtered)) } // Why: empty after filtering (e.g. all persisted repos were removed) - // falls through to "all eligible" so the page never renders with an - // empty selection — see the multi-combobox invariant. + // falls through to the automatic default so the page never renders with + // an empty selection — see the multi-combobox invariant. } - return new Set(eligibleRepos.map((r) => r.id)) + return getDefaultTaskRepoSelection(eligibleRepos) }, [eligibleRepos, pageData.preselectedRepoId, settings?.defaultRepoSelection]) const [repoSelection, setRepoSelection] = useState<ReadonlySet<string>>(resolvedInitialSelection) + const taskPickerGroups = useMemo( + () => getTaskProjectPickerGroups(eligibleRepos, repoSelection), + [eligibleRepos, repoSelection] + ) + const taskPickerRepos = useMemo( + () => taskPickerGroups.map((group) => group.repo), + [taskPickerGroups] + ) // Why: prune selection when a previously-selected repo is removed, and - // preserve sticky-all (when the selection equaled every eligible repo - // pre-change, keep it equal to every eligible repo post-change so "All - // repos" stays truthful). Recreating the Set every time eligibleRepos - // changes would churn the fetch effect — only write when the identity of - // the selection actually needs to change. - const prevEligibleCountRef = useRef(eligibleRepos.length) + // preserve sticky-all (when the selection equaled every logical project + // pre-change, keep it equal to every logical project post-change). Recreating + // the Set every time eligibleRepos changes would churn the fetch effect. + const prevTaskPickerCountRef = useRef(taskPickerRepos.length) useEffect(() => { - const prevCount = prevEligibleCountRef.current - prevEligibleCountRef.current = eligibleRepos.length + const prevCount = prevTaskPickerCountRef.current + prevTaskPickerCountRef.current = taskPickerRepos.length const eligibleIds = new Set(eligibleRepos.map((r) => r.id)) const wasAll = repoSelection.size === prevCount && prevCount > 0 const pruned = new Set<string>() @@ -2556,20 +2819,20 @@ export default function TaskPage(): React.JSX.Element { } } if (wasAll) { - const allNow = new Set(eligibleIds) - if (allNow.size !== repoSelection.size || [...allNow].some((id) => !repoSelection.has(id))) { + const allNow = new Set(taskPickerRepos.map((repo) => repo.id)) + if (!areStringSetsEqual(allNow, repoSelection)) { setRepoSelection(allNow) } return } - if (pruned.size === 0 && eligibleIds.size > 0) { - setRepoSelection(new Set(eligibleIds)) + if (pruned.size === 0 && eligibleIds.size === 0) { return } - if (pruned.size !== repoSelection.size) { - setRepoSelection(pruned) + const normalized = normalizeTaskRepoSelection(eligibleRepos, pruned) + if (!areStringSetsEqual(normalized, repoSelection)) { + setRepoSelection(normalized) } - }, [eligibleRepos, repoSelection]) + }, [eligibleRepos, repoSelection, taskPickerRepos]) const selectedRepos = useMemo( () => eligibleRepos.filter((r) => repoSelection.has(r.id)), @@ -2593,6 +2856,10 @@ export default function TaskPage(): React.JSX.Element { const jiraSites = jiraStatus.sites ?? [] const selectedJiraSiteId = jiraStatus.selectedSiteId ?? jiraStatus.activeSiteId ?? jiraSites[0]?.id ?? null + const selectedJiraSite = + selectedJiraSiteId && selectedJiraSiteId !== 'all' + ? (jiraSites.find((site) => site.id === selectedJiraSiteId) ?? null) + : null const preferredVisibleTaskProviders = useMemo( () => normalizeVisibleTaskProviders(settings?.visibleTaskProviders), [settings?.visibleTaskProviders] @@ -2670,6 +2937,365 @@ export default function TaskPage(): React.JSX.Element { const [taskSource, setTaskSource] = useState<TaskProvider>( resolveVisibleTaskProvider(preferredTaskSource, visibleTaskProviders) ) + const runtimePreflightMountedRef = useRef(true) + const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set()) + const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< + ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus> + >(() => new Map()) + useEffect( + () => () => { + runtimePreflightMountedRef.current = false + }, + [] + ) + const taskSourceRepoContexts = useMemo( + () => + taskSource === 'github' || taskSource === 'gitlab' + ? selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, taskSource)) + .filter((context): context is TaskSourceContext => context !== null) + : [], + [selectedRepos, taskSource] + ) + const hostRegistryById = useMemo( + () => + new Map( + buildExecutionHostRegistry({ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }).map((host) => [host.id, host]) + ), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const hostLabelById = useMemo( + () => new Map([...hostRegistryById].map(([hostId, host]) => [hostId, host.label])), + [hostRegistryById] + ) + const runtimeTaskSourceHostIds = useMemo(() => { + if (taskSource !== 'github' && taskSource !== 'gitlab') { + return [] + } + const hostIds = new Set<TaskSourceContext['hostId']>() + for (const context of taskSourceRepoContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + const host = hostRegistryById.get(context.hostId) + if ( + host?.kind !== 'runtime' || + host.health !== 'available' || + !host.capabilities?.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY) + ) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [hostRegistryById, taskSource, taskSourceRepoContexts]) + useEffect(() => { + const unrequestedHostIds = runtimeTaskSourceHostIds.filter( + (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) + ) + if (unrequestedHostIds.length === 0) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequestedHostIds) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequestedHostIds) { + runtimePreflightRequestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + // Why: task sources can span multiple runtime hosts; each runtime owns + // its own gh/glab installation and auth state. + void callRuntimeRpc<PreflightStatus>( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + .then((status) => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status }) + return next + }) + }) + .catch(() => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status: null }) + return next + }) + }) + } + }, [runtimeTaskSourceHostIds]) + const getTaskPickerRepoHostLabel = useCallback( + (repo: Repo): string | null => { + const provider = taskSource === 'gitlab' ? 'gitlab' : 'github' + const context = getTaskPageRepoSourceContext(repo, provider) + const hostId = context?.hostId ?? repo.executionHostId ?? 'local' + return hostRegistryById.get(hostId)?.label ?? null + }, + [hostRegistryById, taskSource] + ) + const taskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(() => { + if (taskSource !== 'github' && taskSource !== 'gitlab') { + return [] + } + return [ + ...taskSourceRepoContexts.flatMap((context) => { + const host = hostRegistryById.get(context.hostId) + const availability = getTaskSourceHostAvailabilityForHost(host, context.hostId) + return availability ? [availability] : [] + }), + ...getRepoBackedProviderAvailability({ + provider: taskSource, + contexts: taskSourceRepoContexts, + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + ] + }, [ + hostRegistryById, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + taskSource, + taskSourceRepoContexts + ]) + const accountBackedTaskSourceHostId = useMemo( + () => getSettingsFocusedExecutionHostId(settings), + [settings] + ) + const fallbackTaskSourceProjectId = useMemo(() => { + const firstRepoContext = selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) + .find((context): context is TaskSourceContext => context !== null) + return firstRepoContext?.projectId ?? 'account-backed-task-source' + }, [selectedRepos]) + const linearTaskSourceContext = useMemo( + () => + normalizeTaskSourceContext({ + provider: 'linear', + projectId: fallbackTaskSourceProjectId, + hostId: accountBackedTaskSourceHostId, + providerIdentity: { + provider: 'linear', + workspaceId: + selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all' + ? selectedLinearWorkspaceId + : null, + workspaceName: + selectedLinearWorkspace?.organizationName ?? + selectedLinearWorkspace?.displayName ?? + null + }, + accountLabel: + selectedLinearWorkspace?.organizationName ?? selectedLinearWorkspace?.displayName ?? null + }), + [ + accountBackedTaskSourceHostId, + fallbackTaskSourceProjectId, + selectedLinearWorkspace, + selectedLinearWorkspaceId + ] + ) + const jiraTaskSourceContext = useMemo( + () => + normalizeTaskSourceContext({ + provider: 'jira', + projectId: fallbackTaskSourceProjectId, + hostId: accountBackedTaskSourceHostId, + providerIdentity: { + provider: 'jira', + siteId: selectedJiraSiteId && selectedJiraSiteId !== 'all' ? selectedJiraSiteId : null, + siteUrl: selectedJiraSite?.siteUrl ?? null + }, + accountLabel: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null + }), + [ + accountBackedTaskSourceHostId, + fallbackTaskSourceProjectId, + selectedJiraSite, + selectedJiraSiteId + ] + ) + const accountBackedTaskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(() => { + if (taskSource !== 'linear' && taskSource !== 'jira') { + return [] + } + const host = hostRegistryById.get(accountBackedTaskSourceHostId) + const availability = getTaskSourceHostAvailabilityForHost(host, accountBackedTaskSourceHostId) + return availability ? [availability] : [] + }, [accountBackedTaskSourceHostId, hostRegistryById, taskSource]) + const taskSourceAvailabilityNoticeByProvider = useMemo< + Partial<Record<TaskProvider, TaskSourceAvailabilityNotice>> + >(() => { + const availabilityForContexts = ( + provider: Extract<TaskProvider, 'github' | 'gitlab'>, + contexts: readonly TaskSourceContext[] + ): TaskSourceHostAvailability[] => [ + ...contexts.flatMap((context) => { + const host = hostRegistryById.get(context.hostId) + const availability = getTaskSourceHostAvailabilityForHost(host, context.hostId) + return availability ? [availability] : [] + }), + ...getRepoBackedProviderAvailability({ + provider, + contexts, + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + ] + const accountHost = hostRegistryById.get(accountBackedTaskSourceHostId) + const accountHostAvailability = getTaskSourceHostAvailabilityForHost( + accountHost, + accountBackedTaskSourceHostId + ) + const accountAvailability = accountHostAvailability ? [accountHostAvailability] : [] + const labelFor = (provider: TaskProvider): string => + sourceOptions.find((source) => source.id === provider)?.label ?? provider + return { + github: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('github'), + sourceCount: selectedRepos.length, + hostLabelById, + hostAvailability: availabilityForContexts( + 'github', + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + gitlab: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('gitlab'), + sourceCount: selectedRepos.length, + hostLabelById, + hostAvailability: availabilityForContexts( + 'gitlab', + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'gitlab')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + linear: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('linear'), + sourceCount: 1, + hostLabelById, + hostAvailability: accountAvailability + }) ?? undefined, + jira: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('jira'), + sourceCount: 1, + hostLabelById, + hostAvailability: accountAvailability + }) ?? undefined + } + }, [ + accountBackedTaskSourceHostId, + hostRegistryById, + hostLabelById, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + selectedRepos, + sourceOptions + ]) + const taskSourceContextSummary = useMemo(() => { + const providerLabel = + sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource + return getTaskSourceContextSummary({ + provider: taskSource, + providerLabel, + repoContexts: taskSourceRepoContexts, + hostAvailability: + taskSource === 'linear' || taskSource === 'jira' + ? accountBackedTaskSourceHostAvailability + : taskSourceHostAvailability, + accountHostId: accountBackedTaskSourceHostId, + hostLabelById, + selectedRepoCount: selectedRepos.length, + linearWorkspaceName: + selectedLinearWorkspace?.organizationName ?? selectedLinearWorkspace?.id ?? null, + jiraSiteName: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null + }) + }, [ + selectedJiraSite, + selectedLinearWorkspace, + selectedRepos.length, + sourceOptions, + taskSource, + accountBackedTaskSourceHostAvailability, + accountBackedTaskSourceHostId, + hostLabelById, + taskSourceHostAvailability, + taskSourceRepoContexts + ]) + const taskSourceAvailabilityNotice = useMemo(() => { + const providerLabel = + sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource + return getTaskSourceAvailabilityNotice({ + providerLabel, + sourceCount: + taskSource === 'linear' || taskSource === 'jira' + ? 1 + : Math.max(1, taskSourceRepoContexts.length), + hostAvailability: + taskSource === 'linear' || taskSource === 'jira' + ? accountBackedTaskSourceHostAvailability + : taskSourceHostAvailability, + hostLabelById + }) + }, [ + accountBackedTaskSourceHostAvailability, + hostLabelById, + sourceOptions, + taskSource, + taskSourceHostAvailability, + taskSourceRepoContexts.length + ]) + const githubEmptyState = useMemo( + () => + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: selectedRepos.length + }), + [selectedRepos.length] + ) const taskSourceManuallyChangedRef = useRef(false) const lastPageTaskSourceRef = useRef(pageData.taskSource) const taskResumeAppliedRef = useRef(false) @@ -2739,6 +3365,15 @@ export default function TaskPage(): React.JSX.Element { const [gitlabView, setGitlabView] = useState<'issues' | 'mrs' | 'todos'>('mrs') const [gitlabTodos, setGitlabTodos] = useState<GitLabTodo[]>([]) const [gitlabTodosLoading, setGitlabTodosLoading] = useState(false) + const gitlabEmptyState = useMemo( + () => + getRepoBackedTaskEmptyState({ + provider: 'gitlab', + selectedRepoCount: selectedRepos.length, + gitlabView + }), + [gitlabView, selectedRepos.length] + ) const gitlabFilterIsValid = gitlabView === 'issues' @@ -2799,7 +3434,13 @@ export default function TaskPage(): React.JSX.Element { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] for (const r of selectedRepos) { - const cached = getCachedWorkItems(r.id, PER_REPO_FETCH_LIMIT, trimmed) + const cached = getCachedWorkItems( + r.id, + PER_REPO_FETCH_LIMIT, + trimmed, + r.path, + getTaskPageRepoSourceContext(r, 'github') + ) if (cached) { merged.push(...cached) } @@ -2844,7 +3485,7 @@ export default function TaskPage(): React.JSX.Element { useShallow((s) => selectTaskPageWorkItemsCacheEntries( s.workItemsCache, - selectedRepos, + selectedRepos.map(getTaskPageRepoCacheInput), PER_REPO_FETCH_LIMIT, appliedWorkItemsCacheQuery ) @@ -2863,8 +3504,45 @@ export default function TaskPage(): React.JSX.Element { const dialogWorkItem = dialogWorkItemKey ? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem) : null - const pageGitHubDetailWorkItem = pageData.openGitHubWorkItem ? dialogWorkItem : null const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null + const dialogSourceContext = useMemo(() => { + if (!dialogWorkItem) { + return null + } + if ( + pageData.openGitHubSourceContext?.provider === 'github' && + pageData.openGitHubWorkItem?.id === dialogWorkItem.id && + pageData.openGitHubWorkItem.repoId === dialogWorkItem.repoId + ) { + return pageData.openGitHubSourceContext + } + return getTaskPageRepoSourceContext(repoMap.get(dialogWorkItem.repoId), 'github') + }, [dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem, repoMap]) + const gitlabDialogRepo = useMemo( + () => + gitlabDialogItem + ? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId) ?? primaryRepo) + : null, + [gitlabDialogItem, primaryRepo, selectedRepos] + ) + const gitlabDialogSourceContext = useMemo(() => { + if (!gitlabDialogItem) { + return null + } + if ( + pageData.openGitLabSourceContext?.provider === 'gitlab' && + pageData.openGitLabWorkItem?.id === gitlabDialogItem.id && + pageData.openGitLabWorkItem.repoId === gitlabDialogItem.repoId + ) { + return pageData.openGitLabSourceContext + } + return getTaskPageRepoSourceContext(gitlabDialogRepo, 'gitlab', gitlabDialogItem.projectRef) + }, [ + gitlabDialogItem, + gitlabDialogRepo, + pageData.openGitLabSourceContext, + pageData.openGitLabWorkItem + ]) const setDialogWorkItem = useCallback( (item: GitHubWorkItem | null, initialTab: ItemDialogTab = 'conversation') => { @@ -2883,15 +3561,43 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem(pageData.openGitHubWorkItem, pageData.openGitHubInitialTab) }, [pageData.openGitHubInitialTab, pageData.openGitHubWorkItem, setDialogWorkItem]) + useEffect(() => { + setGitlabDialogItem(pageData.openGitLabWorkItem ?? null) + }, [pageData.openGitLabWorkItem]) + const openGitHubDetailPage = useCallback( (item: GitHubWorkItem, initialTab: ItemDialogTab = 'conversation') => { - // Why: in-list opens should float over the Tasks list. Direct opens - // from outside Tasks still pass `openGitHubWorkItem` through page state. - useAppStore.getState().recordFeatureInteraction('github-tasks') - setGithubMode('items') - setDialogWorkItem(item, initialTab) + openTaskPage( + { + taskSource: 'github', + preselectedRepoId: item.repoId, + openGitHubWorkItem: item, + openGitHubSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'), + openGitHubInitialTab: initialTab + }, + { recordTasksInteraction: false } + ) }, - [setDialogWorkItem] + [openTaskPage, repoMap] + ) + + const openGitLabDetailPage = useCallback( + (item: GitLabWorkItem) => { + openTaskPage( + { + taskSource: 'gitlab', + preselectedRepoId: item.repoId, + openGitLabWorkItem: item, + openGitLabSourceContext: getTaskPageRepoSourceContext( + repoMap.get(item.repoId), + 'gitlab', + item.projectRef + ) + }, + { recordTasksInteraction: false } + ) + }, + [openTaskPage, repoMap] ) const patchTaskPageWorkItemRows = useCallback( @@ -2989,33 +3695,33 @@ export default function TaskPage(): React.JSX.Element { // Why: on a partial-failure retry the cache still holds successful-side // data, so `tasksLoading` (which is gated on `anyUncached`) never flips // true and the Retry button would otherwise give no feedback. Track - // retry-in-flight per repo (keyed by `repoPath`) so that clicking Retry - // on one banner only flips that banner's button into its "Retrying…" + // retry-in-flight per selected source so that clicking Retry + // on one banner only flips that source's button into its "Retrying…" // state — other still-failing banners stay in their "Retry" state rather // than misleadingly flipping in lockstep. The fetch effect clears the set // when the nonce-driven refresh settles. - const [retryingRepoPaths, setRetryingRepoPaths] = useState<ReadonlySet<string>>(() => new Set()) + const [retryingSourceKeys, setRetryingSourceKeys] = useState<ReadonlySet<string>>(() => new Set()) const handleRetryIssuesFetch = useCallback( - (repoPath: string) => { - const repo = selectedRepos.find((r) => r.path === repoPath) - if (!repo) { + (sourceKey: string) => { + const source = perRepoSourceState.find((s) => s.sourceKey === sourceKey) + if (!source) { return } // Why: bumping the shared refresh nonce reuses the Tasks list's // single fetch path — nonce changes are treated as force=true so // retry doesn't silently dedupe onto a still-failing in-flight request. // The nonce bump refreshes ALL selected repos, but the Retrying… - // state is scoped to the clicked repo so other banners stay in their + // state is scoped to the clicked source so other banners stay in their // "Retry" state rather than misleadingly flipping to "Retrying…". - setRetryingRepoPaths((prev) => { + setRetryingSourceKeys((prev) => { const next = new Set(prev) - next.add(repoPath) + next.add(source.sourceKey) return next }) setTaskRefreshNonce((n) => n + 1) }, - [selectedRepos] + [perRepoSourceState] ) const handleRefreshGithubTasks = useCallback((): void => { setTasksRefreshing(true) @@ -3036,16 +3742,31 @@ export default function TaskPage(): React.JSX.Element { () => selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null, [selectedRepos, newIssueRepoId] ) + const newIssueSourceContext = useMemo( + () => getTaskPageRepoSourceContext(newIssueTargetRepo, 'github'), + [newIssueTargetRepo] + ) const newIssueRuntimeTarget = useMemo(() => { if (!newIssueTargetRepo?.id) { return null } - const target = getActiveRuntimeTarget(settings) + const repoOwnerSettings = getSettingsForRepoRuntimeOwner( + { repos: [newIssueTargetRepo], settings }, + newIssueTargetRepo.id + ) + const targetSettings = + newIssueSourceContext?.provider === 'github' + ? { + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(newIssueSourceContext) + } + : repoOwnerSettings + const target = getActiveRuntimeTarget(targetSettings) if (target.kind !== 'environment') { return null } return repos.some((repo) => repo.id === newIssueTargetRepo.id) ? target : null - }, [newIssueTargetRepo?.id, repos, settings]) + }, [newIssueSourceContext, newIssueTargetRepo, repos, settings]) const newIssueRepoLabels = useRepoLabels( newIssueOpen ? (newIssueTargetRepo?.path ?? null) : null, newIssueOpen ? (newIssueTargetRepo?.id ?? null) : null, @@ -3086,6 +3807,21 @@ export default function TaskPage(): React.JSX.Element { const selectedLinearIssue = selectedLinearIssueId ? (cachedSelectedLinearIssue ?? selectedLinearIssueFallback) : null + const linearDetailSourceContext = useMemo(() => { + if ( + selectedLinearIssue && + pageData.openLinearSourceContext?.provider === 'linear' && + pageData.openLinearIssue?.id === selectedLinearIssue.id + ) { + return pageData.openLinearSourceContext + } + return linearTaskSourceContext + }, [ + linearTaskSourceContext, + pageData.openLinearIssue, + pageData.openLinearSourceContext, + selectedLinearIssue + ]) const setSelectedLinearIssue = useCallback( (issue: LinearIssue | null, options?: { allowOutsideList?: boolean }) => { @@ -3113,11 +3849,15 @@ export default function TaskPage(): React.JSX.Element { const openLinearDetailPage = useCallback( (issue: LinearIssue) => { openTaskPage( - { taskSource: 'linear', openLinearIssue: issue }, + { + taskSource: 'linear', + openLinearIssue: issue, + openLinearSourceContext: linearTaskSourceContext + }, { recordTasksInteraction: false } ) }, - [openTaskPage] + [linearTaskSourceContext, openTaskPage] ) const openRelatedLinearIssue = useCallback( @@ -3144,8 +3884,14 @@ export default function TaskPage(): React.JSX.Element { taskPageData: { ...s.taskPageData, openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) }, [clearSelectedLinearIssue, setDialogWorkItem]) @@ -3161,17 +3907,55 @@ export default function TaskPage(): React.JSX.Element { const cachedSelectedJiraIssue = findTaskPageJiraIssue( jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, - selectedJiraIssueKey + selectedJiraIssueKey, + { + sourceContext: jiraTaskSourceContext, + siteId: selectedJiraIssueFallback?.siteId ?? pageData.openJiraIssue?.siteId ?? null + } ) const selectedJiraIssue = selectedJiraIssueKey ? (cachedSelectedJiraIssue ?? selectedJiraIssueFallback) : null + const jiraDetailSourceContext = useMemo(() => { + if ( + selectedJiraIssue && + pageData.openJiraSourceContext?.provider === 'jira' && + pageData.openJiraIssue?.key === selectedJiraIssue.key && + pageData.openJiraIssue.siteId === selectedJiraIssue.siteId + ) { + return pageData.openJiraSourceContext + } + return jiraTaskSourceContext + }, [ + jiraTaskSourceContext, + pageData.openJiraIssue, + pageData.openJiraSourceContext, + selectedJiraIssue + ]) const setSelectedJiraIssue = useCallback((issue: JiraIssue | null) => { setSelectedJiraIssueKey(issue?.key ?? null) setSelectedJiraIssueFallback(issue) }, []) + useEffect(() => { + setSelectedJiraIssue(pageData.openJiraIssue ?? null) + }, [pageData.openJiraIssue, setSelectedJiraIssue]) + + const openJiraDetailPage = useCallback( + (issue: JiraIssue) => { + openTaskPage( + { + taskSource: 'jira', + openJiraIssue: issue, + openJiraSourceContext: jiraTaskSourceContext + }, + { recordTasksInteraction: false } + ) + }, + [jiraTaskSourceContext, openTaskPage] + ) + // Linear tab state const [linearMode, setLinearMode] = useState<LinearMode>('issues') const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([]) @@ -3437,7 +4221,10 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false if (context.kind === 'project') { - void fetchLinearProject(context.id, context.workspaceId, { force: true }) + void fetchLinearProject(context.id, context.workspaceId, { + force: true, + sourceContext: linearTaskSourceContext + }) .then((project) => { if (cancelled) { return @@ -3473,7 +4260,8 @@ export default function TaskPage(): React.JSX.Element { setLinearCustomViewsLoading(true) setLinearCustomViewsError(null) void fetchLinearCustomView(context.id, context.workspaceId, context.model, { - force: true + force: true, + sourceContext: linearTaskSourceContext }) .then((restoredView) => { if (cancelled) { @@ -3506,6 +4294,7 @@ export default function TaskPage(): React.JSX.Element { fetchLinearProject, listLinearCustomViews, linearConnected, + linearTaskSourceContext, setTaskResumeState, taskResumeApplied, taskResumeState?.linearContext, @@ -3527,12 +4316,14 @@ export default function TaskPage(): React.JSX.Element { return } let cancelled = false - const cachedTeams = getCachedLinearTeams(selectedLinearWorkspaceId) + const cachedTeams = getCachedLinearTeams(selectedLinearWorkspaceId, { + sourceContext: linearTaskSourceContext + }) // Why: workspace switches must not leave the prior workspace's teams // available for new-issue creation while the replacement fetch is pending, // but a workspace-scoped cache can keep the selector usable immediately. setAvailableTeams(cachedTeams ?? []) - void listLinearTeams(selectedLinearWorkspaceId) + void listLinearTeams(selectedLinearWorkspaceId, { sourceContext: linearTaskSourceContext }) .then((teams) => { if (!cancelled) { setAvailableTeams(teams) @@ -3554,7 +4345,8 @@ export default function TaskPage(): React.JSX.Element { linearTeamRefreshNonce, taskResumeApplied, getCachedLinearTeams, - listLinearTeams + listLinearTeams, + linearTaskSourceContext ]) const [availableJiraProjects, setAvailableJiraProjects] = useState<JiraProject[]>([]) @@ -3572,7 +4364,7 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setAvailableJiraProjects([]) setJiraProjectsLoading(true) - void jiraListProjects(settings, selectedJiraSiteId) + void jiraListProjects(jiraTaskSourceContext ?? settings, selectedJiraSiteId) .then((projects) => { if (!cancelled) { setAvailableJiraProjects(projects) @@ -3591,14 +4383,24 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, taskSource, jiraConnected, selectedJiraSiteId, taskResumeApplied]) + }, [ + settings, + taskSource, + jiraConnected, + selectedJiraSiteId, + taskResumeApplied, + jiraTaskSourceContext + ]) // Why: stable key for `selectedRepos` so the GitLab fetch effect below // doesn't re-run on every parent re-render just because the array // reference changed. The memoized string keys off id + path + // connectionId — the only fields the effect actually reads. const selectedReposKey = useMemo( - () => selectedRepos.map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}`).join(','), + () => + selectedRepos + .map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}|${r.executionHostId ?? ''}`) + .join(','), [selectedRepos] ) @@ -3643,6 +4445,8 @@ export default function TaskPage(): React.JSX.Element { return window.api.gl .listIssues({ repoPath: repo.path, + repoId: repo.id, + sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), state: 'opened', assignee: isAssignedToMe ? '@me' : undefined, limit: 50 @@ -3663,6 +4467,8 @@ export default function TaskPage(): React.JSX.Element { window.api.gl .listMRs({ repoPath: repo.path, + repoId: repo.id, + sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), state: activeMRFilter ?? 'opened', page: 1, perPage: 50 @@ -3730,7 +4536,11 @@ export default function TaskPage(): React.JSX.Element { let stale = false setGitlabTodosLoading(true) void window.api.gl - .todos({ repoPath: primaryRepo.path }) + .todos({ + repoPath: primaryRepo.path, + repoId: primaryRepo.id, + sourceContext: getTaskPageRepoSourceContext(primaryRepo, 'gitlab') + }) .then((todos) => { if (!stale) { setGitlabTodos(todos as GitLabTodo[]) @@ -3749,7 +4559,7 @@ export default function TaskPage(): React.JSX.Element { return () => { stale = true } - }, [taskSource, gitlabView, gitlabRefreshNonce, primaryRepo?.path]) + }, [taskSource, gitlabView, gitlabRefreshNonce, primaryRepo]) const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => { @@ -4192,7 +5002,11 @@ export default function TaskPage(): React.JSX.Element { } try { - const states = await linearTeamStates(settings, issue.team.id, issue.workspaceId) + const states = await linearTeamStates( + linearTaskSourceContext ?? settings, + issue.team.id, + issue.workspaceId + ) const workflowState = findLinearWorkflowStateForStatus(states, targetState) if (!workflowState) { toast.error( @@ -4216,7 +5030,7 @@ export default function TaskPage(): React.JSX.Element { applyFallbackState(nextState) const result = await linearUpdateIssue( - settings, + linearTaskSourceContext ?? settings, issue.id, { stateId: workflowState.id }, issue.workspaceId @@ -4254,6 +5068,7 @@ export default function TaskPage(): React.JSX.Element { linearStatusBoardEnabled, patchScopedLinearIssue, patchLinearIssue, + linearTaskSourceContext, settings ] ) @@ -4280,10 +5095,14 @@ export default function TaskPage(): React.JSX.Element { findTaskPageJiraIssue( jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, - issue.key + issue.key, + { + sourceContext: jiraTaskSourceContext, + siteId: issue.siteId + } ) ?? issue ), - [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache] + [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, jiraTaskSourceContext] ) // New Linear project dialog state @@ -4353,7 +5172,7 @@ export default function TaskPage(): React.JSX.Element { const targetWorkspaceId = newLinearIssueTargetTeam.workspaceId || (selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId : null) - linearListProjects(settings, undefined, 100, targetWorkspaceId) + linearListProjects(linearTaskSourceContext ?? settings, undefined, 100, targetWorkspaceId) .then((p) => { if (!cancelled) { setNewLinearIssueProjects(p.items) @@ -4374,6 +5193,7 @@ export default function TaskPage(): React.JSX.Element { linearConnected, newLinearIssueOpen, newLinearIssueTargetTeam, + linearTaskSourceContext, settings, selectedLinearWorkspaceId ]) @@ -4639,7 +5459,7 @@ export default function TaskPage(): React.JSX.Element { setAvailableJiraIssueTypes([]) setJiraIssueTypesLoading(true) void jiraListIssueTypes( - settings, + jiraTaskSourceContext ?? settings, newJiraIssueTargetProject.id, newJiraIssueTargetProject.siteId ) @@ -4665,7 +5485,7 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject]) + }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, jiraTaskSourceContext]) useEffect(() => { if ( @@ -4686,7 +5506,7 @@ export default function TaskPage(): React.JSX.Element { setJiraCreateFieldsError(null) setNewJiraIssueCustomFieldValues({}) void jiraListCreateFields( - settings, + jiraTaskSourceContext ?? settings, newJiraIssueTargetProject.id, newJiraIssueTargetType.id, newJiraIssueTargetProject.siteId @@ -4711,7 +5531,14 @@ export default function TaskPage(): React.JSX.Element { // responses after the user switches either selector. cancelled = true } - }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType]) + }, [ + settings, + jiraConnected, + newJiraIssueOpen, + newJiraIssueTargetProject, + newJiraIssueTargetType, + jiraTaskSourceContext + ]) // Why: defense-in-depth safety net applied to the current page's items. // The active tab scopes requests to issues or PRs, and this keeps stale @@ -4784,7 +5611,7 @@ export default function TaskPage(): React.JSX.Element { item.branchName, item.headSha, item.prRepo ?? null, - { repoId: repo.id } + { repoId: repo.id, sourceContext: getTaskPageRepoSourceContext(repo, 'github') } ).then((checks) => { patchTaskPageWorkItemRows( { id: item.id, repoId: item.repoId }, @@ -4849,7 +5676,12 @@ export default function TaskPage(): React.JSX.Element { return } const q = stripRepoQualifiers(appliedTaskSearch.trim()) - const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path })) + const repoArgs = selectedRepos.map((r) => ({ + repoId: r.id, + path: r.path, + executionHostId: r.executionHostId, + sourceContext: getTaskPageRepoSourceContext(r, 'github') + })) const requestGeneration = paginationGenerationRef.current const target = targetPage ?? pages.length @@ -4933,19 +5765,19 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied) { return } - // Why: both early-return branches must clear `retryingRepoPaths` — if the + // Why: both early-return branches must clear `retryingSourceKeys` — if the // user clicks Retry and then switches `taskSource` away from 'github' (or // somehow ends up with zero repos selected) before the fetch dispatches, // neither the `.then` nor the `.catch` below will fire, and the Retry // button would stay stuck in its disabled/Retrying state indefinitely. if (taskSource !== 'github' || githubMode !== 'items') { - setRetryingRepoPaths(new Set()) + setRetryingSourceKeys(new Set()) setTasksRefreshing(false) setTasksFiltering(false) return } if (selectedRepos.length === 0) { - setRetryingRepoPaths(new Set()) + setRetryingSourceKeys(new Set()) setTasksRefreshing(false) setTasksFiltering(false) return @@ -4965,7 +5797,13 @@ export default function TaskPage(): React.JSX.Element { let anyUncached = false let anyRepoCached = false for (const r of selectedRepos) { - const cached = getCachedWorkItems(r.id, PER_REPO_FETCH_LIMIT, q) + const cached = getCachedWorkItems( + r.id, + PER_REPO_FETCH_LIMIT, + q, + r.path, + getTaskPageRepoSourceContext(r, 'github') + ) if (cached === null) { anyUncached = true } else { @@ -5000,7 +5838,12 @@ export default function TaskPage(): React.JSX.Element { workItemsInvalidationNonce !== lastFetchedInvalidationNonceRef.current lastFetchedInvalidationNonceRef.current = workItemsInvalidationNonce const forcedFetch = (forceRefresh && taskRefreshNonce > 0) || preferenceInvalidated - const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path })) + const repoArgs = selectedRepos.map((r) => ({ + repoId: r.id, + path: r.path, + executionHostId: r.executionHostId, + sourceContext: getTaskPageRepoSourceContext(r, 'github') + })) const landingRefreshKey = `${repoArgs.map((r) => `${r.repoId}:${r.path}`).join('|')}::${q}` const shouldProbeOnLanding = !forcedFetch && anyRepoCached && !landingGitHubRefreshKeysRef.current.has(landingRefreshKey) @@ -5015,29 +5858,29 @@ export default function TaskPage(): React.JSX.Element { // so the toolbar still shows a refresh-in-progress affordance. setTasksRefreshing(forcedFetch) - // Why: snapshot the retrying paths at effect-dispatch so overlapping + // Why: snapshot the retrying source keys at effect-dispatch so overlapping // retries don't clear each other's pending state. An earlier cancelled // effect settling after a newer retry starts would otherwise wipe the - // newer retry's repo from the set. Clearing only the paths captured + // newer retry's source from the set. Clearing only the keys captured // when this effect dispatched preserves later additions. - const dispatchedRetryPaths = retryingRepoPaths + const dispatchedRetrySourceKeys = retryingSourceKeys void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed }) => { - // Why: clear only the repos this effect was responsible for + // Why: clear only the sources this effect was responsible for // retrying (the snapshot captured at dispatch time). Overlapping // retries — a second click while a prior fetch is still in flight - // — must not clear the newer repo from the set, so we can't just + // — must not clear the newer source from the set, so we can't just // reset the whole set here. The early-return branches above reset // the whole set because those branches won't dispatch a fetch. - setRetryingRepoPaths((prev) => { - if (dispatchedRetryPaths.size === 0) { + setRetryingSourceKeys((prev) => { + if (dispatchedRetrySourceKeys.size === 0) { return prev } const next = new Set(prev) - for (const p of dispatchedRetryPaths) { - next.delete(p) + for (const key of dispatchedRetrySourceKeys) { + next.delete(key) } return next }) @@ -5063,19 +5906,19 @@ export default function TaskPage(): React.JSX.Element { .catch((err) => { // Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a // reject here means an IPC-level or programmer error — surface it. - // Clear only the repos this effect was responsible for retrying + // Clear only the sources this effect was responsible for retrying // (the snapshot captured at dispatch time). Overlapping retries — // a second click while a prior fetch is still in flight — must - // not clear the newer repo from the set, so we can't just reset + // not clear the newer source from the set, so we can't just reset // the whole set here. The early-return branches above reset the // whole set because those branches won't dispatch a fetch. - setRetryingRepoPaths((prev) => { - if (dispatchedRetryPaths.size === 0) { + setRetryingSourceKeys((prev) => { + if (dispatchedRetrySourceKeys.size === 0) { return prev } const next = new Set(prev) - for (const p of dispatchedRetryPaths) { - next.delete(p) + for (const key of dispatchedRetrySourceKeys) { + next.delete(key) } return next }) @@ -5093,7 +5936,12 @@ export default function TaskPage(): React.JSX.Element { // The search API is cached 120s server-side so this doesn't add // meaningful latency or rate-limit pressure. void countWorkItemsAcrossRepos( - selectedRepos.map((r) => ({ repoId: r.id, path: r.path })), + selectedRepos.map((r) => ({ + repoId: r.id, + path: r.path, + executionHostId: r.executionHostId, + sourceContext: getTaskPageRepoSourceContext(r, 'github') + })), q ).then((count) => { if (!cancelled) { @@ -5307,12 +6155,13 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'), prefilledName: getGitHubWorkItemWorkspaceSeed(item), initialRepoId: item.repoId, telemetrySource: 'sidebar' }) }, - [openModal] + [openModal, repoMap] ) const handleUseWorkItem = useCallback( @@ -5373,12 +6222,17 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: getTaskPageRepoSourceContext( + repoMap.get(item.repoId), + 'gitlab', + item.projectRef + ), prefilledName: getGitLabWorkItemWorkspaceSeed(item), initialRepoId: item.repoId, telemetrySource: 'sidebar' }) }, - [openModal] + [openModal, repoMap] ) const handleUseGitLabItem = useCallback( @@ -5404,7 +6258,10 @@ export default function TaskPage(): React.JSX.Element { newIssueRuntimeTarget, 'github.createIssue', { - repo: newIssueTargetRepo.id, + repo: + newIssueSourceContext?.provider === 'github' + ? (newIssueSourceContext.repoId ?? newIssueTargetRepo.id) + : newIssueTargetRepo.id, title, body: newIssueBody, labels: newIssueLabels, @@ -5415,6 +6272,7 @@ export default function TaskPage(): React.JSX.Element { : await window.api.gh.createIssue({ repoPath: newIssueTargetRepo.path, repoId: newIssueTargetRepo.id, + sourceContext: newIssueSourceContext, title, body: newIssueBody, labels: newIssueLabels, @@ -5470,12 +6328,20 @@ export default function TaskPage(): React.JSX.Element { ? callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.workItem>>>( newIssueRuntimeTarget, 'github.workItem', - { repo: newIssueTargetRepo.id, number: result.number, type: 'issue' }, + { + repo: + newIssueSourceContext?.provider === 'github' + ? (newIssueSourceContext.repoId ?? newIssueTargetRepo.id) + : newIssueTargetRepo.id, + number: result.number, + type: 'issue' + }, { timeoutMs: 30_000 } ) : window.api.gh.workItem({ repoPath: newIssueTargetRepo.path, repoId: newIssueTargetRepo.id, + sourceContext: newIssueSourceContext, number: result.number, type: 'issue' }) @@ -5499,6 +6365,7 @@ export default function TaskPage(): React.JSX.Element { newIssueAssignees, newIssueLabels, newIssueRuntimeTarget, + newIssueSourceContext, newIssueSubmitting, newIssueTargetRepo, newIssueTitle, @@ -5516,7 +6383,7 @@ export default function TaskPage(): React.JSX.Element { } setNewLinearProjectSubmitting(true) try { - const result = await linearCreateProject(settings, { + const result = await linearCreateProject(linearTaskSourceContext ?? settings, { name, description: newLinearProjectDescription.trim() || undefined, content: newLinearProjectContent.trim() || undefined, @@ -5590,6 +6457,7 @@ export default function TaskPage(): React.JSX.Element { newLinearProjectTargetDate, newLinearProjectTargetTeam, openLinearProjectContext, + linearTaskSourceContext, settings ]) @@ -5617,7 +6485,7 @@ export default function TaskPage(): React.JSX.Element { setNewLinearIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await linearCreateIssue(settings, { + const result = await linearCreateIssue(linearTaskSourceContext ?? settings, { teamId: newLinearIssueTargetTeam.id, title, description: newLinearIssueBody || undefined, @@ -5664,7 +6532,11 @@ export default function TaskPage(): React.JSX.Element { // Why: auto-select the new issue in the inline workspace so the user // sees exactly what was filed, mirroring the GitHub create-issue flow. - void linearGetIssue(settings, result.id, newLinearIssueTargetTeam.workspaceId) + void linearGetIssue( + linearTaskSourceContext ?? settings, + result.id, + newLinearIssueTargetTeam.workspaceId + ) .then((full) => { if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return @@ -5692,6 +6564,7 @@ export default function TaskPage(): React.JSX.Element { providerRuntimeContextKey, selectedLinearProject, setSelectedLinearIssue, + linearTaskSourceContext, settings ]) @@ -5710,7 +6583,7 @@ export default function TaskPage(): React.JSX.Element { setNewJiraIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await jiraCreateIssue(settings, { + const result = await jiraCreateIssue(jiraTaskSourceContext ?? settings, { siteId: newJiraIssueTargetProject.siteId, projectId: newJiraIssueTargetProject.id, issueTypeId: newJiraIssueTargetType.id, @@ -5747,7 +6620,11 @@ export default function TaskPage(): React.JSX.Element { setNewJiraIssueCustomFieldValues({}) setJiraRefreshNonce((n) => n + 1) - void jiraGetIssue(settings, result.key, newJiraIssueTargetProject.siteId) + void jiraGetIssue( + jiraTaskSourceContext ?? settings, + result.key, + newJiraIssueTargetProject.siteId + ) .then((full) => { if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return @@ -5775,6 +6652,7 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueTargetType, newJiraIssueTitle, providerRuntimeContextKey, + jiraTaskSourceContext, settings, setSelectedJiraIssue, visibleJiraCreateFields @@ -5922,7 +6800,7 @@ export default function TaskPage(): React.JSX.Element { trimmed.length > 0 ? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const) : ({ kind: 'list', filter: 'all', limit: effectiveLinearIssueLimit } as const) - const cachedResult = getCachedLinearIssues(readArgs) + const cachedResult = getCachedLinearIssues(readArgs, { sourceContext: linearTaskSourceContext }) if (readArgs.kind === 'search') { setLinearIssuesHasMore(false) if (cachedResult) { @@ -5964,10 +6842,12 @@ export default function TaskPage(): React.JSX.Element { const request = readArgs.kind === 'search' ? searchLinearIssues(readArgs.query, LINEAR_ITEM_LIMIT, { - force: forceRefresh || shouldProbeOnLanding + force: forceRefresh || shouldProbeOnLanding, + sourceContext: linearTaskSourceContext }) : listLinearIssues(readArgs.filter, effectiveLinearIssueLimit, { - force: forceRefresh || shouldProbeOnLanding + force: forceRefresh || shouldProbeOnLanding, + sourceContext: linearTaskSourceContext }) void request @@ -6029,7 +6909,8 @@ export default function TaskPage(): React.JSX.Element { linearIssueLimit, linearRefreshNonce, taskResumeApplied, - getCachedLinearIssues + getCachedLinearIssues, + linearTaskSourceContext ]) useEffect(() => { @@ -6051,7 +6932,9 @@ export default function TaskPage(): React.JSX.Element { } let cancelled = false const query = appliedLinearProjectSearch.trim() - const cached = getCachedLinearProjects(query || undefined, LINEAR_ITEM_LIMIT) + const cached = getCachedLinearProjects(query || undefined, LINEAR_ITEM_LIMIT, undefined, { + sourceContext: linearTaskSourceContext + }) if (cached) { setLinearProjectsResult(cached) } @@ -6059,7 +6942,8 @@ export default function TaskPage(): React.JSX.Element { setLinearProjectsLoading(force || cached === null) setLinearProjectsError(null) void listLinearProjectsFromStore(query || undefined, LINEAR_ITEM_LIMIT, undefined, { - force + force, + sourceContext: linearTaskSourceContext }) .then((result) => { if (!cancelled) { @@ -6088,7 +6972,8 @@ export default function TaskPage(): React.JSX.Element { selectedLinearProject, appliedLinearProjectSearch, linearRefreshNonce, - getCachedLinearProjects + getCachedLinearProjects, + linearTaskSourceContext ]) useEffect(() => { @@ -6100,7 +6985,8 @@ export default function TaskPage(): React.JSX.Element { setLinearProjectDetailLoading(true) setLinearProjectDetailError(null) void fetchLinearProject(selectedLinearProject.id, selectedLinearProject.workspaceId, { - force: linearRefreshNonce > 0 + force: linearRefreshNonce > 0, + sourceContext: linearTaskSourceContext }) .then((project) => { if (!cancelled) { @@ -6126,7 +7012,13 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [fetchLinearProject, linearRefreshNonce, selectedLinearProject, setTaskResumeState]) + }, [ + fetchLinearProject, + linearRefreshNonce, + selectedLinearProject, + setTaskResumeState, + linearTaskSourceContext + ]) useEffect(() => { if (!selectedLinearProject?.workspaceId || linearProjectTab !== 'issues') { @@ -6140,7 +7032,7 @@ export default function TaskPage(): React.JSX.Element { selectedLinearProject.id, selectedLinearProject.workspaceId, effectiveLimit, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) .then((result) => { if (!cancelled) { @@ -6164,6 +7056,7 @@ export default function TaskPage(): React.JSX.Element { linearProjectTab, linearRefreshNonce, listLinearProjectIssues, + linearTaskSourceContext, selectedLinearProject ]) @@ -6176,7 +7069,9 @@ export default function TaskPage(): React.JSX.Element { } let cancelled = false const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => - getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT) + getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { + sourceContext: linearTaskSourceContext + }) ) const allCached = cachedResults.every( (result): result is LinearCollectionResult<LinearCustomViewSummary> => result !== null @@ -6191,7 +7086,10 @@ export default function TaskPage(): React.JSX.Element { // models avoids a second, redundant Issues/Projects switch. void Promise.all( LINEAR_CUSTOM_VIEW_MODELS.map((model) => - listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force }) + listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { + force, + sourceContext: linearTaskSourceContext + }) ) ) .then((result) => { @@ -6221,7 +7119,8 @@ export default function TaskPage(): React.JSX.Element { selectedLinearCustomView, linearRefreshNonce, getCachedLinearCustomViews, - listLinearCustomViews + listLinearCustomViews, + linearTaskSourceContext ]) useEffect(() => { @@ -6240,13 +7139,13 @@ export default function TaskPage(): React.JSX.Element { selectedLinearCustomView.id, selectedLinearCustomView.workspaceId, issueLimit, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) : listLinearCustomViewProjects( selectedLinearCustomView.id, selectedLinearCustomView.workspaceId, LINEAR_ITEM_LIMIT, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) void request .then((result) => { @@ -6276,6 +7175,7 @@ export default function TaskPage(): React.JSX.Element { linearCustomViewIssueLimit, listLinearCustomViewIssues, listLinearCustomViewProjects, + linearTaskSourceContext, selectedLinearCustomView ]) @@ -6356,8 +7256,10 @@ export default function TaskPage(): React.JSX.Element { const trimmed = appliedJiraSearch.trim() const request = trimmed.length > 0 - ? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT) - : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT) + ? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT, { sourceContext: jiraTaskSourceContext }) + : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, { + sourceContext: jiraTaskSourceContext + }) void request .then((issues) => { @@ -6386,7 +7288,8 @@ export default function TaskPage(): React.JSX.Element { appliedJiraSearch, activeJiraPreset, jiraRefreshNonce, - taskResumeApplied + taskResumeApplied, + jiraTaskSourceContext ]) useEffect(() => { @@ -6427,11 +7330,12 @@ export default function TaskPage(): React.JSX.Element { const linkedWorkItem = buildLinearIssueLinkedWorkItem(issue) openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: linearTaskSourceContext, prefilledName: getLinearIssueWorkspaceName(issue), telemetrySource: 'sidebar' }) }, - [openModal] + [linearTaskSourceContext, openModal] ) const handleUseLinearItem = useCallback( @@ -6525,15 +7429,17 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: jiraTaskSourceContext, prefilledName: getJiraIssueWorkspaceSeed(issue), telemetrySource: 'sidebar' }) }, - [openModal] + [jiraTaskSourceContext, openModal] ) const handleUseJiraItem = useCallback( (issue: JiraIssue): void => { + useAppStore.getState().recordFeatureInteraction('jira-tasks') openComposerForJiraItem(issue) }, [openComposerForJiraItem] @@ -6622,13 +7528,19 @@ export default function TaskPage(): React.JSX.Element { <div className="mx-1 h-5 w-px bg-border/50" aria-hidden /> {visibleSourceOptions.map((source) => { const active = taskSource === source.id + const sourceAvailabilityNotice = + taskSourceAvailabilityNoticeByProvider[source.id] ?? null + const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking return ( <Tooltip key={source.id}> <TooltipTrigger asChild> <button type="button" - disabled={source.disabled} + disabled={sourceDisabled} onClick={() => { + if (sourceAvailabilityNotice?.blocking) { + return + } taskSourceManuallyChangedRef.current = true openTaskPage( { taskSource: source.id }, @@ -6643,24 +7555,30 @@ export default function TaskPage(): React.JSX.Element { ) }) }} - aria-label={source.label} + aria-label={sourceAvailabilityNotice?.label ?? source.label} className={cn( 'group flex h-8 w-8 items-center justify-center rounded-md border transition', active ? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm' : 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground', - source.disabled && 'cursor-not-allowed opacity-55' + sourceDisabled && 'cursor-not-allowed opacity-55' )} > <source.Icon className="size-3.5" /> </button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={6}> - {source.label} + {sourceAvailabilityNotice?.label ?? source.label} </TooltipContent> </Tooltip> ) })} + <div + className="hidden min-w-0 max-w-[min(420px,40vw)] items-center rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-xs text-muted-foreground sm:flex" + title={taskSourceContextSummary.title} + > + <span className="truncate">{taskSourceContextSummary.label}</span> + </div> </div> {taskSource === 'linear' && linearConnected ? ( <div className="flex items-center gap-2"> @@ -6760,6 +7678,17 @@ export default function TaskPage(): React.JSX.Element { ) : null} </div> + {taskSourceAvailabilityNotice ? ( + <div + role="status" + className="flex max-w-3xl items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground" + title={taskSourceAvailabilityNotice.title} + > + <AlertCircle className="size-3.5 flex-none" /> + <span className="min-w-0 truncate">{taskSourceAvailabilityNotice.label}</span> + </div> + ) : null} + {taskSource === 'github' ? ( <div className="flex min-w-0 flex-wrap items-center gap-2"> {projectModeVisible ? ( @@ -6804,22 +7733,26 @@ export default function TaskPage(): React.JSX.Element { {githubMode !== 'project' && ( <> <div className="min-w-0 max-w-[220px] shrink-0"> - <RepoMultiCombobox - repos={eligibleRepos} + <TaskProjectSourceCombobox + groups={taskPickerGroups} selected={repoSelection} + getRepoHostLabel={getTaskPickerRepoHostLabel} onChange={(next) => { - setRepoSelection(next) - void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) ) - ) - }) + } + ) }} onSelectAll={() => { - const allIds = new Set(eligibleRepos.map((r) => r.id)) + const allIds = new Set(taskPickerRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { toast.error( @@ -7511,22 +8444,26 @@ export default function TaskPage(): React.JSX.Element { })} </div> <div className="min-w-0 w-full sm:w-[200px]"> - <RepoMultiCombobox - repos={eligibleRepos} + <TaskProjectSourceCombobox + groups={taskPickerGroups} selected={repoSelection} + getRepoHostLabel={getTaskPickerRepoHostLabel} onChange={(next) => { - setRepoSelection(next) - void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) ) - ) - }) + } + ) }} onSelectAll={() => { - const allIds = new Set(eligibleRepos.map((r) => r.id)) + const allIds = new Set(taskPickerRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { toast.error( @@ -7628,13 +8565,13 @@ export default function TaskPage(): React.JSX.Element { </section> </div> - {taskSource === 'github' && pageGitHubDetailWorkItem ? ( - pageGitHubDetailWorkItem.type === 'pr' ? ( + {taskSource === 'github' && dialogWorkItem ? ( + dialogWorkItem.type === 'pr' ? ( <PullRequestPage - workItem={pageGitHubDetailWorkItem} + workItem={dialogWorkItem} initialTab={dialogInitialTab} repoPath={dialogRepoPath} - repoId={pageGitHubDetailWorkItem.repoId} + repoId={dialogWorkItem.repoId} backLabel="Pull requests" onUse={(item) => { setDialogWorkItem(null) @@ -7645,10 +8582,10 @@ export default function TaskPage(): React.JSX.Element { /> ) : ( <GitHubItemDialog - workItem={pageGitHubDetailWorkItem} + workItem={dialogWorkItem} initialTab={dialogInitialTab} repoPath={dialogRepoPath} - repoId={pageGitHubDetailWorkItem.repoId} + repoId={dialogWorkItem.repoId} variant="page" backLabel="GitHub list" onUse={(item) => { @@ -7747,10 +8684,10 @@ export default function TaskPage(): React.JSX.Element { <Button variant="outline" size="sm" - onClick={() => handleRetryIssuesFetch(s.repoPath)} - disabled={tasksLoading || retryingRepoPaths.has(s.repoPath)} + onClick={() => handleRetryIssuesFetch(s.sourceKey)} + disabled={tasksLoading || retryingSourceKeys.has(s.sourceKey)} > - {retryingRepoPaths.has(s.repoPath) ? ( + {retryingSourceKeys.has(s.sourceKey) ? ( <span className="flex items-center gap-1"> <LoaderCircle className="h-3 w-3 animate-spin" /> {translate('auto.components.TaskPage.5b6b2af943', 'Retrying…')} @@ -7824,13 +8761,10 @@ export default function TaskPage(): React.JSX.Element { perRepoSourceState.every((s) => !s.error) ? ( <div className="px-4 py-10 text-center"> <p className="text-base font-medium text-foreground"> - {translate('auto.components.TaskPage.d0e3c8f933', 'No matching GitHub work')} + {githubEmptyState.title} </p> <p className="mt-2 text-sm text-muted-foreground"> - {translate( - 'auto.components.TaskPage.285bc21dc5', - 'Change the query or clear it.' - )} + {githubEmptyState.description} </p> </div> ) : null} @@ -7955,14 +8889,22 @@ export default function TaskPage(): React.JSX.Element { {!showPRManagementColumns ? ( <div className="min-w-0 flex items-center text-xs text-muted-foreground"> - <GHAssigneesCell item={item} repo={itemRepo ?? null} /> + <GHAssigneesCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> ) : null} {showPRManagementColumns ? ( <> <div className="flex min-w-0 items-center"> - <PRReviewCell item={item} repo={itemRepo ?? null} /> + <PRReviewCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> <div className="flex min-w-0 items-center"> @@ -7977,13 +8919,18 @@ export default function TaskPage(): React.JSX.Element { <PRMergeCell item={item} repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} onRefresh={() => setTaskRefreshNonce((current) => current + 1)} /> </div> </> ) : ( <div className="flex items-center"> - <GHStatusCell item={item} repo={itemRepo ?? null} /> + <GHStatusCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> )} @@ -8302,26 +9249,13 @@ export default function TaskPage(): React.JSX.Element { </div> ) : null} {!gitlabLoading && displayedGitLabItems.length === 0 && !gitlabError ? ( - <div className="px-4 py-12 text-center text-sm text-muted-foreground"> - {primaryRepo - ? gitlabView === 'issues' - ? translate( - 'auto.components.TaskPage.a9f256ecea', - 'No GitLab issues match this filter.' - ) - : gitlabView === 'mrs' - ? translate( - 'auto.components.TaskPage.cd7dc432a3', - 'No GitLab MRs match this filter.' - ) - : translate( - 'auto.components.TaskPage.f294c500ef', - 'No GitLab work matches this filter.' - ) - : translate( - 'auto.components.TaskPage.d6d08c1650', - 'Select a project to see GitLab work items.' - )} + <div className="px-4 py-12 text-center"> + <p className="text-base font-medium text-foreground"> + {gitlabEmptyState.title} + </p> + <p className="mt-2 text-sm text-muted-foreground"> + {gitlabEmptyState.description} + </p> </div> ) : null} <div className="divide-y divide-border/50"> @@ -8337,13 +9271,13 @@ export default function TaskPage(): React.JSX.Element { key={item.id} onClick={() => { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') - setGitlabDialogItem(item) + openGitLabDetailPage(item) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() useAppStore.getState().recordFeatureInteraction('gitlab-tasks') - setGitlabDialogItem(item) + openGitLabDetailPage(item) } }} className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] hover:bg-muted/50" @@ -8526,14 +9460,14 @@ export default function TaskPage(): React.JSX.Element { tabIndex={0} aria-current={selected ? 'true' : undefined} data-current={selected ? 'true' : undefined} - onClick={() => setSelectedJiraIssue(issue)} + onClick={() => openJiraDetailPage(issue)} onKeyDown={(e) => { if (e.target !== e.currentTarget) { return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setSelectedJiraIssue(issue) + openJiraDetailPage(issue) } }} className={cn( @@ -8694,7 +9628,8 @@ export default function TaskPage(): React.JSX.Element { <JiraIssueWorkspace issue={selectedJiraIssue} onUse={handleUseJiraItem} - onClose={() => setSelectedJiraIssue(null)} + onClose={closeTaskDetailPage} + sourceContext={jiraDetailSourceContext} /> </div> ) @@ -8706,6 +9641,7 @@ export default function TaskPage(): React.JSX.Element { onUse={handleUseLinearItem} onOpenIssue={openRelatedLinearIssue} onClose={closeTaskDetailPage} + sourceContext={linearDetailSourceContext} /> ) : !linearStatusReady ? ( <div className="mt-4 flex items-center justify-center py-14"> @@ -9306,7 +10242,11 @@ export default function TaskPage(): React.JSX.Element { </div> <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground"> {effectiveLinearDisplayProperties.has('state') ? ( - <LinearStateCell issue={issue} className="px-1.5 py-0.5" /> + <LinearStateCell + issue={issue} + className="px-1.5 py-0.5" + sourceContext={linearTaskSourceContext} + /> ) : null} {effectiveLinearDisplayProperties.has('assignee') ? ( <span> @@ -9421,7 +10361,11 @@ export default function TaskPage(): React.JSX.Element { </div> <div className="mt-1 flex min-w-0 items-center gap-1.5 lg:!hidden"> {effectiveLinearDisplayProperties.has('state') ? ( - <LinearStateCell issue={issue} className="px-1.5 py-0.5" /> + <LinearStateCell + issue={issue} + className="px-1.5 py-0.5" + sourceContext={linearTaskSourceContext} + /> ) : null} {effectiveLinearDisplayProperties.has('assignee') ? ( <span className="min-w-0 truncate text-[11px] text-muted-foreground"> @@ -9463,7 +10407,11 @@ export default function TaskPage(): React.JSX.Element { {effectiveLinearDisplayProperties.has('state') ? ( <div className="flex min-w-0 max-lg:!hidden"> - <LinearStateCell issue={issue} className="max-w-full px-2 py-0.5" /> + <LinearStateCell + issue={issue} + className="max-w-full px-2 py-0.5" + sourceContext={linearTaskSourceContext} + /> </div> ) : null} @@ -11127,7 +12075,7 @@ export default function TaskPage(): React.JSX.Element { </Dialog> <GitHubItemDialog - workItem={pageGitHubDetailWorkItem ? null : dialogWorkItem} + workItem={dialogWorkItem} repoPath={ // Why: the dialog is for a single item — resolve its repoPath from the // item's own repoId (set when fan-out merged the list) so it works in @@ -11136,6 +12084,7 @@ export default function TaskPage(): React.JSX.Element { dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null } repoId={dialogWorkItem?.repoId ?? null} + sourceContext={dialogSourceContext} onUse={(item) => { setDialogWorkItem(null) handleUseWorkItem(item) @@ -11148,13 +12097,9 @@ export default function TaskPage(): React.JSX.Element { // Why: dialog's repoPath has to come from the clicked item's // own repo, not primaryRepo — items may originate in any of // the selected repos now that the GitLab fetch is multi-repo. - repoPath={ - gitlabDialogItem - ? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId)?.path ?? - primaryRepo?.path ?? - null) - : null - } + repoPath={gitlabDialogRepo?.path ?? null} + repoId={gitlabDialogItem?.repoId ?? null} + sourceContext={gitlabDialogSourceContext} onCreateWorkspace={(item) => { setGitlabDialogItem(null) handleUseGitLabItem(item) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0027a4bc4f4..79aee7ec6e1 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -100,6 +100,7 @@ import { useContextualTour } from './contextual-tours/use-contextual-tour' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { closeTerminalTab } from './terminal/terminal-tab-actions' import { translate } from '@/i18n/i18n' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -148,6 +149,10 @@ function isPinnedVisibleTab( return findUnifiedTabByVisibleId(state, worktreeId, visibleId)?.isPinned === true } +function getActiveWorktreeRuntimeEnvironmentId(worktreeId: string | null): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + function isPinnedActiveEditorTab( state: TerminalStoreSnapshot, worktreeId: string, @@ -212,9 +217,6 @@ function Terminal(): React.JSX.Element | null { const closeTab = useAppStore((s) => s.closeTab) const setActiveTab = useAppStore((s) => s.setActiveTab) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle) const setTabColor = useAppStore((s) => s.setTabColor) const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) @@ -761,7 +763,7 @@ function Terminal(): React.JSX.Element | null { } // Why: in the paired web client, host session-tabs are authoritative. // Creating a local fallback races the host's initial terminal and duplicates tabs. - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) { return } @@ -778,13 +780,7 @@ function Terminal(): React.JSX.Element | null { // activity and reshuffle the sidebar. Explicit "New Tab" actions // (handleNewTab below) still bump normally. createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) - }, [ - workspaceSessionReady, - activeWorktreeId, - activeRuntimeEnvironmentId, - createTab, - reconcileWorktreeTabModel - ]) + }, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel]) const handleNewTab = useCallback( (shellOverride?: string) => { @@ -798,10 +794,11 @@ function Terminal(): React.JSX.Element | null { void openNewTerminalTabInActiveWorkspace(targetGroupId) return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionTerminal({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, command: shellOverride, activate: true }) @@ -839,7 +836,6 @@ function Terminal(): React.JSX.Element | null { focusTerminalTabSurface(newTab.id) }, [ - activeRuntimeEnvironmentId, activeWorktreeId, createTab, openNewTerminalTabInActiveWorkspace, @@ -901,10 +897,11 @@ function Terminal(): React.JSX.Element | null { return } const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, url: defaultUrl }) return @@ -913,12 +910,7 @@ function Terminal(): React.JSX.Element | null { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), focusAddressBar: true }) - }, [ - activeRuntimeEnvironmentId, - activeWorktreeId, - createBrowserTab, - openNewBrowserTabInActiveWorkspace - ]) + }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) const handleOpenEntry = useCallback(async (args: TabCreateEntryArgs) => { await openTabBarEntry(args) @@ -935,10 +927,11 @@ function Terminal(): React.JSX.Element | null { if (!source) { return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId }) @@ -949,7 +942,7 @@ function Terminal(): React.JSX.Element | null { sessionProfileId: source.sessionProfileId }) }, - [activeRuntimeEnvironmentId, activeWorktreeId, createBrowserTab] + [activeWorktreeId, createBrowserTab] ) const handleNewFile = useCallback(async () => { @@ -982,11 +975,12 @@ function Terminal(): React.JSX.Element | null { if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) { return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(owningWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) return } @@ -1022,7 +1016,6 @@ function Terminal(): React.JSX.Element | null { closeBrowserTab(tabId) }, [ - activeRuntimeEnvironmentId, closeBrowserTab, setActiveBrowserTab, setActiveFile, @@ -1060,14 +1053,15 @@ function Terminal(): React.JSX.Element | null { if (unifiedTab?.isPinned) { continue } + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if ( - isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && + isWebRuntimeSessionActive(runtimeEnvironmentId) && (unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser') ) { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) continue } @@ -1093,14 +1087,7 @@ function Terminal(): React.JSX.Element | null { queueEditorCloseRequests(dirtyFileIds) } }, - [ - activeRuntimeEnvironmentId, - activeWorktreeId, - closeBrowserTab, - closeFile, - closeTab, - queueEditorCloseRequests - ] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) const handleCloseTabsToRight = useCallback( @@ -1123,14 +1110,15 @@ function Terminal(): React.JSX.Element | null { if (unifiedTab?.isPinned) { continue } + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if ( - isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && + isWebRuntimeSessionActive(runtimeEnvironmentId) && (unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser') ) { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) continue } @@ -1156,14 +1144,7 @@ function Terminal(): React.JSX.Element | null { queueEditorCloseRequests(dirtyFileIds) } }, - [ - activeRuntimeEnvironmentId, - activeWorktreeId, - closeBrowserTab, - closeFile, - closeTab, - queueEditorCloseRequests - ] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) const handleCloseAllFiles = useCallback(() => { @@ -1188,17 +1169,18 @@ function Terminal(): React.JSX.Element | null { const handleActivateTab = useCallback( (tabId: string) => { - if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) } setActiveTab(tabId) setActiveTabType('terminal') }, - [activeRuntimeEnvironmentId, activeWorktreeId, setActiveTab, setActiveTabType] + [activeWorktreeId, setActiveTab, setActiveTabType] ) const handleTogglePaneExpand = useCallback( @@ -1217,17 +1199,18 @@ function Terminal(): React.JSX.Element | null { const handleActivateBrowserTab = useCallback( (tabId: string) => { - if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) } setActiveBrowserTab(tabId) setActiveTabType('browser') }, - [activeRuntimeEnvironmentId, activeWorktreeId, setActiveBrowserTab, setActiveTabType] + [activeWorktreeId, setActiveBrowserTab, setActiveTabType] ) // Keyboard shortcuts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 80861bff424..ee583c44743 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -56,6 +56,8 @@ import { queueBrowserFocusRequest } from '@/components/browser-pane/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' +import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { @@ -79,9 +81,15 @@ import { getComposerEligibleRepos, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' +import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type WorktreePaletteItem = { @@ -152,7 +160,8 @@ function getComposerPrefetchRepoId( return resolveComposerGitRepoId({ eligibleRepos: getComposerEligibleRepos(state.repos), initialRepoId, - activeRepoId: state.activeRepoId + activeRepoId: state.activeRepoId, + focusedHostScope: state.workspaceHostScope }) } @@ -212,6 +221,29 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme ) } +function PaletteHostBadgeChip({ + badge +}: { + badge: PaletteHostBadge | null +}): React.JSX.Element | null { + if (!badge) { + return null + } + // Host labels come from the registry and are intentionally not translated. + return ( + <span + aria-label={translate( + 'auto.components.WorktreeJumpPalette.paletteHostBadge', + 'Host: {{value0}}', + { value0: badge.label } + )} + className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88" + > + {badge.label} + </span> + ) +} + function findBrowserSelection( pageId: string, workspaceId: string, @@ -283,8 +315,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) - useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + const settings = useAppStore((s) => s.settings) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) @@ -317,6 +352,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const preserveCreateLookupOnCloseRef = useRef(false) const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + // Why: host badges only appear when more than one execution host exists; reuse + // the same registry the sidebar host-scope strip builds so labels stay in sync. + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) const canCreateWorktree = repos.length > 0 const hasQuery = deferredQuery.trim().length > 0 @@ -1150,6 +1209,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: repoForLookup.id, + repo: repoForLookup + }) // Why: awaiting inside the user gesture would leave the palette open // indefinitely on slow networks. Close immediately and populate the // composer once the lookup returns. @@ -1157,15 +1221,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { preserveCreateLookupOnCloseRef.current = true recordFeatureInteraction('cmd-j-create-workspace') closeModal() - void window.api.gh - .workItemByOwnerRepo({ - repoPath: repoForLookup.path, - repoId: repoForLookup.id, - owner: slug.owner, - repo: slug.repo, - number, - type: ghLink.type - }) + void lookupGitHubWorkItemByOwnerRepoForSource({ + repoPath: repoForLookup.path, + repoId: repoForLookup.id, + sourceContext, + owner: slug.owner, + repo: slug.repo, + number, + type: ghLink.type + }) .then((item) => { if (!createLookupGuard.isCurrent(lookupToken)) { return @@ -1227,12 +1291,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: repoForLookup.id, + repo: repoForLookup + }) const lookupToken = createLookupGuard.start() preserveCreateLookupOnCloseRef.current = true recordFeatureInteraction('cmd-j-create-workspace') closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, repoId: repoForLookup.id, number: ghNumber }) + void lookupGitHubWorkItemForSource({ + repoPath: repoForLookup.path, + repoId: repoForLookup.id, + sourceContext, + number: ghNumber + }) .then((item) => { if (!createLookupGuard.isCurrent(lookupToken)) { return @@ -1452,6 +1525,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') : null const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' + const hostBadge = getPaletteHostBadge(repo, hostOptions) return ( <CommandItem @@ -1567,6 +1641,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={hostBadge} /> </div> </div> </div> @@ -1618,6 +1693,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ? repoMap.get(simulatorWorktree.repoId) : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName + const simulatorHostBadge = getPaletteHostBadge(simulatorRepo, hostOptions) return ( <CommandItem @@ -1683,6 +1759,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={simulatorHostBadge} /> </div> </div> </div> @@ -1694,6 +1771,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const browserWorktree = worktreeMap.get(result.worktreeId) const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName + const browserHostBadge = getPaletteHostBadge(browserRepo, hostOptions) return ( <CommandItem @@ -1759,6 +1837,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={browserHostBadge} /> </div> </div> </div> diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 3bb37a76061..b0ffad51b3f 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -13,6 +13,8 @@ import { formatAutomationTokens, summarizeAutomationRunUsage } from './automation-usage-model' +import type { AutomationTargetAvailability } from './automation-target-availability' +import { getAutomationSourceDisplay } from './automation-source-display' import { translate } from '@/i18n/i18n' type AutomationDetailProps = { @@ -21,6 +23,8 @@ type AutomationDetailProps = { projectName: string workspaceName: string projectDefaultBaseRef: string | null + hostLabelById?: ReadonlyMap<string, string> + runNowAvailability: AutomationTargetAvailability | null now: number onRunNow: (automation: Automation) => void onEdit: (automation: Automation) => void @@ -28,11 +32,21 @@ type AutomationDetailProps = { onDelete: (automation: Automation) => void } -function DetailMetric({ label, value }: { label: string; value: string }): React.JSX.Element { +function DetailMetric({ + label, + value, + title +}: { + label: string + value: string + title?: string +}): React.JSX.Element { return ( <div className="min-w-0"> <div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div> - <div className="mt-1 break-words text-sm font-medium">{value}</div> + <div className="mt-1 break-words text-sm font-medium" title={title}> + {value} + </div> </div> ) } @@ -86,6 +100,8 @@ export function AutomationDetail({ projectName, workspaceName, projectDefaultBaseRef, + hostLabelById, + runNowAvailability, now, onRunNow, onEdit, @@ -115,6 +131,8 @@ export function AutomationDetail({ automation.workspaceMode === 'new_per_run' ? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default') : workspaceName + const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById) + const runNowDisabled = runNowAvailability?.canRunNow === false return ( <div className="flex w-full flex-col gap-4"> @@ -133,10 +151,26 @@ export function AutomationDetail({ </p> </div> <div className="flex shrink-0 items-center gap-1"> - <Button variant="secondary" size="sm" onClick={() => onRunNow(automation)}> - <Play className="size-4" /> - {translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')} - </Button> + <Tooltip> + <TooltipTrigger asChild> + <span> + <Button + variant="secondary" + size="sm" + onClick={() => onRunNow(automation)} + disabled={runNowDisabled} + > + <Play className="size-4" /> + {translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')} + </Button> + </span> + </TooltipTrigger> + {runNowDisabled ? ( + <TooltipContent side="bottom" sideOffset={6}> + {runNowAvailability.message} + </TooltipContent> + ) : null} + </Tooltip> <ToolbarIconButton label={translate( 'auto.components.automations.AutomationDetail.4b1ea02d2e', @@ -184,6 +218,12 @@ export function AutomationDetail({ </div> ) : null} + {runNowAvailability?.canRunNow === false ? ( + <div className="rounded-md border border-border/50 bg-muted/40 p-3 text-sm text-muted-foreground shadow-sm"> + {runNowAvailability.message} + </div> + ) : null} + <div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-5 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm"> <DetailMetric label={translate('auto.components.automations.AutomationDetail.18763ded26', 'Schedule')} @@ -209,6 +249,13 @@ export function AutomationDetail({ label={translate('auto.components.automations.AutomationDetail.15ea446b93', 'Session')} value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'} /> + {sourceDisplay ? ( + <DetailMetric + label={translate('auto.components.automations.AutomationDetail.29baf8f4c2', 'Source')} + value={sourceDisplay.label} + title={sourceDisplay.title} + /> + ) : null} <DetailMetric label={translate('auto.components.automations.AutomationDetail.620b22145e', 'Grace')} value={formatGrace(automation.missedRunGraceMinutes)} diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 53a80091392..6d113de78a4 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -58,6 +58,7 @@ type AutomationEditorDialogProps = { settings: GlobalSettings | null draft: AutomationDraft onProjectChange: (projectId: string) => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined onCreateTargetChange: (target: AutomationCreateTarget) => void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void @@ -78,6 +79,7 @@ export function AutomationEditorDialog({ settings, draft, onProjectChange, + getRepoHostLabel, onCreateTargetChange, onOpenChange, onDraftChange, @@ -166,6 +168,7 @@ export function AutomationEditorDialog({ pickerTriggerClassName={PICKER_TRIGGER_CLASS} modeToggleItemClassName={MODE_TOGGLE_ITEM_CLASS} onProjectChange={onProjectChange} + getRepoHostLabel={getRepoHostLabel} onDraftChange={onDraftChange} onOpenChange={onOpenChange} onSave={onSave} diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 6a116852817..22edb5f40a2 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import AgentCombobox from '@/components/agent/AgentCombobox' -import RepoCombobox from '@/components/repo/RepoCombobox' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' @@ -15,6 +14,7 @@ import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' import { AutomationSessionField } from './AutomationSessionField' import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' +import AutomationProjectCombobox from './AutomationProjectCombobox' import type { AutomationDraft } from './AutomationEditorDialog' type AutomationEditorDialogFooterProps = { @@ -34,6 +34,7 @@ type AutomationEditorDialogFooterProps = { pickerTriggerClassName: string modeToggleItemClassName: string onProjectChange: (projectId: string) => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void onOpenChange: (open: boolean) => void onSave: () => void @@ -56,6 +57,7 @@ export function AutomationEditorDialogFooter({ pickerTriggerClassName, modeToggleItemClassName, onProjectChange, + getRepoHostLabel, onDraftChange, onOpenChange, onSave @@ -69,7 +71,7 @@ export function AutomationEditorDialogFooter({ 'Project' )} > - <RepoCombobox + <AutomationProjectCombobox repos={repos} value={draft.projectId} onValueChange={onProjectChange} @@ -78,7 +80,7 @@ export function AutomationEditorDialogFooter({ 'Select project' )} triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`} - showStandaloneAddButton={false} + getRepoHostLabel={getRepoHostLabel} /> </Field> <Field diff --git a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx new file mode 100644 index 00000000000..6e31661419f --- /dev/null +++ b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx @@ -0,0 +1,403 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronRight, ChevronsUpDown, FolderPlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useAppStore } from '@/store' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/types' +import { + getAutomationProjectGroupForRepo, + getAutomationProjectGroups, + getAutomationProjectSelectedSource +} from './automation-project-groups' + +type AutomationProjectComboboxProps = { + repos: Repo[] + value: string + onValueChange: (repoId: string) => void + placeholder?: string + triggerClassName?: string + getRepoHostLabel?: (repo: Repo) => string | null | undefined +} + +function getRepoDetail(repo: Repo, hostLabel?: string | null): string { + const label = hostLabel?.trim() + return label ? `${label} · ${repo.path}` : repo.path +} + +function hasMultipleHosts(repos: readonly Repo[]): boolean { + const hostIds = new Set<string>() + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + if (hostIds.size > 1) { + return true + } + } + return false +} + +function hasMultipleHostsInGroup(sources: readonly Repo[]): boolean { + return hasMultipleHosts(sources) +} + +export default function AutomationProjectCombobox({ + repos, + value, + onValueChange, + placeholder = 'Select project', + triggerClassName, + getRepoHostLabel +}: AutomationProjectComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const [hostMenuProjectKey, setHostMenuProjectKey] = useState<string | null>(null) + const hostMenuCloseTimerRef = useRef<number | null>(null) + const hostMenuHoverRef = useRef<{ + projectKey: string | null + row: boolean + content: boolean + }>({ projectKey: null, row: false, content: false }) + const addRepo = useAppStore((s) => s.addRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const [isAdding, setIsAdding] = useState(false) + const inputRef = useRef<HTMLInputElement | null>(null) + const focusFrameRef = useRef<number | null>(null) + const mountedRef = useMountedRef() + + const groups = useMemo(() => getAutomationProjectGroups(repos, value), [repos, value]) + const selectedGroup = useMemo( + () => getAutomationProjectGroupForRepo(groups, value), + [groups, value] + ) + const selectedRepo = selectedGroup + ? getAutomationProjectSelectedSource(selectedGroup, value) + : null + const showHostLabels = useMemo(() => hasMultipleHosts(repos), [repos]) + const filteredGroups = useMemo(() => { + const trimmed = query.trim() + if (!trimmed) { + return groups + } + return groups.filter((group) => searchRepos(group.sources, trimmed).length > 0) + }, [groups, query]) + + const cancelFocusFrame = useCallback((): void => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + const setInputNode = useCallback( + (node: HTMLInputElement | null): void => { + if (node === null) { + cancelFocusFrame() + } + inputRef.current = node + }, + [cancelFocusFrame] + ) + + const focusSearchInput = useCallback(() => { + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus() + }) + }, [cancelFocusFrame]) + + const clearHostMenuCloseTimer = useCallback(() => { + if (hostMenuCloseTimerRef.current !== null) { + window.clearTimeout(hostMenuCloseTimerRef.current) + hostMenuCloseTimerRef.current = null + } + }, []) + + const resetHostMenuHover = useCallback(() => { + hostMenuHoverRef.current = { projectKey: null, row: false, content: false } + }, []) + + const setHostMenuHover = useCallback( + (projectKey: string, region: 'row' | 'content', hovered: boolean) => { + clearHostMenuCloseTimer() + if (hostMenuHoverRef.current.projectKey !== projectKey) { + hostMenuHoverRef.current = { projectKey, row: false, content: false } + } + hostMenuHoverRef.current[region] = hovered + if (hovered) { + setHostMenuProjectKey(projectKey) + return + } + hostMenuCloseTimerRef.current = window.setTimeout(() => { + const hover = hostMenuHoverRef.current + if (hover.projectKey === projectKey && !hover.row && !hover.content) { + setHostMenuProjectKey((current) => (current === projectKey ? null : current)) + resetHostMenuHover() + } + hostMenuCloseTimerRef.current = null + }, 100) + }, + [clearHostMenuCloseTimer, resetHostMenuHover] + ) + + useEffect(() => clearHostMenuCloseTimer, [clearHostMenuCloseTimer]) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + setCommandValue(value) + return + } + cancelFocusFrame() + setQuery('') + setHostMenuProjectKey(null) + resetHostMenuHover() + }, + [cancelFocusFrame, resetHostMenuHover, value] + ) + + const handleSelect = useCallback( + (repoId: string) => { + onValueChange(repoId) + setOpen(false) + setQuery('') + setHostMenuProjectKey(null) + resetHostMenuHover() + }, + [onValueChange, resetHostMenuHover] + ) + + const handleAddFolder = useCallback(async () => { + if (isAdding) { + return + } + setIsAdding(true) + try { + const repo = await addRepo() + if (repo) { + if (isGitRepoKind(repo)) { + await fetchWorktrees(repo.id) + } + if (!mountedRef.current) { + return + } + handleSelect(repo.id) + } + } finally { + if (mountedRef.current) { + setIsAdding(false) + } + } + }, [addRepo, fetchWorktrees, handleSelect, isAdding, mountedRef]) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className={cn( + 'h-8 min-w-[184px] justify-between px-3 text-xs font-normal', + triggerClassName + )} + > + {selectedRepo ? ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + <RepoBadgeLabel + name={selectedRepo.displayName} + color={selectedRepo.badgeColor} + badgeClassName="size-1.5" + /> + </span> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[16rem] p-0" + onOpenAutoFocus={(event) => { + event.preventDefault() + focusSearchInput() + }} + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + ref={setInputNode} + placeholder={translate( + 'auto.components.automations.AutomationProjectCombobox.search', + 'Search projects/folders...' + )} + value={query} + onValueChange={setQuery} + /> + <CommandList> + {filteredGroups.length === 0 ? ( + <div className="px-3 py-6 text-center text-xs text-muted-foreground"> + {translate( + 'auto.components.automations.AutomationProjectCombobox.empty', + 'No projects/folders match your search.' + )} + </div> + ) : null} + {filteredGroups.map((group) => { + const selectedSource = getAutomationProjectSelectedSource(group, value) + const selectedProject = group.sources.some((source) => source.id === value) + const hasHostMenu = hasMultipleHostsInGroup(group.sources) + const hostLabel = showHostLabels ? getRepoHostLabel?.(selectedSource) : null + const detail = hasHostMenu + ? `${hostLabel?.trim() || getRepoExecutionHostId(selectedSource)} · ${group.sources.length} hosts` + : getRepoDetail(selectedSource, hostLabel) + return ( + <div + key={group.projectKey} + onMouseEnter={() => { + setCommandValue(group.repo.id) + if (hasHostMenu) { + setHostMenuHover(group.projectKey, 'row', true) + } + }} + onMouseLeave={() => { + if (hasHostMenu) { + setHostMenuHover(group.projectKey, 'row', false) + } + }} + className={cn( + 'group/automation-project-row flex items-stretch transition-colors hover:bg-accent hover:text-accent-foreground', + commandValue === group.repo.id && 'bg-accent text-accent-foreground' + )} + > + <button + type="button" + onClick={() => handleSelect(selectedSource.id)} + onMouseDown={(event) => event.preventDefault()} + className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left text-xs" + > + <Check + className={cn( + 'size-3 text-foreground', + selectedProject ? 'opacity-100' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <RepoBadgeLabel + name={group.repo.displayName} + color={group.repo.badgeColor} + className="max-w-full" + /> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> + </div> + </button> + {hasHostMenu ? ( + <Popover + open={hostMenuProjectKey === group.projectKey} + onOpenChange={(nextOpen) => + setHostMenuProjectKey(nextOpen ? group.projectKey : null) + } + > + <PopoverTrigger asChild> + <button + type="button" + title={translate( + 'auto.components.automations.AutomationProjectCombobox.chooseHost', + 'Choose automation host' + )} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => event.preventDefault()} + className="flex w-7 shrink-0 items-center justify-center text-muted-foreground" + > + <ChevronRight className="size-3.5" /> + </button> + </PopoverTrigger> + <PopoverContent + side="right" + align="start" + sideOffset={6} + className="w-[min(260px,calc(100vw-1rem))] p-1" + onMouseEnter={() => setHostMenuHover(group.projectKey, 'content', true)} + onMouseLeave={() => setHostMenuHover(group.projectKey, 'content', false)} + > + <div className="py-1"> + {group.sources.map((source) => { + const sourceHostLabel = showHostLabels + ? getRepoHostLabel?.(source) + : null + const sourceSelected = source.id === selectedSource.id + return ( + <button + key={source.id} + type="button" + onMouseDown={(event) => event.preventDefault()} + onClick={() => handleSelect(source.id)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + sourceSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-xs"> + {sourceHostLabel ?? getRepoExecutionHostId(source)} + </div> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground"> + {source.path} + </p> + </div> + </button> + ) + })} + </div> + </PopoverContent> + </Popover> + ) : null} + </div> + ) + })} + </CommandList> + <div className="border-t border-border"> + <Button + type="button" + variant="ghost" + disabled={isAdding} + onClick={() => void handleAddFolder()} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => setCommandValue('')} + className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal" + > + <FolderPlus className="size-3.5 text-muted-foreground" /> + <span> + {isAdding + ? translate( + 'auto.components.automations.AutomationProjectCombobox.adding', + 'Adding project…' + ) + : translate( + 'auto.components.automations.AutomationProjectCombobox.addProject', + 'Add project' + )} + </span> + </Button> + </div> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index b6007af94bb..f5add0ff4a0 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -36,6 +36,8 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -51,8 +53,14 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' -import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { Worktree } from '../../../../shared/types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { PreflightStatus } from '../../../../preload/api-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { Repo, Worktree } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { buildAutomationCronSchedule, @@ -89,6 +97,27 @@ import { import { AutomationRunPageFrame } from './AutomationRunPageFrame' import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { buildAutomationRunContextForRepo } from './automation-run-context' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '../task-source-provider-availability' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability, + isSshConnectionBusy +} from './external-automation-source-availability' +import { + createAutomationForTarget, + deleteAutomationForTarget, + getAutomationListTarget, + listAutomationRunsForTarget, + listAutomationsForTarget, + runAutomationNowForTarget, + updateAutomationForTarget +} from './automation-host-client' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' @@ -99,6 +128,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id) const DEFAULT_TIME = '09:00' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' type AutomationPaneTab = 'overview' | 'runs' +type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' } type ExternalAutomationListEntry = | { @@ -123,6 +153,46 @@ function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } +function getRepoBackedAutomationSourceContext( + automation: Automation +): RepoBackedAutomationSourceContext | null { + const context = automation.sourceContext + return context?.provider === 'github' || context?.provider === 'gitlab' + ? (context as RepoBackedAutomationSourceContext) + : null +} + +function getRuntimeSourceHostAvailability( + context: TaskSourceContext, + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > +): TaskSourceHostAvailability | null { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + return null + } + const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) + if (!entry) { + return { hostId: context.hostId, reason: 'checking-task-source-capability' } + } + if (!entry.status) { + return { hostId: context.hostId, health: 'disconnected' } + } + if (entry.status.graphStatus !== 'ready') { + return { hostId: context.hostId, health: 'connecting' } + } + const capabilities = entry.status.capabilities + if (!capabilities) { + return { hostId: context.hostId, reason: 'checking-task-source-capability' } + } + if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { hostId: context.hostId, reason: 'missing-task-source-capability' } + } + return null +} + function formatTimeInput(hour: number, minute: number): string { return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` } @@ -191,11 +261,7 @@ function getExternalProviderLabel(manager: ExternalAutomationManager): string { } function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local' -} - -function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' } function getExternalRunStatusLabel(run: ExternalAutomationRun): string { @@ -257,6 +323,7 @@ async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number) export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -269,7 +336,17 @@ export default function AutomationsPage(): React.JSX.Element { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const settings = useAppStore((s) => s.settings) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const repoMap = useRepoMap() @@ -306,6 +383,11 @@ export default function AutomationsPage(): React.JSX.Element { const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState<SelectedExternalRunPage | null>(null) + const runtimePreflightMountedRef = useRef(true) + const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set()) + const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< + ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus> + >(() => new Map()) const selectAutomationId = useCallback( (automationId: string | null): void => { setSelectedAutomationRunPageId(null) @@ -482,9 +564,142 @@ export default function AutomationsPage(): React.JSX.Element { canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) - const selectedRepo = selected ? (repoMap.get(selected.projectId) ?? null) : null + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + const repoBackedAutomationSourceContexts = useMemo( + () => + automations + .map((automation) => getRepoBackedAutomationSourceContext(automation)) + .filter((context): context is RepoBackedAutomationSourceContext => context !== null), + [automations] + ) + const runtimeAutomationSourceHostIds = useMemo(() => { + const hostIds = new Set<TaskSourceContext['hostId']>() + for (const context of repoBackedAutomationSourceContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + if (hostAvailability) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId]) + useEffect( + () => () => { + runtimePreflightMountedRef.current = false + }, + [] + ) + useEffect(() => { + if (!preflightStatusCurrent || !preflightStatusChecked) { + void refreshPreflightStatus() + } + }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) + useEffect(() => { + const unrequestedHostIds = runtimeAutomationSourceHostIds.filter( + (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) + ) + if (unrequestedHostIds.length === 0) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequestedHostIds) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequestedHostIds) { + runtimePreflightRequestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + // Why: automation sources can be owned by a different remote server than + // the run target; provider auth/tooling must be checked on the source host. + void callRuntimeRpc<PreflightStatus>( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + .then((status) => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status }) + return next + }) + }) + .catch(() => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status: null }) + return next + }) + }) + } + }, [runtimeAutomationSourceHostIds]) + const automationSourceHostAvailabilityById = useMemo(() => { + const availabilityById = new Map<string, TaskSourceHostAvailability[]>() + for (const automation of automations) { + const context = getRepoBackedAutomationSourceContext(automation) + if (!context) { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + const providerAvailability = getRepoBackedProviderAvailability({ + provider: context.provider, + contexts: [context], + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + const availability = [ + ...(hostAvailability ? [hostAvailability] : []), + ...providerAvailability + ] + if (availability.length > 0) { + availabilityById.set(automation.id, availability) + } + } + return availabilityById + }, [ + automations, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + runtimeStatusByEnvironmentId + ]) + const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null const selectedWorktree = selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null + const selectedRunNowAvailability = selected + ? getAutomationTargetAvailability({ + automation: selected, + repo: selectedRepo, + workspace: selectedWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) + }) + : null const canSaveDraft = editingAutomationId === null || !draftAtOpen || @@ -497,10 +712,56 @@ export default function AutomationsPage(): React.JSX.Element { sourceKey: getExternalAutomationSourceKey(selectedExternal.manager) } : null + const selectedExternalSshStatus = selectedExternalSshSource + ? sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status + : undefined + const selectedExternalSshConnected = selectedExternalSshStatus === 'connected' const isSelectedExternalSshConnecting = selectedExternalSshSource !== null && (connectingExternalSourceKey === selectedExternalSshSource.sourceKey || - isSshConnectionBusy(sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status)) + isSshConnectionBusy(selectedExternalSshStatus)) + const selectedExternalSourceAvailability = + selectedExternal?.kind === 'source' + ? getExternalAutomationSourceAvailability({ + manager: selectedExternal.manager, + providerLabel: getExternalProviderLabel(selectedExternal.manager), + targetKindLabel: getExternalTargetKindLabel(selectedExternal.manager), + sshStatus: selectedExternalSshStatus, + isConnectingOverride: isSelectedExternalSshConnecting + }) + : null + + const getAutomationRepoHostLabel = useCallback( + (repo: Repo): string => { + const hostId = getRepoExecutionHostId(repo) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'ssh') { + return sshTargetLabels.get(parsed.targetId) ?? parsed.targetId + } + if (parsed?.kind === 'runtime') { + return ( + runtimeEnvironments.find((environment) => environment.id === parsed.environmentId) + ?.name ?? parsed.environmentId + ) + } + return 'Local Mac' + }, + [runtimeEnvironments, sshTargetLabels] + ) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostLabelById = useMemo(() => { + const labels = new Map<string, string>([['local', 'Local Mac']]) + for (const [targetId, label] of sshTargetLabels) { + labels.set(`ssh:${encodeURIComponent(targetId)}`, label) + } + for (const environment of runtimeEnvironments) { + labels.set(`runtime:${encodeURIComponent(environment.id)}`, environment.name) + } + for (const [hostId, label] of hostLabelOverrides) { + labels.set(hostId, label) + } + return labels + }, [hostLabelOverrides, runtimeEnvironments, sshTargetLabels]) useEffect(() => { if ((!selected || selectedExternal) && activePaneTab === 'runs') { @@ -525,10 +786,11 @@ export default function AutomationsPage(): React.JSX.Element { const refresh = useCallback(async () => { setIsLoading(true) + const automationHostTarget = getAutomationListTarget(settings) try { const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ - window.api.automations.list(), - window.api.automations.listRuns(), + listAutomationsForTarget(automationHostTarget), + listAutomationRunsForTarget(automationHostTarget), window.api.automations.listExternalManagers() ]) const currentSelectedId = useAppStore.getState().selectedAutomationId @@ -539,7 +801,7 @@ export default function AutomationsPage(): React.JSX.Element { ? currentSelectedId : (nextAutomations[0]?.id ?? null) const nextSelectedRuns = nextSelectedId - ? await window.api.automations.listRuns({ automationId: nextSelectedId }) + ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) : [] setAutomations(nextAutomations) setRuns(nextRuns) @@ -554,7 +816,7 @@ export default function AutomationsPage(): React.JSX.Element { } finally { setIsLoading(false) } - }, [selectAutomationId]) + }, [selectAutomationId, settings]) const hydratePersistedUIState = useCallback(async (): Promise<void> => { useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) @@ -577,15 +839,17 @@ export default function AutomationsPage(): React.JSX.Element { return } let cancelled = false - void window.api.automations.listRuns({ automationId }).then((nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) + void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then( + (nextRuns) => { + if (!cancelled) { + setSelectedAutomationRuns({ automationId, runs: nextRuns }) + } } - }) + ) return () => { cancelled = true } - }, [selected?.id, runs]) + }, [selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { @@ -783,7 +1047,7 @@ export default function AutomationsPage(): React.JSX.Element { name: latest.name, prompt: latest.prompt, agentId: latest.agentId, - projectId: latest.projectId, + projectId: getAutomationRunRepoId(latest), workspaceMode: latest.workspaceMode, workspaceId: latest.workspaceId ?? '', baseBranch: latest.baseBranch ?? '', @@ -1035,13 +1299,27 @@ export default function AutomationsPage(): React.JSX.Element { ? Math.max(0, rawMissedRunGraceMinutes) : 720 const precheck = buildDraftPrecheck(draft) + const runContext = buildAutomationRunContextForRepo({ + repoId: draft.projectId, + repos, + projectHostSetups + }) + if (!runContext) { + toast.error( + translate( + 'auto.components.automations.AutomationsPage.32534e7c9c', + 'Choose an available workspace before saving.' + ) + ) + return + } let currentAutomation = editingAutomationId ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) : null if (editingAutomationId) { try { currentAutomation = - (await window.api.automations.list()).find( + (await listAutomationsForTarget(getAutomationListTarget(settings))).find( (automation) => automation.id === editingAutomationId ) ?? currentAutomation } catch { @@ -1053,6 +1331,7 @@ export default function AutomationsPage(): React.JSX.Element { prompt: draft.prompt, precheck, agentId: draft.agentId, + runContext, projectId: draft.projectId, workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, @@ -1067,15 +1346,18 @@ export default function AutomationsPage(): React.JSX.Element { updates.dtstart = now } const automation = editingAutomationId - ? await window.api.automations.update({ - id: editingAutomationId, - updates - }) - : await window.api.automations.create({ + ? currentAutomation + ? await updateAutomationForTarget(currentAutomation, updates) + : await window.api.automations.update({ + id: editingAutomationId, + updates + }) + : await createAutomationForTarget({ name: draft.name, prompt: draft.prompt, precheck, agentId: draft.agentId, + runContext, projectId: draft.projectId, workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, @@ -1126,15 +1408,12 @@ export default function AutomationsPage(): React.JSX.Element { } const toggleAutomation = async (automation: Automation): Promise<void> => { - await window.api.automations.update({ - id: automation.id, - updates: { enabled: !automation.enabled } - }) + await updateAutomationForTarget(automation, { enabled: !automation.enabled }) await refresh() } const deleteAutomation = async (automation: Automation): Promise<void> => { - await window.api.automations.delete({ id: automation.id }) + await deleteAutomationForTarget(automation) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) } @@ -1195,7 +1474,24 @@ export default function AutomationsPage(): React.JSX.Element { } const runNow = async (automation: Automation): Promise<void> => { - await window.api.automations.runNow({ id: automation.id }) + const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null + const workspace = automation.workspaceId + ? (worktreeMap.get(automation.workspaceId) ?? null) + : null + const availability = getAutomationTargetAvailability({ + automation, + repo, + workspace, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) + if (!availability.canRunNow) { + toast.error(availability.message) + return + } + await runAutomationNowForTarget(automation) useAppStore.getState().recordFeatureInteraction('automation-run') await hydratePersistedUIState() await refresh() @@ -1205,7 +1501,6 @@ export default function AutomationsPage(): React.JSX.Element { } const rerunAutomationRun = async (automation: Automation, run: AutomationRun): Promise<void> => { - const automationId = automation.id const runId = run.id if (rerunRunIdsInFlightRef.current.has(runId)) { return @@ -1214,7 +1509,7 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await window.api.automations.runNow({ id: automationId }) + await runAutomationNowForTarget(automation) await hydratePersistedUIState() await refresh() toast.message( @@ -1373,6 +1668,16 @@ export default function AutomationsPage(): React.JSX.Element { const sourceKey = getExternalAutomationSourceKey(manager) setConnectingExternalSourceKey(sourceKey) try { + if (sshConnectionStates.get(manager.target.connectionId)?.status === 'connected') { + await refresh() + toast.success( + translate( + 'auto.components.automations.AutomationsPage.a21f6c33ad', + 'Automation source refreshed.' + ) + ) + return + } const state = await window.api.ssh.connect({ targetId: manager.target.connectionId }) if (!state || state.status !== 'connected') { toast.error( @@ -1568,6 +1873,7 @@ export default function AutomationsPage(): React.JSX.Element { settings={settings} draft={draft} onProjectChange={handleProjectChange} + getRepoHostLabel={getAutomationRepoHostLabel} onCreateTargetChange={handleCreateTargetChange} onOpenChange={setCreateOpen} onDraftChange={setDraft} @@ -1753,10 +2059,19 @@ export default function AutomationsPage(): React.JSX.Element { </div> ) : null} {automations.map((automation) => { - const automationRepo = repoMap.get(automation.projectId) + const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) const automationWorktree = automation.workspaceId ? worktreeMap.get(automation.workspaceId) : null + const automationRunAvailability = getAutomationTargetAvailability({ + automation, + repo: automationRepo, + workspace: automationWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) const workspaceLabel = automation.workspaceMode === 'new_per_run' ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` @@ -1836,12 +2151,25 @@ export default function AutomationsPage(): React.JSX.Element { </button> </ContextMenuTrigger> <ContextMenuContent className="w-48"> - <ContextMenuItem onSelect={() => void runNow(automation)}> + <ContextMenuItem + disabled={!automationRunAvailability.canRunNow} + onSelect={(event) => { + if (!automationRunAvailability.canRunNow) { + event.preventDefault() + return + } + void runNow(automation) + }} + > <Play className="size-3.5" /> - {translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} + <span className="min-w-0 truncate"> + {automationRunAvailability.canRunNow + ? translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + ) + : automationRunAvailability.message} + </span> </ContextMenuItem> <ContextMenuItem onSelect={() => void openEditDialog(automation)}> <Pencil className="size-3.5" /> @@ -1882,11 +2210,16 @@ export default function AutomationsPage(): React.JSX.Element { const providerLabel = getExternalProviderLabel(entry.manager) const targetKindLabel = getExternalTargetKindLabel(entry.manager) if (entry.kind === 'source') { - const sourceStatus = - entry.manager.target.type === 'ssh' ? 'Connect to load jobs' : 'Unavailable' - const sourceSummary = - entry.manager.error ?? - `${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.` + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const sourceAvailability = getExternalAutomationSourceAvailability({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus + }) return ( <button key={entry.key} @@ -1919,12 +2252,12 @@ export default function AutomationsPage(): React.JSX.Element { <span className="truncate">{targetKindLabel}</span> </span> <span className="mt-1 block truncate text-xs text-muted-foreground"> - {sourceSummary} + {sourceAvailability.summary} </span> </span> <span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground"> <Clock className="size-3.5" /> - <span className="line-clamp-2">{sourceStatus}</span> + <span className="line-clamp-2">{sourceAvailability.statusLabel}</span> </span> </button> ) @@ -1932,7 +2265,18 @@ export default function AutomationsPage(): React.JSX.Element { const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : 'Paused' - const actionDisabled = !entry.manager.canManage || externalActionKey !== null + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) return ( <ContextMenu key={entry.key}> @@ -1995,10 +2339,13 @@ export default function AutomationsPage(): React.JSX.Element { onSelect={() => requestExternalAction(entry.manager, entry.job, 'run')} > <Play className="size-3.5" /> - {translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} + <span className="min-w-0 truncate"> + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + </span> </ContextMenuItem> {entry.manager.provider === 'hermes' ? ( <ContextMenuItem @@ -2134,14 +2481,7 @@ export default function AutomationsPage(): React.JSX.Element { {selectedExternal.manager.targetLabel} </div> <div className="text-xs text-muted-foreground"> - {getExternalProviderLabel(selectedExternal.manager)}{' '} - {translate( - 'auto.components.automations.AutomationsPage.aaa007846f', - 'source unavailable' - )} - {selectedExternal.manager.error - ? ` - ${selectedExternal.manager.error}` - : null} + {selectedExternalSourceAvailability?.summary} </div> </div> {selectedExternalSshSource ? ( @@ -2149,31 +2489,33 @@ export default function AutomationsPage(): React.JSX.Element { type="button" variant="outline" size="sm" - disabled={isSelectedExternalSshConnecting} + disabled={selectedExternalSourceAvailability?.isConnecting ?? false} onClick={() => void connectExternalAutomationSource(selectedExternalSshSource.manager) } > - {isSelectedExternalSshConnecting ? ( + {selectedExternalSourceAvailability?.isConnecting ? ( <RefreshCw className="size-3.5 animate-spin" /> ) : null} - {isSelectedExternalSshConnecting + {selectedExternalSourceAvailability?.isConnecting ? translate( 'auto.components.automations.AutomationsPage.f93ed7a6f8', 'Connecting...' ) - : translate( - 'auto.components.automations.AutomationsPage.7934ee0d81', - 'Connect SSH' - )} + : selectedExternalSshConnected + ? translate( + 'auto.components.automations.AutomationsPage.53f06f0ad5', + 'Retry source' + ) + : translate( + 'auto.components.automations.AutomationsPage.7934ee0d81', + 'Connect SSH' + )} </Button> ) : null} </div> <div className="px-3 py-6 text-sm text-muted-foreground"> - {translate( - 'auto.components.automations.AutomationsPage.97ff587ee3', - 'Connect this source to check for Hermes automations in the remote profile.' - )} + {selectedExternalSourceAvailability?.detail} </div> </div> )} @@ -2213,6 +2555,8 @@ export default function AutomationsPage(): React.JSX.Element { ? 'New workspace each run' : (selectedWorktree?.displayName ?? 'Missing workspace') } + hostLabelById={hostLabelById} + runNowAvailability={selectedRunNowAvailability} now={relativeNow} onRunNow={(automation) => void runNow(automation)} onEdit={(automation) => void openEditDialog(automation)} diff --git a/src/renderer/src/components/automations/CreateFromPicker.test.tsx b/src/renderer/src/components/automations/CreateFromPicker.test.tsx new file mode 100644 index 00000000000..46cb4ded084 --- /dev/null +++ b/src/renderer/src/components/automations/CreateFromPicker.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { CreateFromPicker } from './CreateFromPicker' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</> +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandInput: () => <input />, + CommandItem: ({ children }: { children: React.ReactNode }) => <button>{children}</button>, + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div> +})) + +const storeState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [] as Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] +} + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState) +})) + +vi.mock('@/runtime/runtime-repo-client', () => ({ + getRuntimeRepoBaseRefDefault: vi.fn().mockResolvedValue({ + defaultBaseRef: 'main', + remoteCount: 1 + }), + searchRuntimeRepoBaseRefs: vi.fn().mockResolvedValue([]) +})) + +let container: HTMLDivElement +let root: Root + +function repoMapFor(repo: Repo): Map<string, Repo> { + return new Map([[repo.id, repo]]) +} + +function makeRepo(overrides: Partial<Repo>): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + ...overrides + } +} + +async function renderPicker(repo: Repo): Promise<void> { + await act(async () => { + root.render( + <CreateFromPicker + repoId={repo.id} + repoMap={repoMapFor(repo)} + worktrees={[]} + value="" + onValueChange={vi.fn()} + /> + ) + }) +} + +describe('CreateFromPicker host routing', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + vi.clearAllMocks() + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + storeState.repos = [] + }) + + it('uses the selected runtime-owned repo host instead of the focused runtime', async () => { + const repo = makeRepo({ executionHostId: 'runtime:owner-runtime' }) + storeState.repos = [repo] + + await renderPicker(repo) + + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'owner-runtime' }, + repo.id + ) + }) + + it('keeps an explicit local repo on the local client even when a runtime is focused', async () => { + const repo = makeRepo({ executionHostId: 'local' }) + storeState.repos = [repo] + + await renderPicker(repo) + + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: null }, + repo.id + ) + expect(searchRuntimeRepoBaseRefs).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index 8b89da1852b..3dea3e99680 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { cn } from '@/lib/utils' import type { Repo, Worktree } from '../../../../shared/types' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -40,8 +41,8 @@ export function CreateFromPicker({ triggerClassName?: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repoId) ) const repo = repoMap.get(repoId) const [open, setOpen] = React.useState(false) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index e116564cbdd..27259681b3b 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -15,6 +15,7 @@ import { type FetchExternalAutomationRuns } from './ExternalAutomationRunTable' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' import { translate } from '@/i18n/i18n' type ExternalAutomationManagersProps = { @@ -59,7 +60,7 @@ function getProviderLabel(manager: ExternalAutomationManager): string { } function getTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local' + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' } function ExternalActionButton({ @@ -163,6 +164,10 @@ export function ExternalAutomationManagers({ <div className="divide-y divide-border/40"> {manager.jobs.map((job) => { const scheduleDisplay = getExternalAutomationScheduleDisplay(manager, job) + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager, + actionInProgress: runningActionKey !== null + }) return ( <div key={job.id} @@ -228,11 +233,14 @@ export function ExternalAutomationManagers({ </div> <div className="flex items-center justify-end gap-1"> <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.cc77ba88ff', - 'Run external automation' - )} - disabled={!manager.canManage || runningActionKey !== null} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.cc77ba88ff', + 'Run external automation' + ) + } + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, 'run')} > {runningActionKey === actionKey(manager, job, 'run') ? ( @@ -243,11 +251,14 @@ export function ExternalAutomationManagers({ </ExternalActionButton> {manager.provider === 'hermes' ? ( <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.1df491fd00', - 'Edit external automation' - )} - disabled={!manager.canManage || runningActionKey !== null} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.1df491fd00', + 'Edit external automation' + ) + } + disabled={disabledMessage !== null} onClick={() => onEdit?.(manager, job)} > <Pencil className="size-3.5" /> @@ -255,7 +266,8 @@ export function ExternalAutomationManagers({ ) : null} <ExternalActionButton label={ - job.enabled + disabledMessage ?? + (job.enabled ? translate( 'auto.components.automations.ExternalAutomationManagers.0def1693bb', 'Pause external automation' @@ -263,9 +275,9 @@ export function ExternalAutomationManagers({ : translate( 'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe', 'Resume external automation' - ) + )) } - disabled={!manager.canManage || runningActionKey !== null} + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')} > {runningActionKey === @@ -278,12 +290,15 @@ export function ExternalAutomationManagers({ )} </ExternalActionButton> <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.a42bf2b27e', - 'Delete external automation' - )} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.a42bf2b27e', + 'Delete external automation' + ) + } className="text-destructive hover:text-destructive" - disabled={!manager.canManage || runningActionKey !== null} + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, 'delete')} > {runningActionKey === actionKey(manager, job, 'delete') ? ( diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts new file mode 100644 index 00000000000..e2073cd04d4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' +import { + createAutomationForTarget, + getAutomationListTarget, + listAutomationsForTarget, + runAutomationNowForTarget +} from './automation-host-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const mockApi = { + automations: { + list: vi.fn(), + listRuns: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + runNow: vi.fn() + } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function makeAutomation(overrides: Partial<Automation> = {}): Automation { + return { + id: 'auto-1', + name: 'Remote check', + prompt: 'Check', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-1', + path: '/srv/orca' + }, + ...overrides + } +} + +describe('automation host client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists automations from the active remote server when one is selected', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automations: [makeAutomation()] }) + + const target = getAutomationListTarget({ activeRuntimeEnvironmentId: 'gpu' }) + const automations = await listAutomationsForTarget(target) + + expect(automations).toHaveLength(1) + expect(mockApi.automations.list).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'gpu' }, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + }) + + it('creates and manually runs runtime-host automations through that server', async () => { + const automation = makeAutomation() + const input: AutomationCreateInput = { + name: automation.name, + prompt: automation.prompt, + precheck: null, + agentId: automation.agentId, + runContext: automation.runContext, + projectId: automation.projectId, + workspaceMode: automation.workspaceMode, + workspaceId: null, + timezone: automation.timezone, + rrule: automation.rrule, + dtstart: automation.dtstart + } + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ automation }) + .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + + await createAutomationForTarget(input) + await runAutomationNowForTarget(automation) + + expect(mockApi.automations.create).not.toHaveBeenCalled() + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'environment', environmentId: 'gpu' }, + 'automation.create', + expect.objectContaining({ + repo: 'repo-1', + workspace: undefined, + runContext: automation.runContext + }), + { timeoutMs: 15_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'gpu' }, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts new file mode 100644 index 00000000000..cee3bf6f613 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -0,0 +1,156 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { GlobalSettings } from '../../../../shared/types' + +type RuntimeAutomationCreateInput = Omit< + AutomationCreateInput, + 'projectId' | 'workspaceId' | 'timezone' +> & { + repo?: string + workspace?: string + timezone?: string +} + +type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'workspaceId'> & { + repo?: string + workspace?: string +} + +type AutomationHostTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +function getRuntimeTargetFromHostId(hostId: string | null | undefined): AutomationHostTarget { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsed.environmentId } + : { kind: 'local' } +} + +export function getAutomationListTarget( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): AutomationHostTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +} + +export function getAutomationOwnerTarget( + automation: Pick<Automation, 'runContext'> +): AutomationHostTarget { + return getRuntimeTargetFromHostId(automation.runContext?.hostId) +} + +export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { + return getRuntimeTargetFromHostId(input.runContext?.hostId) +} + +function toRuntimeAutomationCreateInput( + input: AutomationCreateInput +): RuntimeAutomationCreateInput { + const { projectId, workspaceId, ...rest } = input + return { + ...rest, + repo: projectId, + workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined + } +} + +function toRuntimeAutomationUpdateInput( + input: AutomationUpdateInput +): RuntimeAutomationUpdateInput { + const { projectId, workspaceId, ...rest } = input + return { + ...rest, + ...(projectId !== undefined ? { repo: projectId } : {}), + ...(workspaceId !== undefined ? { workspace: workspaceId ?? undefined } : {}) + } +} + +export async function listAutomationsForTarget( + target: AutomationHostTarget +): Promise<Automation[]> { + if (target.kind === 'local') { + return await window.api.automations.list() + } + const result = await callRuntimeRpc<{ automations: Automation[] }>( + target, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + return result.automations +} + +export async function listAutomationRunsForTarget( + target: AutomationHostTarget, + automationId?: string +): Promise<AutomationRun[]> { + if (target.kind === 'local') { + return await window.api.automations.listRuns(automationId ? { automationId } : undefined) + } + const result = await callRuntimeRpc<{ runs: AutomationRun[] }>( + target, + 'automation.runs', + automationId ? { automationId } : {}, + { timeoutMs: 15_000 } + ) + return result.runs +} + +export async function createAutomationForTarget(input: AutomationCreateInput): Promise<Automation> { + const target = getAutomationCreateTarget(input) + if (target.kind === 'local') { + return await window.api.automations.create(input) + } + const result = await callRuntimeRpc<{ automation: Automation }>( + target, + 'automation.create', + toRuntimeAutomationCreateInput(input), + { timeoutMs: 15_000 } + ) + return result.automation +} + +export async function updateAutomationForTarget( + automation: Automation, + updates: AutomationUpdateInput +): Promise<Automation> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + return await window.api.automations.update({ id: automation.id, updates }) + } + const result = await callRuntimeRpc<{ automation: Automation }>( + target, + 'automation.update', + { id: automation.id, updates: toRuntimeAutomationUpdateInput(updates) }, + { timeoutMs: 15_000 } + ) + return result.automation +} + +export async function deleteAutomationForTarget(automation: Automation): Promise<void> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + await window.api.automations.delete({ id: automation.id }) + return + } + await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) +} + +export async function runAutomationNowForTarget(automation: Automation): Promise<AutomationRun> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + return await window.api.automations.runNow({ id: automation.id }) + } + const result = await callRuntimeRpc<{ run: AutomationRun }>( + target, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + return result.run +} diff --git a/src/renderer/src/components/automations/automation-project-groups.test.ts b/src/renderer/src/components/automations/automation-project-groups.test.ts new file mode 100644 index 00000000000..4423c381505 --- /dev/null +++ b/src/renderer/src/components/automations/automation-project-groups.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { + getAutomationProjectGroupForRepo, + getAutomationProjectGroups, + getAutomationProjectSelectedSource +} from './automation-project-groups' + +function repo(overrides: Partial<Repo>): Repo { + return { + id: overrides.id ?? 'repo-1', + displayName: overrides.displayName ?? 'repo', + path: overrides.path ?? '/repo', + kind: 'git', + addedAt: overrides.addedAt ?? 1, + badgeColor: overrides.badgeColor ?? '#777777', + connectionId: overrides.connectionId ?? null, + executionHostId: overrides.executionHostId, + upstream: overrides.upstream, + repoIcon: overrides.repoIcon + } as Repo +} + +describe('getAutomationProjectGroups', () => { + it('groups same logical project sources under one row', () => { + const groups = getAutomationProjectGroups( + [ + repo({ + id: 'local', + displayName: 'claude-swap', + path: '/Users/me/claude-swap', + repoIcon: { type: 'image', source: 'github', label: 'realiti4/claude-swap', src: '' } + }), + repo({ + id: 'ssh', + displayName: 'claude-swap', + path: '/home/orca/claude-swap', + connectionId: 'docker', + repoIcon: { type: 'image', source: 'github', label: 'realiti4/claude-swap', src: '' } + }), + repo({ + id: 'other', + displayName: 'other', + path: '/other' + }) + ], + 'ssh' + ) + + expect(groups).toHaveLength(2) + expect(groups[0]).toMatchObject({ + projectKey: 'github:realiti4/claude-swap', + repo: { id: 'ssh' } + }) + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local', 'ssh']) + }) + + it('finds and preserves the selected concrete source', () => { + const groups = getAutomationProjectGroups( + [ + repo({ id: 'local', upstream: { owner: 'stablyai', repo: 'orca' } }), + repo({ + id: 'ssh', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + 'ssh' + ) + const group = getAutomationProjectGroupForRepo(groups, 'ssh') + + expect(group).not.toBeNull() + expect(group ? getAutomationProjectSelectedSource(group, 'ssh').id : null).toBe('ssh') + }) +}) diff --git a/src/renderer/src/components/automations/automation-project-groups.ts b/src/renderer/src/components/automations/automation-project-groups.ts new file mode 100644 index 00000000000..eacea1f8c41 --- /dev/null +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -0,0 +1,64 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/types' + +export type AutomationProjectGroup = { + projectKey: string + repo: Repo + sources: Repo[] +} + +export function getAutomationProjectGroups( + repos: readonly Repo[], + selectedRepoId: string +): AutomationProjectGroup[] { + const groupsByProject = new Map<string, AutomationProjectGroup>() + for (const repo of repos) { + const projectKey = getProjectIdentityKey(repo) + const current = groupsByProject.get(projectKey) + if (!current) { + groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) + continue + } + current.sources.push(repo) + if (compareAutomationProjectCandidate(repo, current.repo, selectedRepoId) < 0) { + current.repo = repo + } + } + return [...groupsByProject.values()].map((group) => ({ + ...group, + sources: [...group.sources].sort(compareAutomationProjectSource) + })) +} + +export function getAutomationProjectGroupForRepo( + groups: readonly AutomationProjectGroup[], + repoId: string +): AutomationProjectGroup | null { + return groups.find((group) => group.sources.some((source) => source.id === repoId)) ?? null +} + +export function getAutomationProjectSelectedSource( + group: AutomationProjectGroup, + repoId: string +): Repo { + return group.sources.find((source) => source.id === repoId) ?? group.repo +} + +function compareAutomationProjectCandidate(a: Repo, b: Repo, selectedRepoId: string): number { + const aSelected = a.id === selectedRepoId + const bSelected = b.id === selectedRepoId + if (aSelected !== bSelected) { + return aSelected ? -1 : 1 + } + return compareAutomationProjectSource(a, b) +} + +function compareAutomationProjectSource(a: Repo, b: Repo): number { + const aLocal = getRepoExecutionHostId(a) === LOCAL_EXECUTION_HOST_ID + const bLocal = getRepoExecutionHostId(b) === LOCAL_EXECUTION_HOST_ID + if (aLocal !== bLocal) { + return aLocal ? -1 : 1 + } + return (a.addedAt ?? 0) - (b.addedAt ?? 0) || a.id.localeCompare(b.id) +} diff --git a/src/renderer/src/components/automations/automation-run-context.test.ts b/src/renderer/src/components/automations/automation-run-context.test.ts new file mode 100644 index 00000000000..345b03d56f7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-context.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import { buildAutomationRunContextForRepo } from './automation-run-context' + +function repo(id: string, path = `/repos/${id}`): Repo { + return { + id, + path, + displayName: id, + badgeColor: '#000000', + addedAt: 1 + } +} + +function setup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup { + return { + id: 'setup-builder', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + repoId: 'repo-builder', + path: '/remote/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildAutomationRunContextForRepo', () => { + it('persists logical project and host setup identity for the selected run repo', () => { + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [repo('repo-local', '/local/orca'), repo('repo-builder', '/remote/orca')], + projectHostSetups: [ + setup({ + id: 'setup-local', + hostId: 'local', + repoId: 'repo-local', + path: '/local/orca' + }), + setup() + ] + }) + ).toEqual({ + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'repo-builder', + path: '/remote/orca' + }) + }) + + it('does not build a run context for missing or not-ready setups', () => { + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [repo('repo-builder')], + projectHostSetups: [setup({ setupState: 'setting-up' })] + }) + ).toBeNull() + + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [], + projectHostSetups: [setup()] + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-context.ts b/src/renderer/src/components/automations/automation-run-context.ts new file mode 100644 index 00000000000..65bad47c9f3 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-context.ts @@ -0,0 +1,29 @@ +import { + buildWorkspaceRunContext, + type WorkspaceRunContext +} from '../../../../shared/task-source-context' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' + +export function buildAutomationRunContextForRepo(args: { + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] +}): WorkspaceRunContext | null { + const setup = args.projectHostSetups.find( + (candidate) => candidate.repoId === args.repoId && candidate.setupState === 'ready' + ) + if (!setup) { + return null + } + const repo = args.repos.find((candidate) => candidate.id === setup.repoId) + if (!repo) { + return null + } + return buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path || repo.path + }) +} diff --git a/src/renderer/src/components/automations/automation-source-display.test.ts b/src/renderer/src/components/automations/automation-source-display.test.ts new file mode 100644 index 00000000000..e52e96c71eb --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-display.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getAutomationSourceDisplay } from './automation-source-display' + +describe('automation source display', () => { + it('summarizes repo-backed source context separately from run location', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + hostId: 'ssh:devbox', + projectId: 'github:stablyai/orca', + projectHostSetupId: 'setup-devbox', + repoId: 'repo-devbox', + accountLabel: 'dev@example.com', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + } + } + + expect(getAutomationSourceDisplay(sourceContext)).toEqual({ + label: 'GitHub · devbox · stablyai/orca', + title: 'GitHub source · Host: devbox · Account: dev@example.com · Source: stablyai/orca' + }) + }) + + it('uses account identity for Linear sources', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + hostId: 'local', + projectId: 'repo-1', + projectHostSetupId: 'setup-local', + repoId: 'repo-1', + accountLabel: 'Linear API key', + providerIdentity: { + provider: 'linear', + workspaceId: 'legacy', + workspaceName: 'Saved Linear workspace' + } + } + + expect(getAutomationSourceDisplay(sourceContext)).toEqual({ + label: 'Linear · Local Mac · Saved Linear workspace', + title: + 'Linear source · Host: Local Mac · Account: Linear API key · Source: Saved Linear workspace' + }) + }) + + it('uses saved remote server labels for runtime-backed sources', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + projectId: 'github:stablyai/orca', + projectHostSetupId: 'setup-runtime', + repoId: 'repo-runtime', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + } + } + + expect( + getAutomationSourceDisplay( + sourceContext, + new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']]) + ) + ).toEqual({ + label: 'GitHub · dev box · stablyai/orca', + title: 'GitHub source · Host: dev box · Source: stablyai/orca' + }) + }) + + it('returns null when no source context is saved', () => { + expect(getAutomationSourceDisplay(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-source-display.ts b/src/renderer/src/components/automations/automation-source-display.ts new file mode 100644 index 00000000000..accfb9a73fc --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-display.ts @@ -0,0 +1,64 @@ +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import type { TaskSourceContext } from '../../../../shared/task-source-context' + +export type AutomationSourceDisplay = { + label: string + title: string +} + +export function getAutomationSourceDisplay( + sourceContext: TaskSourceContext | null | undefined, + hostLabelById?: ReadonlyMap<string, string> +): AutomationSourceDisplay | null { + if (!sourceContext) { + return null + } + const providerLabel = getProviderLabel(sourceContext.provider) + const hostLabel = + hostLabelById?.get(sourceContext.hostId) ?? getExecutionHostLabel(sourceContext.hostId) + const identityLabel = getSourceIdentityLabel(sourceContext) + const label = [providerLabel, hostLabel, identityLabel] + .filter((part): part is string => Boolean(part)) + .join(' · ') + const title = [ + `${providerLabel} source`, + `Host: ${hostLabel}`, + sourceContext.accountLabel ? `Account: ${sourceContext.accountLabel}` : null, + identityLabel ? `Source: ${identityLabel}` : null + ] + .filter((part): part is string => Boolean(part)) + .join(' · ') + return { label, title } +} + +function getProviderLabel(provider: TaskSourceContext['provider']): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'linear': + return 'Linear' + case 'jira': + return 'Jira' + } +} + +function getSourceIdentityLabel(sourceContext: TaskSourceContext): string | null { + const identity = sourceContext.providerIdentity + if (identity) { + switch (identity.provider) { + case 'github': + return `${identity.owner}/${identity.repo}` + case 'gitlab': + return identity.namespace && identity.project + ? `${identity.namespace}/${identity.project}` + : (identity.projectId ?? null) + case 'linear': + return identity.workspaceName ?? identity.workspaceId ?? null + case 'jira': + return identity.siteUrl ?? identity.siteId ?? null + } + } + return sourceContext.accountLabel ?? sourceContext.repoId ?? null +} diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts new file mode 100644 index 00000000000..a640de7d7b5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import { getAutomationTargetAvailability } from './automation-target-availability' + +function makeAutomation(overrides: Partial<Automation> = {}): Automation { + return { + id: 'automation-1', + name: 'Nightly', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'worktree-1', + baseBranch: null, + reuseSession: false, + timezone: 'America/Los_Angeles', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRepo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeWorkspace(overrides: Partial<Worktree> = {}): Worktree { + return { + id: 'worktree-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'Main', + ...overrides + } as Worktree +} + +function makeProjectHostSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRuntimeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus { + return { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + ...overrides + } +} + +describe('automation target availability', () => { + it('allows local automations with an available existing workspace', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation(), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map() + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('blocks missing projects and missing existing workspaces', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation(), + repo: null, + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map() + }).reason + ).toBe('missing-project') + + expect( + getAutomationTargetAvailability({ + automation: makeAutomation(), + repo: makeRepo(), + workspace: null, + projectHostSetups: [], + sshConnectionStates: new Map() + }).reason + ).toBe('missing-workspace') + }) + + it('blocks a saved run context that no longer matches the repo host setup', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup()], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + + it('blocks saved run contexts whose project host setup is missing or not ready', () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }) + + expect( + getAutomationTargetAvailability({ + automation, + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map() + }).reason + ).toBe('missing-project-host-setup') + + expect( + getAutomationTargetAvailability({ + automation, + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ setupState: 'error' })], + sshConnectionStates: new Map() + }) + ).toMatchObject({ + reason: 'project-host-setup-not-ready', + message: 'Project setup on the selected automation host is error.' + }) + }) + + it('requires SSH hosts to be connected before manual runs', () => { + const automation = makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'devbox', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }) + const repo = makeRepo({ connectionId: 'devbox', executionHostId: 'ssh:devbox' }) + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'connected' }]]) + }).canRunNow + ).toBe(true) + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]]) + }).reason + ).toBe('ssh-unavailable') + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'auth-failed' }]]) + }).reason + ).toBe('ssh-auth-needed') + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'reconnecting' }]]) + }).reason + ).toBe('ssh-connecting') + }) + + it('blocks manual runs when the saved source account needs provider auth', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map(), + sourceHostAvailability: [{ hostId: 'local', reason: 'missing-provider-auth' }] + }) + ).toMatchObject({ + canRunNow: false, + reason: 'source-auth-needed', + message: 'Connect the saved GitHub source account before running manually.' + }) + }) + + it('blocks manual runs when the saved source host cannot support the provider', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: 'runtime:old-server', + repoId: 'repo-1', + providerIdentity: { + provider: 'gitlab', + projectId: 'stablyai/orca', + namespace: 'stablyai', + project: 'orca', + webUrl: 'https://gitlab.com/stablyai/orca' + } + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map(), + sourceHostAvailability: [ + { hostId: 'runtime:old-server', reason: 'missing-task-source-capability' } + ] + }) + ).toMatchObject({ + canRunNow: false, + reason: 'source-provider-unsupported', + message: 'The saved GitLab source is not supported on this automation host.' + }) + }) + + it('explains runtime-host automation availability before the unsupported manual-run fallback', () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'runtime:env-1', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }) + const repo = makeRepo({ executionHostId: 'runtime:env-1' }) + const setup = makeProjectHostSetup({ + hostId: 'runtime:env-1', + executionHostId: 'runtime:env-1' + }) + const base = { + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [setup], + sshConnectionStates: new Map() + } + + expect(getAutomationTargetAvailability(base).reason).toBe('runtime-checking') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([['env-1', { status: null, checkedAt: 1 }]]) + }).reason + ).toBe('runtime-unavailable') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus({ graphStatus: 'unavailable' }), checkedAt: 1 }] + ]) + }).message + ).toBe('The selected remote server is not ready to run automations yet.') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus({ runtimeProtocolVersion: 0 }), checkedAt: 1 }] + ]) + }).reason + ).toBe('runtime-update-required') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus(), checkedAt: 1 }] + ]) + }) + ).toMatchObject({ + reason: 'available', + message: null + }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts new file mode 100644 index 00000000000..d712117f897 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -0,0 +1,277 @@ +import type { Automation } from '../../../../shared/automations-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { + describeRuntimeCompatBlock, + evaluateRuntimeCompat +} from '../../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' + +export type AutomationTargetAvailability = + | { + canRunNow: true + reason: 'available' + message: null + } + | { + canRunNow: false + reason: + | 'missing-project' + | 'missing-project-host-setup' + | 'project-host-setup-not-ready' + | 'missing-workspace' + | 'host-mismatch' + | 'unsupported-host' + | 'runtime-checking' + | 'runtime-unavailable' + | 'runtime-update-required' + | 'ssh-auth-needed' + | 'ssh-unavailable' + | 'ssh-connecting' + | 'source-auth-needed' + | 'source-tool-unavailable' + | 'source-provider-unsupported' + | 'source-host-unavailable' + message: string + } + +type AutomationTargetAvailabilityArgs = { + automation: Automation + repo: Repo | null | undefined + workspace: Worktree | null | undefined + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>> + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + sourceHostAvailability?: readonly TaskSourceHostAvailability[] +} + +export function getAutomationTargetAvailability({ + automation, + repo, + workspace, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability +}: AutomationTargetAvailabilityArgs): AutomationTargetAvailability { + if (!repo) { + return unavailable('missing-project', 'The target project is no longer available.') + } + if (automation.runContext) { + const parsedHost = parseExecutionHostId(automation.runContext.hostId) + if (parsedHost?.kind === 'runtime') { + const runtimeAvailability = getRuntimeAutomationAvailability( + parsedHost.environmentId, + runtimeStatusByEnvironmentId + ) + if (!runtimeAvailability.canRunNow) { + return runtimeAvailability + } + } + const setup = projectHostSetups.find( + (candidate) => candidate.id === automation.runContext?.projectHostSetupId + ) + if (!setup) { + return unavailable( + 'missing-project-host-setup', + 'Project is not set up on the selected automation host anymore.' + ) + } + if (setup.setupState !== 'ready') { + return unavailable( + 'project-host-setup-not-ready', + `Project setup on the selected automation host is ${setup.setupState}.` + ) + } + if ( + setup.projectId !== automation.runContext.projectId || + setup.hostId !== automation.runContext.hostId || + setup.repoId !== automation.runContext.repoId || + setup.path !== automation.runContext.path || + automation.runContext.repoId !== repo.id || + automation.runContext.path !== repo.path || + automation.runContext.hostId !== getRepoExecutionHostId(repo) + ) { + return unavailable( + 'host-mismatch', + 'The saved run host no longer matches this project setup.' + ) + } + } + if (automation.workspaceMode === 'existing' && !workspace) { + return unavailable('missing-workspace', 'The target workspace is no longer available.') + } + + const sourceAvailability = getAutomationSourceAvailability( + automation.sourceContext, + sourceHostAvailability + ) + if (sourceAvailability) { + return sourceAvailability + } + + const sshTargetId = getAutomationSshTargetId(automation, repo) + if (!sshTargetId) { + return { canRunNow: true, reason: 'available', message: null } + } + + const status = sshConnectionStates.get(sshTargetId)?.status ?? 'disconnected' + switch (status) { + case 'connected': + return { canRunNow: true, reason: 'available', message: null } + case 'auth-failed': + case 'reconnection-failed': + return unavailable('ssh-auth-needed', 'Connect this SSH host before running manually.') + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return unavailable('ssh-connecting', 'This SSH host is still connecting.') + case 'disconnected': + case 'error': + return unavailable('ssh-unavailable', 'Connect this SSH host before running manually.') + } +} + +function getAutomationSourceAvailability( + sourceContext: TaskSourceContext | null | undefined, + sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined +): AutomationTargetAvailability | null { + if (!sourceContext) { + return null + } + const availability = sourceHostAvailability?.find( + (entry) => entry.hostId === sourceContext.hostId + ) + if (!availability) { + return null + } + const providerLabel = getAutomationSourceProviderLabel(sourceContext.provider) + switch (availability.reason) { + case 'missing-provider-auth': + return unavailable( + 'source-auth-needed', + `Connect the saved ${providerLabel} source account before running manually.` + ) + case 'unavailable-source-tool': + return unavailable( + 'source-tool-unavailable', + `Install or configure the ${providerLabel} source tool before running manually.` + ) + case 'unsupported-provider': + case 'missing-task-source-capability': + return unavailable( + 'source-provider-unsupported', + `The saved ${providerLabel} source is not supported on this automation host.` + ) + case 'checking-task-source-capability': + return unavailable( + 'source-host-unavailable', + `Checking the saved ${providerLabel} source host before running manually.` + ) + } + if ( + availability.health === 'disconnected' || + availability.health === 'blocked' || + availability.health === 'error' || + availability.status === 'disconnected' || + availability.status === 'auth-failed' || + availability.status === 'reconnection-failed' || + availability.status === 'error' + ) { + return unavailable( + 'source-host-unavailable', + `Reconnect the saved ${providerLabel} source host before running manually.` + ) + } + if ( + availability.health === 'connecting' || + availability.status === 'connecting' || + availability.status === 'deploying-relay' || + availability.status === 'reconnecting' + ) { + return unavailable( + 'source-host-unavailable', + `The saved ${providerLabel} source host is still connecting.` + ) + } + return null +} + +function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider']): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'linear': + return 'Linear' + case 'jira': + return 'Jira' + } +} + +function getRuntimeAutomationAvailability( + environmentId: string, + runtimeStatusByEnvironmentId: + | ReadonlyMap<string, { status: RuntimeStatus | null; checkedAt: number }> + | undefined +): AutomationTargetAvailability { + const entry = runtimeStatusByEnvironmentId?.get(environmentId) + if (!entry) { + return unavailable( + 'runtime-checking', + 'Checking the selected remote server before running manually.' + ) + } + if (!entry.status) { + return unavailable( + 'runtime-unavailable', + 'Reconnect this remote server before running manually.' + ) + } + if (entry.status.graphStatus !== 'ready') { + return unavailable( + 'runtime-unavailable', + 'The selected remote server is not ready to run automations yet.' + ) + } + const compat = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: entry.status.runtimeProtocolVersion ?? entry.status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + entry.status.minCompatibleRuntimeClientVersion ?? entry.status.minCompatibleMobileVersion + }) + if (compat.kind === 'blocked') { + return unavailable('runtime-update-required', describeRuntimeCompatBlock(compat)) + } + return { canRunNow: true, reason: 'available', message: null } +} + +function getAutomationSshTargetId(automation: Automation, repo: Repo): string | null { + const parsedHost = parseExecutionHostId(automation.runContext?.hostId) + if (parsedHost?.kind === 'ssh') { + return parsedHost.targetId + } + if (automation.executionTargetType === 'ssh' && automation.executionTargetId.trim()) { + return automation.executionTargetId + } + return repo.connectionId?.trim() || null +} + +function unavailable( + reason: Exclude<AutomationTargetAvailability['reason'], 'available'>, + message: string +): AutomationTargetAvailability { + return { canRunNow: false, reason, message } +} diff --git a/src/renderer/src/components/automations/external-automation-source-availability.test.ts b/src/renderer/src/components/automations/external-automation-source-availability.test.ts new file mode 100644 index 00000000000..37f895ea6fe --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-source-availability.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import type { ExternalAutomationManager } from '../../../../shared/automations-types' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' + +function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager { + return { + id: 'hermes-local', + provider: 'hermes', + label: 'Hermes', + targetLabel: 'Local Mac', + target: { type: 'local' }, + status: 'unavailable', + error: null, + canManage: false, + jobs: [], + ...overrides + } +} + +describe('external automation source availability', () => { + it('uses local repair copy for unavailable local sources', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager(), + providerLabel: 'Hermes', + targetKindLabel: 'Local' + }) + ).toMatchObject({ + statusLabel: 'Source unavailable', + summary: 'Hermes source unavailable on local.', + detail: 'Install or repair the local automation source, then retry to load jobs.', + canConnectSsh: false, + isConnecting: false + }) + }) + + it('asks users to connect disconnected SSH sources before checking jobs', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + id: 'hermes-devbox', + targetLabel: 'Devbox', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'disconnected' + }) + ).toMatchObject({ + statusLabel: 'Connect SSH', + summary: 'Hermes source unavailable until ssh host connects.', + detail: 'Connect this SSH host to check for remote automation jobs.', + canConnectSsh: true, + isConnecting: false + }) + }) + + it('distinguishes connected SSH hosts with missing remote automation tooling', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + id: 'hermes-devbox', + targetLabel: 'Devbox', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'connected' + }) + ).toMatchObject({ + statusLabel: 'Source unavailable', + summary: 'Hermes source unavailable on this ssh host.', + detail: 'Install or repair the remote automation source, then retry to load jobs.', + canConnectSsh: true, + isConnecting: false + }) + }) + + it('preserves manager errors while still reporting a connecting SSH state', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + error: 'Hermes binary was not found.', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'connected', + isConnectingOverride: true + }) + ).toMatchObject({ + statusLabel: 'Connecting...', + summary: 'Hermes binary was not found.', + detail: 'Waiting for this SSH host before checking the remote automation source.', + canConnectSsh: true, + isConnecting: true + }) + }) + + it('explains disabled local automation actions when the source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ error: 'Hermes jobs were found, but the hermes CLI is not on PATH.' }) + }) + ).toBe('Hermes jobs were found, but the hermes CLI is not on PATH.') + }) + + it('explains disabled SSH automation actions before the host is connected', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'disconnected' + }) + ).toBe('Connect this ssh host before managing Hermes automations.') + }) + + it('explains disabled SSH automation actions while the host is connecting', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + targetKindLabel: 'SSH host', + sshStatus: 'deploying-relay' + }) + ).toBe('Wait for this ssh host to finish connecting.') + }) + + it('explains disabled SSH automation actions when the remote source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }), + sshStatus: 'connected' + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('keeps concrete remote source errors when SSH status is unavailable to the caller', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }) + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('explains disabled actions while another automation action is running', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ canManage: true }), + actionInProgress: true + }) + ).toBe('Another automation action is still running.') + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts new file mode 100644 index 00000000000..92f0749f892 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -0,0 +1,124 @@ +import type { + ExternalAutomationManager, + ExternalAutomationProvider +} from '../../../../shared/automations-types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +export type ExternalAutomationSourceAvailability = { + statusLabel: string + summary: string + detail: string + canConnectSsh: boolean + isConnecting: boolean +} + +type ExternalAutomationSourceAvailabilityArgs = { + manager: ExternalAutomationManager + providerLabel: string + targetKindLabel: string + sshStatus?: SshConnectionStatus + isConnectingOverride?: boolean +} + +export function getExternalAutomationSourceAvailability({ + manager, + providerLabel, + targetKindLabel, + sshStatus, + isConnectingOverride = false +}: ExternalAutomationSourceAvailabilityArgs): ExternalAutomationSourceAvailability { + if (manager.target.type === 'ssh') { + const isConnecting = isConnectingOverride || isSshConnectionBusy(sshStatus) + if (isConnecting) { + return { + statusLabel: 'Connecting...', + summary: + manager.error ?? + `${providerLabel} source unavailable while ${targetKindLabel.toLowerCase()} connects.`, + detail: 'Waiting for this SSH host before checking the remote automation source.', + canConnectSsh: true, + isConnecting: true + } + } + + if (sshStatus === 'connected') { + return { + statusLabel: 'Source unavailable', + summary: + manager.error ?? + `${providerLabel} source unavailable on this ${targetKindLabel.toLowerCase()}.`, + detail: 'Install or repair the remote automation source, then retry to load jobs.', + canConnectSsh: true, + isConnecting: false + } + } + + return { + statusLabel: 'Connect SSH', + summary: + manager.error ?? + `${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.`, + detail: 'Connect this SSH host to check for remote automation jobs.', + canConnectSsh: true, + isConnecting: false + } + } + + return { + statusLabel: 'Source unavailable', + summary: + manager.error ?? `${providerLabel} source unavailable on ${targetKindLabel.toLowerCase()}.`, + detail: 'Install or repair the local automation source, then retry to load jobs.', + canConnectSsh: false, + isConnecting: false + } +} + +export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { + return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' +} + +export function getExternalAutomationActionDisabledMessage(args: { + manager: ExternalAutomationManager + providerLabel?: string + targetKindLabel?: string + sshStatus?: SshConnectionStatus + actionInProgress?: boolean +}): string | null { + if (args.actionInProgress) { + return 'Another automation action is still running.' + } + if (args.manager.canManage) { + return null + } + const providerLabel = args.providerLabel ?? getProviderLabel(args.manager.provider) + const targetKindLabel = + args.targetKindLabel ?? (args.manager.target.type === 'ssh' ? 'SSH host' : 'Local') + if (args.manager.target.type === 'ssh') { + if (isSshConnectionBusy(args.sshStatus)) { + return `Wait for this ${targetKindLabel.toLowerCase()} to finish connecting.` + } + if (args.manager.error && !isSshDisconnectedError(args.manager.error)) { + return args.manager.error + } + if (args.sshStatus !== 'connected') { + return `Connect this ${targetKindLabel.toLowerCase()} before managing ${providerLabel} automations.` + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) +} + +function getProviderLabel(provider: ExternalAutomationProvider): string { + return provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +function isSshDisconnectedError(message: string): boolean { + return /ssh target is not connected/i.test(message) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a16377b9f68..abd7e1687d8 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -52,6 +52,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Label } from '@/components/ui/label' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import type { BrowserLoadError, @@ -258,6 +259,16 @@ type RemoteBrowserViewportSize = { height: number } +function getBrowserPageRuntimeEnvironmentId( + page: BrowserPageState, + inferredRuntimeEnvironmentId: string | null | undefined +): string | null { + if (page.browserRuntimeEnvironmentId !== undefined) { + return page.browserRuntimeEnvironmentId?.trim() || null + } + return inferredRuntimeEnvironmentId?.trim() || null +} + type RemoteBrowserImagePoint = { x: number y: number @@ -735,8 +746,8 @@ export default function BrowserPane({ browserTab: BrowserWorkspaceState isActive: boolean }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, browserTab.worktreeId) ) const browserPages = useAppStore((s) => getBrowserPagesForWorkspace(s.browserPagesByWorkspace, browserTab.id) @@ -745,14 +756,19 @@ export default function BrowserPane({ browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState) const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) - const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) + const activeBrowserRuntimeEnvironmentId = activeBrowserPage + ? getBrowserPageRuntimeEnvironmentId(activeBrowserPage, activeRuntimeEnvironmentId) + : null + const runtimeEnvironmentActive = Boolean(activeBrowserRuntimeEnvironmentId) const activeBrowserPageId = activeBrowserPage?.id ?? null const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages]) const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds) // Why: inactive Electron webviews must stay mounted in their original DOM // parent. Parking them by unmounting/reparenting loses form text and SPA // state on normal tab switches. - const renderedBrowserPages = browserPages + const renderedBrowserPages = browserPages.filter( + (page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId) + ) const [activeBrowserDriver, setActiveBrowserDriver] = useState<BrowserDriverState>({ kind: 'idle' }) @@ -762,9 +778,11 @@ export default function BrowserPane({ return } for (const page of browserPages) { - destroyPersistentWebview(page.id) + if (getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)) { + destroyPersistentWebview(page.id) + } } - }, [browserPages, runtimeEnvironmentActive]) + }, [activeRuntimeEnvironmentId, browserPages, runtimeEnvironmentActive]) useEffect(() => { if (runtimeEnvironmentActive || !activeBrowserPageId) { @@ -792,11 +810,12 @@ export default function BrowserPane({ await window.api.runtime.reclaimBrowserForDesktop(activeBrowserPageId) }, [activeBrowserPageId]) - if (runtimeEnvironmentActive) { + if (activeBrowserRuntimeEnvironmentId) { return activeBrowserPage ? ( <RemoteBrowserPagePane - key={`${activeRuntimeEnvironmentId?.trim() ?? ''}:${activeBrowserPage.id}`} + key={`${activeBrowserRuntimeEnvironmentId ?? ''}:${activeBrowserPage.id}`} browserTab={activeBrowserPage} + runtimeEnvironmentId={activeBrowserRuntimeEnvironmentId} worktreeId={browserTab.worktreeId} isActive={isActive} onUpdatePageState={updateBrowserPageState} @@ -837,18 +856,20 @@ export default function BrowserPane({ function RemoteBrowserPagePane({ browserTab, + runtimeEnvironmentId, worktreeId, isActive, onUpdatePageState, onSetUrl }: { browserTab: BrowserPageState + runtimeEnvironmentId: string worktreeId: string isActive: boolean onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void onSetUrl: (tabId: string, url: string) => void }): React.JSX.Element { - const settings = useAppStore((s) => s.settings) + const activeRuntimeEnvironmentId = runtimeEnvironmentId const addressBarInputRef = useRef<HTMLInputElement | null>(null) const imageRef = useRef<HTMLImageElement | null>(null) const remoteViewportRef = useRef<HTMLDivElement | null>(null) @@ -881,7 +902,6 @@ function RemoteBrowserPagePane({ const currentBrowserTabIdRef = useRef(browserTab.id) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() ?? null const activeRuntimeEnvironmentIdRef = useRef<string | null>(activeRuntimeEnvironmentId) const startRemoteStreamRef = useRef< (pageId: string) => Promise<RemoteBrowserStreamSubscription | null> @@ -1249,7 +1269,7 @@ function RemoteBrowserPagePane({ return } const state = useAppStore.getState() - const currentEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() ?? null + const currentEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) const pageStillExists = browserPageExists(browserTab.id) if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { return @@ -1268,7 +1288,7 @@ function RemoteBrowserPagePane({ { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } - }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree]) + }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree, worktreeId]) const applyRemoteTabInfo = useCallback( (tab: Pick<BrowserTabInfo, 'url' | 'title'>): void => { diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts new file mode 100644 index 00000000000..538939b8a4a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { getPaletteHostBadge } from './palette-host-badge' +import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' + +describe('getPaletteHostBadge', () => { + it('returns null for single-host (local-only) workspaces', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: null }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + }) + + it('badges the local host when multiple hosts exist', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({ + hostId: 'local', + label: 'Local Mac' + }) + }) + + it('uses the ssh target label for ssh repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: 'ssh-1' }, hosts)).toEqual({ + hostId: 'ssh:ssh-1', + label: 'Builder' + }) + }) + + it('badges runtime-hosted repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ executionHostId: 'runtime:env-1' }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'env-2' } + }) + + expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts)).toEqual({ + hostId: 'runtime:env-1', + label: 'env-1' + }) + }) + + it('maps repos with no executionHostId/connectionId to local', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({}, hosts)).toEqual({ + hostId: 'local', + label: 'Local Mac' + }) + }) + + it('returns null when the repo is missing', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge(null, hosts)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts new file mode 100644 index 00000000000..fb42c9c63a3 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -0,0 +1,28 @@ +import type { Repo } from '../../../../shared/types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + shouldShowHostScopeControls, + type SidebarHostOption +} from '../sidebar/sidebar-host-options' + +export type PaletteHostBadge = { + hostId: ExecutionHostId + label: string +} + +// Why: Cmd+J results only need a host label when more than one host exists; a +// local-only user gets no badge at all, so single-host UIs stay unchanged. +export function getPaletteHostBadge( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | null | undefined, + hostOptions: readonly SidebarHostOption[] +): PaletteHostBadge | null { + if (!repo || !shouldShowHostScopeControls(hostOptions)) { + return null + } + const hostId = getRepoExecutionHostId(repo) + const host = hostOptions.find((option) => option.id === hostId) + if (!host) { + return null + } + return { hostId, label: host.label } +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 200d492420e..bdaedb84776 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -107,12 +107,20 @@ const sections: SettingsNavSection[] = [ searchEntries: [{ title: 'Default Browser URL' }], group: 'workflows' }, + { + id: 'servers', + title: 'Remote Orca Servers', + description: 'Pair remote Orca runtimes.', + icon: Settings, + searchEntries: [{ title: 'Remote Orca Servers' }], + group: 'remote' + }, { id: 'ssh', title: 'SSH Hosts', - description: 'Remote hosts.', + description: 'Remote hosts over SSH.', icon: Settings, - searchEntries: [{ title: 'Remote Shell' }], + searchEntries: [{ title: 'SSH Connections' }], group: 'remote' }, { diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 5d5e170d8a8..d67975db4a8 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -9,6 +9,7 @@ import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHostSync } from '@/lib/workspace-session-host-persistence' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -285,7 +286,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { // Why: restart/update may quit before the debounced session writer fires. // Write the full session now so dirty drafts restore as unsaved tabs. if (shouldPersistWorkspaceSession(state)) { - window.api.session.setSync(buildWorkspaceSessionPayload(state)) + // Why: runtime-owned worktree slices persist under their host + // partition, mirroring the debounced writer's split. + persistWorkspaceSessionByHostSync( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) } detail.resolve() } catch (error) { diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index d8767abbcf4..a890bce3bb7 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -77,6 +77,26 @@ describe('feature interaction writer boundaries', () => { } }) + it('threads GitHub task source context through inline task mutations', () => { + const source = componentSource('TaskPage.tsx') + const sections = [ + sourceBetween(source, 'function GHStatusCell', 'function GitHubAssigneeAvatar'), + sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='), + sourceBetween(source, 'function PRReviewCell', 'function PRChecksCell'), + componentBodyBeforeRender(source, 'PRMergeCell'), + sourceBetween(source, 'const handleCreateNewIssue', 'const handleCreateNewLinearProject') + ] + + for (const section of sections) { + expect(section).toContain('sourceContext') + } + const rowRenderStart = source.indexOf('filteredWorkItems.map((item) => {') + expect(rowRenderStart).toBeGreaterThanOrEqual(0) + expect(source.slice(rowRenderStart, rowRenderStart + 12_000)).toContain( + 'sourceContext={getTaskPageRepoSourceContext(itemRepo,' + ) + }) + it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { const source = componentSource('TaskPage.tsx') const suppression = 'recordTasksInteraction: false' @@ -91,9 +111,9 @@ describe('feature interaction writer boundaries', () => { sourceBetween(source, 'taskSourceManuallyChangedRef.current = true', 'void updateSettings') ] - expect(githubDetailSection).toContain("recordFeatureInteraction('github-tasks')") - expect(githubDetailSection).toContain('setDialogWorkItem(item, initialTab)') - expect(githubDetailSection).not.toContain('openTaskPage') + expect(githubDetailSection).toContain('openGitHubSourceContext') + expect(githubDetailSection).toContain('openTaskPage') + expect(githubDetailSection).toContain(suppression) for (const section of inPageNavigationSections) { expect(section).toContain(suppression) @@ -189,6 +209,15 @@ describe('feature interaction writer boundaries', () => { } }) + it('records Jira provider-depth for workspace use', () => { + const taskPageSource = componentSource('TaskPage.tsx') + const jiraWriter = "recordFeatureInteraction('jira-tasks')" + + expect( + sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect') + ).toContain(jiraWriter) + }) + it('records browser annotation agent handoff only from the prompt-delivered callback', () => { const source = componentSource('browser-pane/BrowserPane.tsx') expect( diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts new file mode 100644 index 00000000000..1e70364f09c --- /dev/null +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('GitHubItemDialog source host boundaries', () => { + it('routes reviewer metadata and reviewer mutations through the task source context', () => { + const source = componentSource('GitHubItemDialog.tsx') + const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + + expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('sourceSettings') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('sourceSettings') + expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') + }) + + it('routes edit metadata through the same task source as issue mutations', () => { + const source = componentSource('GitHubItemDialog.tsx') + const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace') + + expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(section).toContain('useRepoLabels(') + expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('sourceSettings') + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 0f916be3ae7..b9642d0c2e8 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -4,7 +4,8 @@ // built-in ASSIGNEES/LABELS cells render their dedicated content) and fall // through to `fieldValuesByFieldId[field.id].kind` as a safety net so a // fetched value is never silently dropped. -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { CircleDot, FileText, GitPullRequest, Lock, Plus } from 'lucide-react' import { TYPE_FIELD_DATA_TYPE } from './columns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -13,6 +14,8 @@ import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useRepoSlugIndex } from '@/lib/repo-slug-index' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import type { GitHubIssueType, GitHubProjectField, @@ -22,6 +25,7 @@ import type { GitHubProjectUser, ListIssueTypesBySlugResult } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type Props = { @@ -39,6 +43,7 @@ type Props = { onEditLabels?: (add: string[], remove: string[]) => void onEditIssueType?: (issueType: GitHubIssueType | null) => void onOpenDialog?: () => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectCell({ @@ -49,7 +54,8 @@ export default function ProjectCell({ onEditAssignees, onEditLabels, onEditIssueType, - onOpenDialog + onOpenDialog, + sourceSettings }: Props): React.JSX.Element { const value = row.fieldValuesByFieldId[field.id] const isRedacted = row.itemType === 'REDACTED' @@ -60,15 +66,36 @@ export default function ProjectCell({ } if (field.dataType === TYPE_FIELD_DATA_TYPE) { const editableHere = editable && !isRedacted && row.itemType === 'ISSUE' - return <TypeCell row={row} editable={editableHere} onEditIssueType={onEditIssueType} /> + return ( + <TypeCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditIssueType={onEditIssueType} + /> + ) } if (field.dataType === 'ASSIGNEES') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return <AssigneesCell row={row} editable={editableHere} onEditAssignees={onEditAssignees} /> + return ( + <AssigneesCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditAssignees={onEditAssignees} + /> + ) } if (field.dataType === 'LABELS') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return <LabelsCell row={row} editable={editableHere} onEditLabels={onEditLabels} /> + return ( + <LabelsCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditLabels={onEditLabels} + /> + ) } if (field.dataType === 'REPOSITORY') { return ( @@ -236,17 +263,26 @@ function TitleCell({ function TypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { // Why: for issues we surface the repo's `issueType` (Bug/Feature/Task etc) // when set — that's the editable taxonomy. PR/Draft/Restricted rows render // the static itemType glyph because there's no equivalent editable type. if (row.itemType === 'ISSUE') { - return <IssueTypeCell row={row} editable={editable} onEditIssueType={onEditIssueType} /> + return ( + <IssueTypeCell + row={row} + editable={editable} + sourceSettings={sourceSettings} + onEditIssueType={onEditIssueType} + /> + ) } const meta = row.itemType === 'PULL_REQUEST' @@ -275,18 +311,27 @@ function TypeCell({ function IssueTypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { const issueType = row.content.issueType const [open, setOpen] = useState(false) const [options, setOptions] = useState<GitHubIssueType[]>([]) const [loading, setLoading] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') + const { lookupSlug } = useRepoSlugIndex() + const matchedRepo = useMemo( + () => lookupSlug(row.content.repository)[0] ?? null, + [lookupSlug, row.content.repository] + ) + const ownerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, matchedRepo?.id ?? null)) + ) React.useEffect(() => { if (!open || !owner || !repo) { @@ -294,7 +339,7 @@ function IssueTypeCell({ } let cancelled = false setLoading(true) - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : sourceSettings) const request = target.kind === 'environment' ? callRuntimeRpc<ListIssueTypesBySlugResult>( @@ -321,7 +366,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [open, owner, repo, settings]) + }, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings]) const trigger = ( <span className="inline-flex items-center gap-1 text-xs"> @@ -773,15 +818,16 @@ function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element { function AssigneesCell({ row, editable, + sourceSettings, onEditAssignees }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditAssignees?: (add: string[], remove: string[]) => void }): React.JSX.Element { const assignees = row.content.assignees const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') @@ -803,7 +849,7 @@ function AssigneesCell({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - settings + sourceSettings ) const labelContent = @@ -887,18 +933,19 @@ function AssigneesCell({ function LabelsCell({ row, editable, + sourceSettings, onEditLabels }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditLabels?: (add: string[], remove: string[]) => void }): React.JSX.Element { const labels = row.content.labels const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') - const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings) + const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings) const labelContent = labels.length === 0 ? null : labels.map((l) => <LabelChip key={l.name} label={l} />) diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx index 51db853fc1b..2b778e29c10 100644 --- a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -12,15 +12,18 @@ import { VisuallyHidden } from 'radix-ui' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { SlugDialogBody } from './slug-dialog/SlugDialogBody' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type Props = { projectOrigin: GitHubItemDialogProjectOrigin | null + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onClose: () => void } export default function ProjectItemSlugDialog({ projectOrigin, + sourceSettings, onClose }: Props): React.JSX.Element { const open = projectOrigin !== null @@ -49,7 +52,13 @@ export default function ProjectItemSlugDialog({ )} </SheetDescription> </VisuallyHidden.Root> - {projectOrigin ? <SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} /> : null} + {projectOrigin ? ( + <SlugDialogBody + projectOrigin={projectOrigin} + sourceSettings={sourceSettings} + onClose={onClose} + /> + ) : null} </SheetContent> </Sheet> ) diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index c77ce48948e..40ecdb2f9bf 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -44,11 +44,20 @@ type Props = { } const BROWSE_CACHE_TTL_MS = 5 * 60_000 -let browseCache: { +type BrowseCacheEntry = { fetchedAt: number projects: GitHubProjectSummary[] partialFailures?: { owner: string; message: string }[] -} | null = null +} + +const browseCacheByRuntimeScope = new Map<string, BrowseCacheEntry>() + +function getProjectPickerRuntimeScope( + settings: Parameters<typeof getActiveRuntimeTarget>[0] +): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} async function listAccessibleProjectsForRuntime( settings: Parameters<typeof getActiveRuntimeTarget>[0] @@ -110,6 +119,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [query, setQuery] = useState('') const [browseLoading, setBrowseLoading] = useState(false) const [browseError, setBrowseError] = useState<GitHubProjectViewError | null>(null) + const browseCache = browseCacheByRuntimeScope.get(getProjectPickerRuntimeScope(settings)) const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>( () => browseCache?.projects ?? [] ) @@ -130,9 +140,11 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [viewLoading, setViewLoading] = useState(false) const loadBrowse = useCallback(async () => { - if (browseCache && Date.now() - browseCache.fetchedAt < BROWSE_CACHE_TTL_MS) { - setBrowseProjects(browseCache.projects) - setPartialFailures(browseCache.partialFailures ?? []) + const cacheKey = getProjectPickerRuntimeScope(settings) + const cached = browseCacheByRuntimeScope.get(cacheKey) ?? null + if (cached && Date.now() - cached.fetchedAt < BROWSE_CACHE_TTL_MS) { + setBrowseProjects(cached.projects) + setPartialFailures(cached.partialFailures ?? []) return } setBrowseLoading(true) @@ -140,11 +152,11 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React try { const res = await listAccessibleProjectsForRuntime(settings) if (res.ok) { - browseCache = { + browseCacheByRuntimeScope.set(cacheKey, { fetchedAt: Date.now(), projects: res.projects, partialFailures: res.partialFailures - } + }) if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 1513f275da9..55e01e52f99 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -12,6 +12,7 @@ import type { GitHubProjectFieldMutationValue, GitHubProjectRow as GitHubProjectRowType } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' const PROJECT_FROZEN_COLUMN_SURFACE_CLASS = @@ -33,6 +34,7 @@ type Props = { onEditIssueType?: (issueType: GitHubIssueType | null) => void onStartWork?: () => void onOpenInBrowser?: () => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectRow({ @@ -48,7 +50,8 @@ export default function ProjectRow({ onEditLabels, onEditIssueType, onStartWork, - onOpenInBrowser + onOpenInBrowser, + sourceSettings }: Props): React.JSX.Element { const disabled = row.itemType === 'REDACTED' // Why: design doc §Row actions — draft-issue rows have no URL or number, so @@ -97,6 +100,7 @@ export default function ProjectRow({ onEditLabels={onEditLabels} onEditIssueType={onEditIssueType} onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined} + sourceSettings={sourceSettings} /> </div> {next ? ( diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index ebaaba0b7c4..76752d3c314 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -22,6 +22,7 @@ import type { GitHubProjectSortDirection, GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection } @@ -57,6 +58,7 @@ type Props = { onEditIssueType?: (row: GitHubProjectRow, issueType: GitHubIssueType | null) => void onStartWork?: (row: GitHubProjectRow) => void onOpenInBrowser?: (row: GitHubProjectRow) => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectViewList({ @@ -67,7 +69,8 @@ export default function ProjectViewList({ onEditLabels, onEditIssueType, onStartWork, - onOpenInBrowser + onOpenInBrowser, + sourceSettings }: Props): React.JSX.Element { const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set()) // Why: column-header clicks override the view's saved sortByFields locally @@ -255,6 +258,7 @@ export default function ProjectViewList({ onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)} onStartWork={() => onStartWork?.(row)} onOpenInBrowser={() => onOpenInBrowser?.(row)} + sourceSettings={sourceSettings} /> )) : null} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index d1c6dcd9a2e..c46d8120aba 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -75,6 +75,11 @@ function listProjectViewsForRuntime( : window.api.gh.listProjectViews(args) } +function getProjectViewSourceScope(settings: Parameters<typeof getActiveRuntimeTarget>[0]): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} + export default function ProjectViewWrapper(_props: Props = {} as Props): React.JSX.Element { const settings = useAppStore((s) => s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) @@ -89,6 +94,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J const mountedRef = useMountedRef() const activeProject = settings?.githubProjects?.activeProject ?? null + const projectViewSourceScope = useMemo(() => getProjectViewSourceScope(settings), [settings]) const lastViewByProject = useMemo( () => settings?.githubProjects?.lastViewByProject ?? {}, [settings?.githubProjects?.lastViewByProject] @@ -172,14 +178,15 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!viewId) { return } - const projectViewKey = `${key}:${viewId}` + const projectViewKey = `${projectViewSourceScope}:${key}:${viewId}` const queryOverride = appliedQueryByView[projectViewKey] const cacheKey = projectViewCacheKey( activeProject.ownerType, activeProject.owner, activeProject.number, viewId, - queryOverride + queryOverride, + projectViewSourceScope ) if (projectViewCache[cacheKey]?.data) { return @@ -194,7 +201,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J false, queryOverride ) - }, [activeProject, lastViewByProject, projectViewCache, doFetch, appliedQueryByView]) + }, [ + activeProject, + lastViewByProject, + projectViewCache, + doFetch, + appliedQueryByView, + projectViewSourceScope + ]) // Load the project's view list whenever the active project changes so the // tab strip can render. The list is small and rarely changes — fetched once @@ -203,7 +217,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!activeProject) { return } - const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = `${projectViewSourceScope}:${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` if (viewListByProject[projectKey]) { return } @@ -234,7 +248,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J return () => { cancelled = true } - }, [activeProject, viewListByProject, settings]) + }, [activeProject, viewListByProject, settings, projectViewSourceScope]) const handleSwitchView = useCallback( async (viewId: string) => { @@ -286,8 +300,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!viewId) { return null } - return `${key}:${viewId}` - }, [activeProject, lastViewByProject]) + return `${projectViewSourceScope}:${key}:${viewId}` + }, [activeProject, lastViewByProject, projectViewSourceScope]) const currentAppliedOverride = currentProjectViewKey ? appliedQueryByView[currentProjectViewKey] @@ -307,9 +321,10 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J activeProject.owner, activeProject.number, viewId, - currentAppliedOverride + currentAppliedOverride, + projectViewSourceScope ) - }, [activeProject, lastViewByProject, currentAppliedOverride]) + }, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope]) const table: GitHubProjectTable | null = currentCacheKey ? (projectViewCache[currentCacheKey]?.data ?? null) @@ -746,7 +761,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J {activeProject ? (() => { const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` - const views = viewListByProject[projectKey] ?? [] + const scopedProjectKey = `${projectViewSourceScope}:${projectKey}` + const views = viewListByProject[scopedProjectKey] ?? [] const activeViewId = lastViewByProject[projectKey]?.viewId ?? null return ( <ViewTabStrip @@ -791,6 +807,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }} onStartWork={handleStartWork} + sourceSettings={settings} /> ) : null} @@ -831,6 +848,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J button here would only confuse the user. */} <ProjectItemSlugDialog projectOrigin={resolvedMissingRepoDialogs.slugDialog?.origin ?? null} + sourceSettings={settings} onClose={() => setSlugDialog(null)} /> diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index 1b9e3a2b028..ded043e3d68 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { useAppStore } from '@/store' +import type { GlobalSettings } from '../../../../../shared/types' import { translate } from '@/i18n/i18n' export function AssigneesEditor({ @@ -10,16 +10,17 @@ export function AssigneesEditor({ repo, selected, disabled, + sourceSettings, onChange }: { owner: string repo: string selected: string[] disabled?: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (add: string[], remove: string[]) => void | Promise<void> }): React.JSX.Element { const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) // Why: stabilize the assignee seed identity. `selected` is a fresh array on // every parent render — depending on it directly would refire the IPC for // every unrelated re-render while the popover is open. @@ -28,7 +29,7 @@ export function AssigneesEditor({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - settings + sourceSettings ) return ( <Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index 4a9186657e3..6b553ba797c 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -1,33 +1,53 @@ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { Send } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' -import type { PRComment } from '../../../../../shared/types' +import { useRepoSlugIndex } from '@/lib/repo-slug-index' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import type { GlobalSettings, PRComment } from '../../../../../shared/types' import type { GitHubProjectCommentMutationResult, GitHubProjectMutationResult } from '../../../../../shared/github-project-types' import { translate } from '@/i18n/i18n' -function getRuntimeTarget() { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) +function getRuntimeTarget(settings: Parameters<typeof getActiveRuntimeTarget>[0]) { + const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? target : null } +function useRuntimeSettingsForSlug(owner: string, repo: string) { + const { lookupSlug } = useRepoSlugIndex() + const matchedRepo = useMemo( + () => lookupSlug(`${owner}/${repo}`)[0] ?? null, + [lookupSlug, owner, repo] + ) + return useAppStore( + useShallow((s) => + matchedRepo ? getSettingsForRepoRuntimeOwner(s, matchedRepo.id) : s.settings + ) + ) +} + export function CommentsList({ owner, repo, comments, + sourceSettings, onChange }: { owner: string repo: string comments: PRComment[] + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (next: PRComment[]) => void }): React.JSX.Element { + const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo) + const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings return ( <div className="flex flex-col gap-3"> {comments.length === 0 ? ( @@ -45,7 +65,7 @@ export function CommentsList({ repo={repo} comment={c} onDelete={async () => { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, @@ -66,7 +86,7 @@ export function CommentsList({ onChange(comments.filter((x) => x.id !== c.id)) }} onEdit={async (next) => { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, @@ -164,15 +184,19 @@ export function NewCommentForm({ owner, repo, number, + sourceSettings, onAdded }: { owner: string repo: string number: number + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onAdded: (c: PRComment) => void }): React.JSX.Element { const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) + const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo) + const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings return ( <div className="flex flex-col gap-2"> <textarea @@ -195,7 +219,7 @@ export function NewCommentForm({ } setSubmitting(true) try { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, number, body } const res = target ? await callRuntimeRpc<GitHubProjectCommentMutationResult>( diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index a2e64a8e968..1c87fc3fd28 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' -import { useAppStore } from '@/store' +import type { GlobalSettings } from '../../../../../shared/types' import { translate } from '@/i18n/i18n' export function LabelsEditor({ @@ -10,17 +10,18 @@ export function LabelsEditor({ repo, selected, disabled, + sourceSettings, onChange }: { owner: string repo: string selected: string[] disabled?: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (add: string[], remove: string[]) => void | Promise<void> }): React.JSX.Element { const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) - const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings) + const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings) return ( <Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}> <PopoverTrigger asChild> diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index 29534bfdccb..ad7f9b5eb62 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubWorkItemDetails } from '../../../../../shared/types' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' +import type { GlobalSettings } from '../../../../../shared/types' import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' import { CommentsList, NewCommentForm } from './Comments' @@ -15,9 +17,11 @@ import { translate } from '@/i18n/i18n' export function SlugDialogBody({ projectOrigin, + sourceSettings, onClose }: { projectOrigin: GitHubItemDialogProjectOrigin + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onClose: () => void }): React.JSX.Element { const { owner, repo, number, type, cacheKey } = projectOrigin @@ -52,8 +56,19 @@ export function SlugDialogBody({ setLoading(true) setError(null) setDetails(null) - window.api.gh - .projectWorkItemDetailsBySlug({ owner, repo, number, type }) + const target = getActiveRuntimeTarget(sourceSettings) + const request = + target.kind === 'environment' + ? callRuntimeRpc< + { ok: true; details: GitHubWorkItemDetails } | { ok: false; error: { message: string } } + >( + target, + 'github.project.workItemDetailsBySlug', + { owner, repo, number, type }, + { timeoutMs: 30_000 } + ) + : window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, number, type }) + request .then((res) => { if (rid !== requestIdRef.current) { return @@ -76,7 +91,7 @@ export function SlugDialogBody({ } setLoading(false) }) - }, [owner, repo, number, type]) + }, [owner, repo, number, type, sourceSettings]) const title = row?.content.title ?? details?.item.title ?? '' const url = row?.content.url ?? details?.item.url ?? null @@ -208,6 +223,7 @@ export function SlugDialogBody({ repo={repo} selected={labels} disabled={!row} + sourceSettings={sourceSettings} onChange={async (add, remove) => { // Why: bail rather than call the helper with an empty id — // see commitTitle above. Trigger is also disabled when !row. @@ -228,6 +244,7 @@ export function SlugDialogBody({ repo={repo} selected={assignees} disabled={!row} + sourceSettings={sourceSettings} onChange={async (add, remove) => { if (!row) { return @@ -321,12 +338,14 @@ export function SlugDialogBody({ owner={owner} repo={repo} comments={details.comments} + sourceSettings={sourceSettings} onChange={(next) => setDetails((d) => (d ? { ...d, comments: next } : d))} /> <NewCommentForm owner={owner} repo={repo} number={number} + sourceSettings={sourceSettings} onAdded={(c) => setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))} /> </section> diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx index 7de32c72d92..d23329d2580 100644 --- a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx +++ b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx @@ -20,8 +20,10 @@ import type { GitHubOwnerRepo, GitHubViewer, GitHubWorkItem, + GlobalSettings, PRComment } from '../../../../shared/types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' export function GitHubIssueCommentComposer({ @@ -32,6 +34,8 @@ export function GitHubIssueCommentComposer({ itemType, itemState, itemId, + sourceContext, + sourceSettings, projectOrigin, previewGithubRepo, onCommentAdded, @@ -45,6 +49,8 @@ export function GitHubIssueCommentComposer({ itemType: 'issue' | 'pr' itemState?: GitHubWorkItem['state'] itemId?: string + sourceContext?: TaskSourceContext | null + sourceSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined projectOrigin?: GitHubIssueCommentProjectOrigin previewGithubRepo?: GitHubOwnerRepo | null onCommentAdded: (comment: PRComment) => void @@ -127,6 +133,7 @@ export function GitHubIssueCommentComposer({ const result = await addIssueCommentForRepo({ repoPath, repoId: repoId ?? undefined, + sourceContext, number: issueNumber, body: trimmed, type: itemType @@ -162,7 +169,7 @@ export function GitHubIssueCommentComposer({ setSubmitting(false) } } - }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath]) + }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath, sourceContext]) const handleCloseIssue = useCallback( async (reason: GitHubIssueCloseReason = closeReason) => { @@ -176,6 +183,8 @@ export function GitHubIssueCommentComposer({ await runIssueStateUpdate({ repoPath, repoId, + sourceContext, + sourceSettings, projectOrigin, number: issueNumber, updates: { state: 'closed', stateReason: reason } @@ -212,6 +221,8 @@ export function GitHubIssueCommentComposer({ projectOrigin, repoId, repoPath, + sourceContext, + sourceSettings, statePending ] ) @@ -227,6 +238,8 @@ export function GitHubIssueCommentComposer({ await runIssueStateUpdate({ repoPath, repoId, + sourceContext, + sourceSettings, projectOrigin, number: issueNumber, updates: { state: 'open' } @@ -261,6 +274,8 @@ export function GitHubIssueCommentComposer({ projectOrigin, repoId, repoPath, + sourceContext, + sourceSettings, statePending ]) diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts index 6bb52ea9b60..ab6c3ad4bfc 100644 --- a/src/renderer/src/components/github/github-issue-comment-helpers.ts +++ b/src/renderer/src/components/github/github-issue-comment-helpers.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GitHubIssueCloseReason } from '../../../../shared/types' +import type { GitHubIssueCloseReason, GlobalSettings } from '../../../../shared/types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' export type GitHubIssueCommentProjectOrigin = { owner: string @@ -12,6 +13,8 @@ export type GitHubIssueCommentProjectOrigin = { export async function runIssueStateUpdate(args: { repoPath: string repoId?: string | null + sourceContext?: TaskSourceContext | null + sourceSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined projectOrigin: GitHubIssueCommentProjectOrigin | undefined number: number updates: { @@ -21,7 +24,7 @@ export async function runIssueStateUpdate(args: { } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(args.sourceSettings ?? useAppStore.getState().settings) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -45,6 +48,7 @@ export async function runIssueStateUpdate(args: { const res = await window.api.gh.updateIssue({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, number: args.number, updates: args.updates }) @@ -56,6 +60,7 @@ export async function runIssueStateUpdate(args: { export async function addIssueCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -63,6 +68,7 @@ export async function addIssueCommentForRepo(args: { return window.api.gh.addIssueComment({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, number: args.number, body: args.body, type: args.type diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index e40fb04c62a..38297503285 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -5,6 +5,8 @@ import { installWindowVisibilityInterval } from '@/lib/window-visibility-interva import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types' +import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' +import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' const REFRESH_INTERVAL_MS = 60_000 @@ -169,6 +171,8 @@ function GitHubRateLimitRows({ export function GitHubRateLimitPanel({ className }: { className?: string }): React.JSX.Element { const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot() + const settings = useAppStore((s) => s.settings) + const budgetScope = getProviderRateLimitScope(settings, 'GitHub') return ( <div className={cn('space-y-3 rounded-md border border-border/60 p-3', className)}> @@ -187,6 +191,14 @@ export function GitHubRateLimitPanel({ className }: { className?: string }): Rea 'Orca uses REST, Search, and GraphQL through the GitHub CLI.' )} </p> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.github.github.rate.limit.display.budget_scope_prefix', + 'Budget scope' + )} + scope={budgetScope} + className="text-xs" + /> </div> <button type="button" diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index e477146fd1c..6bc87d967e8 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -6,6 +6,8 @@ import { installWindowVisibilityInterval } from '@/lib/window-visibility-interva import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GetGitLabRateLimitResult, GitLabRateLimitSnapshot } from '../../../../shared/types' +import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' +import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' const REFRESH_INTERVAL_MS = 60_000 @@ -146,6 +148,8 @@ function GitLabRateLimitRows({ export function GitLabRateLimitPanel({ className }: { className?: string }): React.JSX.Element { const { snapshot, hasError, isFetching, refresh } = useGitLabRateLimitSnapshot() + const settings = useAppStore((s) => s.settings) + const budgetScope = getProviderRateLimitScope(settings, 'GitLab') return ( <div className={cn('space-y-3 rounded-md border border-border/60 p-3', className)}> @@ -164,6 +168,14 @@ export function GitLabRateLimitPanel({ className }: { className?: string }): Rea 'Orca uses REST through the GitLab CLI.' )} </p> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.gitlab.gitlab.rate.limit.display.budget_scope_prefix', + 'Budget scope' + )} + scope={budgetScope} + className="text-xs" + /> </div> <Button type="button" diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx new file mode 100644 index 00000000000..bce2153656b --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import ProjectCombobox from './ProjectCombobox' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</> +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandInput: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( + (props, ref) => <input ref={ref} {...props} /> + ), + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value: string + }) => ( + <button type="button" data-command-value={value} onClick={() => onSelect?.(value)}> + {children} + </button> + ) +})) + +let container: HTMLDivElement +let root: Root + +const projects: NewWorkspaceProjectOption[] = [ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + }, + { + id: 'github:stablyai/noqa', + displayName: 'noqa', + badgeColor: '#222222', + detail: 'stablyai/noqa' + } +] + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +describe('ProjectCombobox', () => { + it('renders a logical project label without host-specific SSH chrome', () => { + act(() => { + root.render( + <ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} /> + ) + }) + + const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') + expect(trigger?.textContent).toContain('orca') + expect(trigger?.textContent).not.toContain('SSH') + }) + + it('selects projects by logical project id', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + <ProjectCombobox + options={projects} + value="github:stablyai/orca" + onValueChange={onValueChange} + /> + ) + }) + act(() => { + container + .querySelector<HTMLButtonElement>('[data-command-value="github:stablyai/noqa"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx new file mode 100644 index 00000000000..dc9a424a807 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import { + searchNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' +import { translate } from '@/i18n/i18n' + +type ProjectComboboxProps = { + options: readonly NewWorkspaceProjectOption[] + value: string | null + onValueChange: (projectId: string) => void + onValueSelected?: (projectId: string) => void + placeholder?: string + triggerClassName?: string + invalid?: boolean + describedBy?: string +} + +export default function ProjectCombobox({ + options, + value, + onValueChange, + onValueSelected, + placeholder = 'Choose project', + triggerClassName, + invalid = false, + describedBy +}: ProjectComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const inputRef = React.useRef<HTMLInputElement | null>(null) + const focusFrameRef = React.useRef<number | null>(null) + const selectedProject = useMemo( + () => options.find((option) => option.id === value) ?? null, + [options, value] + ) + const filteredOptions = useMemo( + () => searchNewWorkspaceProjectOptions(options, query), + [options, query] + ) + + const cancelFocusFrame = useCallback((): void => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + const setInputNode = useCallback( + (node: HTMLInputElement | null): void => { + if (node === null) { + cancelFocusFrame() + } + inputRef.current = node + }, + [cancelFocusFrame] + ) + + const focusSearchInput = useCallback((): void => { + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus() + }) + }, [cancelFocusFrame]) + + const handleOpenChange = useCallback( + (nextOpen: boolean): void => { + setOpen(nextOpen) + if (nextOpen) { + setCommandValue(value ?? '') + return + } + cancelFocusFrame() + setQuery('') + }, + [cancelFocusFrame, value] + ) + + const handleSelect = useCallback( + (projectId: string): void => { + onValueChange(projectId) + setOpen(false) + setQuery('') + onValueSelected?.(projectId) + }, + [onValueChange, onValueSelected] + ) + + const handleTriggerKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLButtonElement>): void => { + if (open) { + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setCommandValue(value ?? '') + setOpen(true) + return + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + if (event.key.length === 1 && /\S/.test(event.key)) { + event.preventDefault() + setCommandValue(value ?? '') + setQuery(event.key) + setOpen(true) + } + }, + [open, value] + ) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + aria-invalid={invalid ? true : undefined} + aria-describedby={describedBy} + onKeyDown={handleTriggerKeyDown} + className={cn( + 'h-8 min-w-[184px] justify-between px-3 text-xs font-normal', + triggerClassName + )} + data-project-combobox-root="true" + > + {selectedProject ? ( + <RepoBadgeLabel + name={selectedProject.displayName} + color={selectedProject.badgeColor} + badgeClassName="size-1.5" + /> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0" + data-project-combobox-root="true" + onOpenAutoFocus={(event) => { + event.preventDefault() + focusSearchInput() + }} + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + ref={setInputNode} + placeholder={translate( + 'auto.components.new.workspace.ProjectCombobox.search', + 'Search projects...' + )} + value={query} + onValueChange={setQuery} + /> + <CommandList> + <CommandEmpty> + {translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} + </CommandEmpty> + {filteredOptions.map((option) => ( + <CommandItem + key={option.id} + value={option.id} + onSelect={() => handleSelect(option.id)} + className="items-center gap-2 px-3 py-2" + > + <Check + className={cn( + 'size-4 text-foreground', + option.id === value ? 'opacity-100' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <RepoBadgeLabel + name={option.displayName} + color={option.badgeColor} + className="max-w-full" + /> + <p className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {option.detail} + </p> + </div> + </CommandItem> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx new file mode 100644 index 00000000000..3026fb1b241 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</> +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + disabled, + onSelect, + value + }: { + children: React.ReactNode + disabled?: boolean + onSelect?: (value: string) => void + value: string + }) => ( + <button + type="button" + data-command-value={value} + disabled={disabled} + onClick={() => onSelect?.(value)} + > + {children} + </button> + ) +})) + +let container: HTMLDivElement +let root: Root + +const readyOption: ProjectHostSetupOption = { + id: 'local-setup', + kind: 'ready', + projectId: 'project-1', + hostId: 'local', + repoId: 'local-repo', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' +} + +const needsSetupOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:ssh:builder', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'ssh:builder', + label: 'Builder', + detail: 'Project not set up on this host', + isAvailable: true +} + +const unavailableOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:runtime:old', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'runtime:old', + label: 'Old server', + detail: 'Update Orca on this host to set up projects', + isAvailable: false +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function renderCombobox({ + onValueChange = vi.fn() +}: { + onValueChange?: (setupId: string) => void +} = {}): void { + act(() => { + root.render( + <ProjectHostSetupCombobox + options={[readyOption, needsSetupOption]} + value={readyOption.id} + onValueChange={onValueChange} + /> + ) + }) +} + +describe('ProjectHostSetupCombobox', () => { + it('routes ready setup rows through onValueChange', () => { + const onValueChange = vi.fn() + + renderCombobox({ onValueChange }) + + act(() => { + container + .querySelector<HTMLButtonElement>('[data-command-value="local-setup"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('local-setup') + }) + + it('hides hosts that need setup from the run target list', () => { + const onValueChange = vi.fn() + + renderCombobox({ onValueChange }) + + expect( + container.querySelector<HTMLButtonElement>('[data-command-value="needs-setup:ssh:builder"]') + ).toBeNull() + expect(container.textContent).not.toContain('Project not set up on this host') + expect(onValueChange).not.toHaveBeenCalled() + }) + + it('hides unavailable setup rows from the run target list', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + <ProjectHostSetupCombobox + options={[readyOption, unavailableOption]} + value={readyOption.id} + onValueChange={onValueChange} + /> + ) + }) + + const unavailableButton = container.querySelector<HTMLButtonElement>( + '[data-command-value="needs-setup:runtime:old"]' + ) + expect(unavailableButton).toBeNull() + expect(container.textContent).not.toContain('Update Orca on this host') + + expect(onValueChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx new file mode 100644 index 00000000000..22df2d5f192 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { Check, ChevronsUpDown, Server } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import { translate } from '@/i18n/i18n' + +type ProjectHostSetupComboboxProps = { + options: readonly ProjectHostSetupOption[] + value: string | null + onValueChange: (setupId: string) => void +} + +export default function ProjectHostSetupCombobox({ + options, + value, + onValueChange +}: ProjectHostSetupComboboxProps): React.JSX.Element { + const [open, setOpen] = React.useState(false) + const readyOptions = options.filter((option) => option.kind === 'ready') + const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null + + const handleSelect = React.useCallback( + (setupId: string): void => { + const option = options.find((candidate) => candidate.id === setupId) + if (!option) { + return + } + if (!readyOptions.some((candidate) => candidate.id === setupId)) { + return + } + onValueChange(setupId) + setOpen(false) + }, + [onValueChange, options, readyOptions] + ) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className="h-9 w-full justify-between border-input px-3 text-sm font-normal focus:border-ring focus:ring-[3px] focus:ring-ring/50" + > + {selected ? ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + <Server className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="truncate">{selected.label}</span> + </span> + ) : ( + <span className="text-muted-foreground"> + {translate( + 'auto.components.new.workspace.ProjectHostSetupCombobox.placeholder', + 'Choose host' + )} + </span> + )} + <ChevronsUpDown className="size-3.5 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0" + > + <Command value={selected?.id ?? ''}> + <CommandList> + <CommandEmpty> + {translate( + 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', + 'No hosts are ready for this project.' + )} + </CommandEmpty> + {readyOptions.map((option) => ( + <CommandItem + key={option.id} + value={option.id} + onSelect={() => handleSelect(option.id)} + className="items-center gap-2 px-3 py-2" + > + <Check + className={cn( + 'size-4 text-foreground', + option.id === selected?.id ? 'opacity-100' : 'opacity-0' + )} + /> + <Server className="size-3.5 shrink-0 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm">{option.label}</div> + <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {option.path} + </div> + </div> + </CommandItem> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 38ca4ff3348..51740a51062 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -37,9 +37,14 @@ import { parseGitHubIssueOrPRLink, type RepoSlug } from '@/lib/github-links' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit' import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { JiraIcon } from '@/components/icons/JiraIcon' @@ -66,6 +71,7 @@ import { getSmartWorkspaceNameModes, type MrStateFilter } from './smart-workspace-localized-options' +import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context' type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number] @@ -161,6 +167,43 @@ export default function SmartWorkspaceNameField({ () => repos.find((repo) => repo.id === repoId) ?? null, [repoId, repos] ) + const selectedRepoOwnerSettings = useMemo( + () => getRepoOwnerRoutedSettings(settings, selectedRepo), + [selectedRepo, settings] + ) + const githubSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) + const gitlabSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'gitlab', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) + const linearSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'linear', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) const [mode, setMode] = useState<SmartNameMode>(textOnly ? 'text' : 'smart') const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened') const [open, setOpen] = useState(false) @@ -354,6 +397,7 @@ export default function SmartWorkspaceNameField({ const item = await lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: githubSourceContext, intent: { kind: 'link', owner: directLink.slug.owner, @@ -361,9 +405,8 @@ export default function SmartWorkspaceNameField({ number: directLink.number, type: directLink.type }, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) if (!stale) { setGithubItems(item ? [item] : []) @@ -405,10 +448,10 @@ export default function SmartWorkspaceNameField({ const request = lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: githubSourceContext, intent, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) void request .then((item) => { @@ -433,14 +476,22 @@ export default function SmartWorkspaceNameField({ const trimmed = normalizedGhQuery.query.trim() const query = trimmed ? normalizedGhQuery.query : '' - const cached = getCachedWorkItems(selectedRepo.id, RESULT_LIMIT, query) + const cached = getCachedWorkItems( + selectedRepo.id, + RESULT_LIMIT, + query, + selectedRepo.path, + githubSourceContext + ) if (cached) { setGithubItems(cached.slice(0, RESULT_LIMIT)) setGithubLoading(false) } else { setGithubLoading(true) } - void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query, { + sourceContext: githubSourceContext + }) .then((items) => { if (!stale) { setGithubItems(items.slice(0, RESULT_LIMIT)) @@ -468,6 +519,7 @@ export default function SmartWorkspaceNameField({ parsedGhLink, repos, selectedRepo, + githubSourceContext, shouldQueryGithub ]) @@ -497,7 +549,7 @@ export default function SmartWorkspaceNameField({ setBranchResultsSource(null) setBranchesLoading(true) void searchRuntimeRepoBaseRefDetails( - settings, + selectedRepoOwnerSettings, branchSearchRequest.repoId, branchSearchRequest.query, branchSearchRequest.limit @@ -525,7 +577,7 @@ export default function SmartWorkspaceNameField({ return () => { stale = true } - }, [branchSearchRequest, settings]) + }, [branchSearchRequest, selectedRepoOwnerSettings]) useEffect(() => { if (disabled || !shouldQueryLinear || !linearStatus.connected) { @@ -537,8 +589,10 @@ export default function SmartWorkspaceNameField({ setLinearLoading(true) const trimmed = debouncedQuery.trim() const request = trimmed - ? searchLinearIssues(trimmed, RESULT_LIMIT) - : listLinearIssues('assigned', RESULT_LIMIT).then((result) => result.items) + ? searchLinearIssues(trimmed, RESULT_LIMIT, { sourceContext: linearSourceContext }) + : listLinearIssues('assigned', RESULT_LIMIT, { sourceContext: linearSourceContext }).then( + (result) => result.items + ) void request .then((issues) => { if (!stale) { @@ -561,7 +615,7 @@ export default function SmartWorkspaceNameField({ // Why: list/search actions are stable store methods; depending on them // would refetch on unrelated store writes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedQuery, disabled, linearStatus.connected, shouldQueryLinear]) + }, [debouncedQuery, disabled, linearSourceContext, linearStatus.connected, shouldQueryLinear]) // Why: GitLab paste-URL flow. Watches the debounced query for a GitLab // issue/MR URL (parseGitLabIssueOrMRLink already filters non-GitLab URLs @@ -599,6 +653,8 @@ export default function SmartWorkspaceNameField({ void window.api.gl .workItemByPath({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: gitlabSourceContext, // Why: parseGitLabIssueOrMRLink doesn't carry the host (the URL // pattern is host-agnostic on purpose so self-hosted instances // work). Use 'gitlab.com' as the IPC arg — the main process maps @@ -628,7 +684,15 @@ export default function SmartWorkspaceNameField({ return () => { stale = true } - }, [disabled, mode, onGitLabItemSelect, parsedGlLink, selectedRepo, shouldQueryGitlab]) + }, [ + disabled, + gitlabSourceContext, + mode, + onGitLabItemSelect, + parsedGlLink, + selectedRepo, + shouldQueryGitlab + ]) // Why: when the user is on the GitLab tab (or in 'smart' mix) and // hasn't pasted a URL, surface the project's MRs filtered by the @@ -657,6 +721,8 @@ export default function SmartWorkspaceNameField({ void window.api.gl .listMRs({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: gitlabSourceContext, state: mrStateFilter, page: 1, perPage: RESULT_LIMIT @@ -694,6 +760,7 @@ export default function SmartWorkspaceNameField({ onGitLabItemSelect, parsedGlLink, selectedRepo, + gitlabSourceContext, shouldQueryGitlab ]) @@ -804,9 +871,15 @@ export default function SmartWorkspaceNameField({ handledCrossRepoUrlRef.current = debouncedQuery.trim() setGithubLoading(true) try { - const item = await window.api.gh.workItemByOwnerRepo({ + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: targetRepo.id, + repo: targetRepo + }) + const item = await lookupGitHubWorkItemByOwnerRepoForSource({ repoPath: targetRepo.path, repoId: targetRepo.id, + sourceContext, owner: crossRepoPrompt.link.slug.owner, repo: crossRepoPrompt.link.slug.repo, number: crossRepoPrompt.link.number, @@ -1088,7 +1161,9 @@ export default function SmartWorkspaceNameField({ align="start" sideOffset={4} className="popover-scroll-content flex w-[var(--radix-popover-trigger-width)] flex-col p-0" - style={{ maxHeight: 'min(var(--radix-popover-content-available-height,22rem),22rem)' }} + // Why: this popover lives inside the create-workspace dialog; a + // taller result list can cover the submit footer while typing. + style={{ maxHeight: 'min(var(--radix-popover-content-available-height,7rem),7rem)' }} onOpenAutoFocus={(event) => event.preventDefault()} onPointerDownOutside={(event) => { // Why: the input is a PopoverAnchor, not a PopoverTrigger, so diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx index 18941683a51..832af22219c 100644 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx @@ -24,7 +24,7 @@ describe('AgentFeatureSetupStep', () => { expect(html).toContain('Computer Use') expect(html).toContain('Agent Orchestration') expect(html).toContain('Linear agent skill') - expect(html).toContain('Install CLI & Skills') + expect(html).toContain('Enable capabilities') expect(html).toContain('role="checkbox"') }) }) diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx index decf49da34f..0202ef6a6a6 100644 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.tsx @@ -1,5 +1,4 @@ import { - ArrowLeft, ArrowRight, CircleStop, FolderOpen, @@ -10,12 +9,10 @@ import { } from 'lucide-react' import type { Dispatch, SetStateAction } from 'react' import { Button } from '@/components/ui/button' -import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { NestedRepoScanResult } from '../../../../shared/types' -import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' -import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' import { translate } from '@/i18n/i18n' +import { RepoStepNestedImportPanel } from './RepoStepNestedImportPanel' type RepoStepProps = { cloneUrl: string @@ -65,92 +62,20 @@ export function RepoStep({ error }: RepoStepProps) { const disabled = Boolean(busyLabel) - const nestedImportDisabled = disabled || nestedScanInProgress if (nestedScan) { - const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath return ( - <div className="flex h-full min-h-0 min-w-0 flex-col gap-3"> - <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-muted/30 p-5"> - <div className="flex min-w-0 shrink-0 items-center gap-4"> - <div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground"> - <FolderOpen className="size-5" /> - </div> - <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.2d20200346", "Import repositories")}</div> - <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground"> - {nestedScanInProgress ? ( - <Tooltip> - <TooltipTrigger asChild> - <Button - type="button" - variant="ghost" - size="icon-xs" - className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" - aria-label={translate("auto.components.onboarding.RepoStep.c3d9d44ca2", "Stop scan")} - title={translate("auto.components.onboarding.RepoStep.c7af322fc3", "Stop scanning")} - onClick={onStopNestedScan} - > - <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> - <CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" /> - </Button> - </TooltipTrigger> - <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.onboarding.RepoStep.e8fdb36338", "Scanning repositories. Click to stop.")}</TooltipContent> - </Tooltip> - ) : null} - <span className="min-w-0 truncate"> - {translate("auto.components.onboarding.RepoStep.2e6438dd34", "{{value0}}Found {{value1}} {{value2}} in this folder.", { value0: nestedScanInProgress ? 'Scanning... ' : '', value1: nestedScan.repos.length, value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' })} - </span> - </div> - <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.cecd6593fa", "Scanned folder:")} {folderName} - {nestedScan.selectedPath} - </div> - </div> - </div> - <NestedRepoChecklist - scan={nestedScan} - selectedPaths={nestedSelectedPaths} - onSelectedPathsChange={onNestedSelectedPathsChange} - disabled={nestedImportDisabled} - className="mt-4 flex-1" - /> - {nestedScanInProgress || - nestedScan.truncated || - nestedScan.timedOut || - nestedScan.stopped ? ( - <div className="mt-2 shrink-0"> - <NestedRepoScanLimitNotice scan={nestedScan} /> - </div> - ) : null} - <div className="mt-4 flex shrink-0 flex-wrap items-center gap-2"> - <button - type="button" - className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40" - disabled={disabled && !nestedScanInProgress} - onClick={onCancelNested} - > - <ArrowLeft className="size-3.5" /> - {translate("auto.components.onboarding.RepoStep.27ca610db1", "Back")}</button> - <button - type="button" - className="ml-auto rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" - disabled={nestedImportDisabled || nestedSelectedPaths.size === 0} - onClick={onImportNested} - > - {translate("auto.components.onboarding.RepoStep.2d20200346", "Import repositories")}</button> - </div> - </div> - {busyLabel && ( - <div className="shrink-0 rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200"> - {busyLabel} - </div> - )} - {error && ( - <div className="shrink-0 rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200"> - {error} - </div> - )} - </div> + <RepoStepNestedImportPanel + nestedScan={nestedScan} + nestedScanInProgress={nestedScanInProgress} + nestedSelectedPaths={nestedSelectedPaths} + onNestedSelectedPathsChange={onNestedSelectedPathsChange} + onImportNested={onImportNested} + onCancelNested={onCancelNested} + onStopNestedScan={onStopNestedScan} + busyLabel={busyLabel} + error={error} + disabled={disabled} + /> ) } return ( @@ -168,15 +93,27 @@ export function RepoStep({ <FolderOpen className="size-5" /> </div> <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.8cab104e3c", "Open a server project")}</div> + <div className="text-base font-semibold text-foreground"> + {translate( + 'auto.components.onboarding.RepoStep.8cab104e3c', + 'Open a project on this host' + )} + </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.466108ab89", "Enter a path that exists on the runtime server.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.466108ab89', + 'Enter a path that exists on the selected host.' + )} + </div> </div> </div> <div className="mt-4 flex flex-col gap-2 sm:flex-row"> <input className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.2ebbc26343", "/home/user/project")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.2ebbc26343', + '/home/user/project' + )} value={serverPath} disabled={disabled} spellCheck={false} @@ -187,14 +124,16 @@ export function RepoStep({ className="shrink-0 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" disabled={!serverPath.trim() || disabled} > - {translate("auto.components.onboarding.RepoStep.3863747c56", "Add Git Project")}</button> + {translate('auto.components.onboarding.RepoStep.3863747c56', 'Add Git Project')} + </button> <button type="button" className="shrink-0 rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-40" disabled={!serverPath.trim() || disabled} onClick={() => onOpenServerFolder('folder')} > - {translate("auto.components.onboarding.RepoStep.e8214aa632", "Open as Folder")}</button> + {translate('auto.components.onboarding.RepoStep.e8214aa632', 'Open as Folder')} + </button> </div> </form> ) : ( @@ -212,18 +151,31 @@ export function RepoStep({ <div className="min-w-0 flex-1"> <div className="flex min-w-0 items-center gap-2"> <div className="min-w-0 text-base font-semibold text-foreground"> - {translate("auto.components.onboarding.RepoStep.f4e9c8dcf8", "Browse for a folder")}</div> + {translate( + 'auto.components.onboarding.RepoStep.f4e9c8dcf8', + 'Browse for a folder' + )} + </div> <ArrowRight className="size-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" /> </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.831524961f", "Choose any local directory, git repo or not.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.831524961f', + 'Choose any local directory, git repo or not.' + )} + </div> </div> </div> <div className="ml-[3.75rem] mt-3 flex w-fit max-w-[calc(100%-3.75rem)] items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-[12px] text-muted-foreground"> <span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground"> <Lightbulb className="size-3.5" /> </span> - <span>{translate("auto.components.onboarding.RepoStep.6558d50c69", "Want to import many repos at once? Select the parent folder.")}</span> + <span> + {translate( + 'auto.components.onboarding.RepoStep.6558d50c69', + 'Want to import many repos at once? Select the parent folder.' + )} + </span> </div> </button> )} @@ -240,15 +192,24 @@ export function RepoStep({ <GitBranch className="size-5" /> </div> <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.132425a3e3", "Clone a repo")}</div> + <div className="text-base font-semibold text-foreground"> + {translate('auto.components.onboarding.RepoStep.132425a3e3', 'Clone a repo')} + </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.288d8444b7", "Paste an HTTPS or SSH URL.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.288d8444b7', + 'Paste an HTTPS or SSH URL.' + )} + </div> </div> </div> <div className="mt-4 flex gap-2"> <input className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.955134915e", "git@github.com:org/repo.git")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.955134915e', + 'git@github.com:org/repo.git' + )} value={cloneUrl} disabled={disabled} onChange={(event) => onCloneUrlChange(event.target.value)} @@ -258,15 +219,20 @@ export function RepoStep({ className="shrink-0 rounded-lg bg-primary px-5 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" disabled={!cloneUrl.trim() || (runtimeActive && !cloneDestination.trim()) || disabled} > - {translate("auto.components.onboarding.RepoStep.7932e95f68", "Clone")}</button> + {translate('auto.components.onboarding.RepoStep.7932e95f68', 'Clone')} + </button> </div> {runtimeActive && ( <div className="mt-2 space-y-1"> <label className="text-[11px] font-medium text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.24c7c8696c", "Clone into server path")}</label> + {translate('auto.components.onboarding.RepoStep.24c7c8696c', 'Clone into host path')} + </label> <input className="w-full rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.7ec3f48820", "/home/user")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.7ec3f48820', + '/home/user' + )} value={cloneDestination} disabled={disabled} spellCheck={false} @@ -278,15 +244,19 @@ export function RepoStep({ <div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-1 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-2"> - <span>{translate("auto.components.onboarding.RepoStep.7b679207e4", "Workspace")}</span> + <span>{translate('auto.components.onboarding.RepoStep.7b679207e4', 'Workspace')}</span> <span className="truncate font-mono text-foreground"> - {runtimeActive ? translate("auto.components.onboarding.RepoStep.cf23006ba7", "Runtime server") : workspaceDir} + {runtimeActive + ? translate('auto.components.onboarding.RepoStep.cf23006ba7', 'Selected host') + : workspaceDir} </span> </div> {runtimeActive ? ( <div className="flex items-center gap-1.5"> <Server className="size-3.5" /> - <span>{translate("auto.components.onboarding.RepoStep.c33b190ca3", "Server paths only")}</span> + <span> + {translate('auto.components.onboarding.RepoStep.c33b190ca3', 'Host paths only')} + </span> </div> ) : ( <button @@ -296,7 +266,12 @@ export function RepoStep({ onClick={onOpenSshSettings} > <Server className="size-3.5 shrink-0" /> - <span className="truncate">{translate("auto.components.onboarding.RepoStep.b7c4da0504", "SSH? Set hosts up in Settings")}</span> + <span className="truncate"> + {translate( + 'auto.components.onboarding.RepoStep.b7c4da0504', + 'SSH? Set hosts up in Settings' + )} + </span> <ArrowRight className="size-3.5 shrink-0" /> </button> )} @@ -313,8 +288,14 @@ export function RepoStep({ variant="ghost" size="icon-xs" className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" - aria-label={translate("auto.components.onboarding.RepoStep.c3d9d44ca2", "Stop scan")} - title={translate("auto.components.onboarding.RepoStep.c7af322fc3", "Stop scanning")} + aria-label={translate( + 'auto.components.onboarding.RepoStep.c3d9d44ca2', + 'Stop scan' + )} + title={translate( + 'auto.components.onboarding.RepoStep.c7af322fc3', + 'Stop scanning' + )} onClick={onStopNestedScan} > <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> @@ -322,7 +303,11 @@ export function RepoStep({ </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.onboarding.RepoStep.e8fdb36338", "Scanning repositories. Click to stop.")}</TooltipContent> + {translate( + 'auto.components.onboarding.RepoStep.e8fdb36338', + 'Scanning repositories. Click to stop.' + )} + </TooltipContent> </Tooltip> ) : null} </div> diff --git a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx new file mode 100644 index 00000000000..e7b24e2b71e --- /dev/null +++ b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx @@ -0,0 +1,145 @@ +import { ArrowLeft, CircleStop, FolderOpen, Loader2 } from 'lucide-react' +import type { Dispatch, SetStateAction } from 'react' +import { Button } from '@/components/ui/button' +import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { NestedRepoScanResult } from '../../../../shared/types' +import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' +import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' + +type RepoStepNestedImportPanelProps = { + nestedScan: NestedRepoScanResult + nestedScanInProgress: boolean + nestedSelectedPaths: Set<string> + onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>> + onImportNested: () => void + onCancelNested: () => void + onStopNestedScan: () => void + busyLabel: string | null + error: string | null + disabled: boolean +} + +export function RepoStepNestedImportPanel({ + nestedScan, + nestedScanInProgress, + nestedSelectedPaths, + onNestedSelectedPathsChange, + onImportNested, + onCancelNested, + onStopNestedScan, + busyLabel, + error, + disabled +}: RepoStepNestedImportPanelProps) { + const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath + const nestedImportDisabled = disabled || nestedScanInProgress + return ( + <div className="flex h-full min-h-0 min-w-0 flex-col gap-3"> + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-muted/30 p-5"> + <div className="flex min-w-0 shrink-0 items-center gap-4"> + <div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground"> + <FolderOpen className="size-5" /> + </div> + <div className="min-w-0 flex-1"> + <div className="text-base font-semibold text-foreground"> + {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} + </div> + <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground"> + {nestedScanInProgress ? ( + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon-xs" + className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" + aria-label={translate( + 'auto.components.onboarding.RepoStep.c3d9d44ca2', + 'Stop scan' + )} + title={translate( + 'auto.components.onboarding.RepoStep.c7af322fc3', + 'Stop scanning' + )} + onClick={onStopNestedScan} + > + <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> + <CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" /> + </Button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + {translate( + 'auto.components.onboarding.RepoStep.e8fdb36338', + 'Scanning repositories. Click to stop.' + )} + </TooltipContent> + </Tooltip> + ) : null} + <span className="min-w-0 truncate"> + {translate( + 'auto.components.onboarding.RepoStep.2e6438dd34', + '{{value0}}Found {{value1}} {{value2}} in this folder.', + { + value0: nestedScanInProgress ? 'Scanning... ' : '', + value1: nestedScan.repos.length, + value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' + } + )} + </span> + </div> + <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '} + {folderName} - {nestedScan.selectedPath} + </div> + </div> + </div> + <NestedRepoChecklist + scan={nestedScan} + selectedPaths={nestedSelectedPaths} + onSelectedPathsChange={onNestedSelectedPathsChange} + disabled={nestedImportDisabled} + className="mt-4 flex-1" + /> + {nestedScanInProgress || + nestedScan.truncated || + nestedScan.timedOut || + nestedScan.stopped ? ( + <div className="mt-2 shrink-0"> + <NestedRepoScanLimitNotice scan={nestedScan} /> + </div> + ) : null} + <div className="mt-4 flex shrink-0 flex-wrap items-center gap-2"> + <button + type="button" + className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40" + disabled={disabled && !nestedScanInProgress} + onClick={onCancelNested} + > + <ArrowLeft className="size-3.5" /> + {translate('auto.components.onboarding.RepoStep.27ca610db1', 'Back')} + </button> + <button + type="button" + className="ml-auto rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" + disabled={nestedImportDisabled || nestedSelectedPaths.size === 0} + onClick={onImportNested} + > + {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} + </button> + </div> + </div> + {busyLabel ? ( + <div className="shrink-0 rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200"> + {busyLabel} + </div> + ) : null} + {error ? ( + <div className="shrink-0 rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200"> + {error} + </div> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index a305456c07c..88d02e23af2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -705,7 +705,7 @@ export function useOnboardingFlow( if (settings?.activeRuntimeEnvironmentId?.trim()) { const path = serverPath.trim() if (!path) { - const message = 'Enter a server path.' + const message = 'Enter a path on the selected host.' setError(message) return } @@ -996,7 +996,7 @@ export function useOnboardingFlow( const destination = target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir if (!destination) { - const message = 'Enter a server path for the clone destination.' + const message = 'Enter a host path for the clone destination.' setError(message) return } diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index 2d3153e5dcf..490e3a66ff1 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -3,11 +3,14 @@ import { useAppStore } from '@/store' import { getHasAnyWorktreesFromState } from '@/store/selectors' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { + mergeWorkspacePortScans, + runtimeTargetForExecutionHostId, scanWorkspacePortsForTarget, - workspacePortRuntimeTargetKey + workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' +import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' const WORKSPACE_PORT_SCAN_INTERVAL_MS = 30_000 const WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS = 1_000 @@ -23,17 +26,26 @@ function makeUnavailableScan(reason: string): WorkspacePortScanResult { export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): null { const settings = useAppStore((s) => s.settings) + const repos = useAppStore((s) => s.repos) const hasWorktrees = useAppStore(getHasAnyWorktreesFromState) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const inFlightRef = useRef<Promise<void> | null>(null) const generationRef = useRef(0) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + const scanKey = workspacePortScanKeyForTarget(runtimeTarget) + const scanTargets = useMemo( + () => + buildExecutionHostRegistry({ repos, settings }) + .map((host) => runtimeTargetForExecutionHostId(host.id)) + .filter((target): target is NonNullable<typeof target> => target !== null), + [repos, settings] + ) const refresh = useCallback(() => { - if (!hasWorktrees) { + if (!hasWorktrees || scanTargets.length === 0) { setWorkspacePortScan(null) setWorkspacePortScanRefreshing(false) return Promise.resolve() @@ -43,30 +55,57 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): } const generation = generationRef.current - const promise = scanWorkspacePortsForTarget(runtimeTarget) - .then((result) => { - if (generation === generationRef.current) { - setWorkspacePortScan({ key: scanKey, result }) + setWorkspacePortScanRefreshing(true) + const promise = Promise.all( + scanTargets.map(async (target) => { + const key = workspacePortScanKeyForTarget(target) + try { + const result = await scanWorkspacePortsForTarget(target) + return { key, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { key, result: makeUnavailableScan(message || 'Workspace port scan failed.') } } }) - .catch((error) => { - if (generation !== generationRef.current) { - return + ) + .then((results) => { + if (generation === generationRef.current) { + const scansByKey = Object.fromEntries(results.map(({ key, result }) => [key, result])) + for (const { key, result } of results) { + setWorkspacePortScanForKey(key, result) + } + const activeScan = scansByKey[scanKey] + const merged = mergeWorkspacePortScans(scansByKey) + const projectionKey = + results.length > 1 ? 'all-hosts:all' : activeScan ? scanKey : results[0].key + setWorkspacePortScan( + merged + ? { + key: projectionKey, + result: merged + } + : null + ) } - const message = error instanceof Error ? error.message : String(error) - setWorkspacePortScan({ - key: scanKey, - result: makeUnavailableScan(message || 'Workspace port scan failed.') - }) }) .finally(() => { if (inFlightRef.current === promise) { inFlightRef.current = null } + if (generation === generationRef.current) { + setWorkspacePortScanRefreshing(false) + } }) inFlightRef.current = promise return promise - }, [hasWorktrees, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing]) + }, [ + hasWorktrees, + scanKey, + scanTargets, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ]) useEffect(() => { if (!enabled) { diff --git a/src/renderer/src/components/pull-request-page-host-boundary.test.ts b/src/renderer/src/components/pull-request-page-host-boundary.test.ts new file mode 100644 index 00000000000..9c1148a51d8 --- /dev/null +++ b/src/renderer/src/components/pull-request-page-host-boundary.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('PullRequestPage host boundaries', () => { + it('routes reviewer metadata and mutations through the PR repo owner host', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('repoOwnerSettings') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('repoOwnerSettings') + expect(section).toContain('getActiveRuntimeTarget(repoOwnerSettings)') + }) + + it('routes PR edit metadata through the same repo owner host as mutations', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function GHEditSection', 'function GHCommentComposer') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') + expect(section).toContain('useRepoLabels(') + expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, repoOwnerSettings)') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('repoOwnerSettings') + }) + + it('routes PR mention metadata through the PR repo owner host', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function ConversationTab', 'const mentionOptions') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') + expect(section).toContain('useRepoAssignees(repoPath, item.repoId, repoOwnerSettings)') + }) +}) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 2c3241e97ef..cbca96f1d4e 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { Worktree } from '../../../shared/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { getConnectionId } from '@/lib/connection-context' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { useWorktreeById, useWorktreesForRepo } from '@/store/selectors' @@ -122,7 +123,9 @@ export function useRuntimeFileListForWorktree({ void listRuntimeFiles( { - settings: useAppStore.getState().settings, + // Why: Quick Open lists files for the selected workspace. It must + // follow that workspace's owner host, not the globally focused host. + settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index fb7da464340..91b087f0350 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -247,7 +247,7 @@ export default function AiVaultPanel(): React.JSX.Element { <div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground"> {translate( 'auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory', - 'Remote workspaces can browse local history. Resume actions run from local workspaces.' + 'SSH-host workspaces can browse local history. Resume actions run from Local Mac workspaces.' )} </div> ) : null} diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx index 1f5e324ab74..291574181c0 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx @@ -72,13 +72,13 @@ describe('ChecksPanelReviewHeader', () => { expect(markup).toContain('unlink PR') }) - it('shows GitLab MR unlink actions in the menu', () => { + it('shows GitLab MR identity without GitHub-only link management actions', () => { const markup = renderHeader({ provider: 'gitlab' }) expect(markup).toContain('Open on GitLab') expect(markup).toContain('!31') - expect(markup).toContain('More MR actions') - expect(markup).toContain('unlink MR') + expect(markup).not.toContain('More PR actions') + expect(markup).not.toContain('unlink PR') expect(markup).not.toContain('Link another PR') }) }) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ce6d0acea24..904ad1396e9 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -12,7 +12,7 @@ import { Link, Unlink } from 'lucide-react' -import { useAppStore } from '@/store' +import { useAppStore, type AppState } from '@/store' import { mergePRCommentIntoList, prChecksCacheSuffix, @@ -42,10 +42,6 @@ import { PRCommentsList, PRTriageStrip } from './checks-panel-content' -import { - clearPRCommentsListSelection, - type PRCommentsListSelectionClearRequest -} from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, @@ -89,21 +85,14 @@ import { restorePRCommentThreadSnapshot } from './pr-comment-thread-resolution' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' -import { - CHECKS_PANEL_BASE_POLL_INTERVAL_MS, - nextChecksPanelPollInterval -} from './checks-panel-polling' import { getChecksPanelEmptyStateCopy, shouldShowChecksPanelPublishBranchAction } from './checks-panel-empty-state' import { - cancelRuntimeGeneratePullRequestFields, - generateRuntimePullRequestFields, getRuntimeGitScope, getRuntimeGitStatus, - getRuntimeGitUpstreamStatus, - type RuntimeGeneratePullRequestFieldsOverrides + getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' import { buildChecksPanelGitStatusContextKey, @@ -138,24 +127,10 @@ import { type SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CreateHostedReviewComposer } from './CreateHostedReviewComposer' import { formatCreateError } from './create-pull-request-review-copy' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' -import { - resolveChecksPanelHostedReviewBaseRef, - shouldOpenChecksPanelCreateComposer -} from './checks-panel-review-creation' -import { - createRunningPullRequestGenerationRecord, - getPullRequestGenerationRecordKey, - resolvePullRequestGenerationCancel, - resolvePullRequestGenerationFailure, - resolvePullRequestGenerationSuccess, - shouldHydratePullRequestGenerationResult, - type PullRequestFieldRevisions, - type PullRequestGenerationContext, - type PullRequestGenerationFields -} from '@/store/slices/pull-request-generation' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { translate } from '@/i18n/i18n' import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' @@ -206,8 +181,7 @@ export function ChecksPanelReviewHeader({ const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}` const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub' - const showPullRequestMenu = review.provider === 'github' || review.provider === 'gitlab' - const shortReviewLabel = review.provider === 'gitlab' ? 'MR' : 'PR' + const showPullRequestMenu = review.provider === 'github' return ( <div className="flex items-center gap-2"> @@ -249,14 +223,12 @@ export function ChecksPanelReviewHeader({ variant="ghost" size="icon-xs" aria-label={translate( - 'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1', - 'More {{value0}} actions', - { value0: shortReviewLabel } + 'auto.components.right.sidebar.ChecksPanel.653c105ecc', + 'More PR actions' )} title={translate( - 'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1', - 'More {{value0}} actions', - { value0: shortReviewLabel } + 'auto.components.right.sidebar.ChecksPanel.653c105ecc', + 'More PR actions' )} className="text-muted-foreground hover:text-foreground" > @@ -266,21 +238,12 @@ export function ChecksPanelReviewHeader({ <DropdownMenuContent align="end" className="w-44"> <DropdownMenuItem disabled={!canUnlinkPullRequest} onSelect={onUnlinkPullRequest}> <Unlink className="size-3.5" /> - {translate( - 'auto.components.right.sidebar.ChecksPanel.a9d7c128e4', - 'unlink {{value0}}', - { value0: shortReviewLabel } - )} + {translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} + </DropdownMenuItem> + <DropdownMenuItem onSelect={onLinkAnotherPullRequest}> + <Link className="size-3.5" /> + {translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')} </DropdownMenuItem> - {review.provider === 'github' ? ( - <DropdownMenuItem onSelect={onLinkAnotherPullRequest}> - <Link className="size-3.5" /> - {translate( - 'auto.components.right.sidebar.ChecksPanel.07871c0589', - 'Link another PR' - )} - </DropdownMenuItem> - ) : null} </DropdownMenuContent> </DropdownMenu> )} @@ -326,6 +289,7 @@ async function fetchGitLabMRDetailsForChecks(args: { } return (await window.api.gl.workItemDetails({ repoPath: args.repoPath, + repoId: args.repoId, iid: args.iid, type: 'mr' })) as GitLabWorkItemDetails | null @@ -355,6 +319,7 @@ async function resolveGitLabMRDiscussionForChecks(args: { } return window.api.gl.resolveMRDiscussion({ repoPath: args.repoPath, + repoId: args.repoId, iid: args.iid, discussionId: args.discussionId, resolved: args.resolved @@ -378,12 +343,6 @@ export default function ChecksPanel(): React.JSX.Element { (s) => s.getHostedReviewCreationEligibility ) const createHostedReview = useAppStore((s) => s.createHostedReview) - const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) - const allocatePullRequestGenerationRequestId = useAppStore( - (s) => s.allocatePullRequestGenerationRequestId - ) - const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) - const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' @@ -440,8 +399,6 @@ export default function ChecksPanel(): React.JSX.Element { const [agentComposerState, setAgentComposerState] = useState<ChecksAgentComposerState | null>( null ) - const [commentSelectionClearRequest, setCommentSelectionClearRequest] = - useState<PRCommentsListSelectionClearRequest | null>(null) const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] = useState<HostedReviewCreationSnapshot | null>(null) const [gitStatusSnapshot, setGitStatusSnapshot] = useState<ChecksPanelGitStatusSnapshot | null>( @@ -453,7 +410,7 @@ export default function ChecksPanel(): React.JSX.Element { const [titleSaving, setTitleSaving] = useState(false) const titleInputRef = useRef<HTMLInputElement>(null) const titleInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const pollIntervalRef = useRef(CHECKS_PANEL_BASE_POLL_INTERVAL_MS) + const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s const mountedRef = useMountedRef() const confirm = useConfirmationDialog() const prevChecksRef = useRef<string>('') @@ -501,30 +458,22 @@ export default function ChecksPanel(): React.JSX.Element { const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' const activeWorktreePath = activeWorktree?.path ?? null const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null - const hostedReviewCreationBaseRef = resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: activeWorktree?.baseRef, - repoBaseRef: repo?.worktreeBaseRef - }) - const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ - worktreeId: activeWorktreeId, - worktreePath: activeWorktreePath, - repoId: repo?.id, - branch - }) - const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey - ? (prGenerationRecords[activePullRequestGenerationKey] ?? null) - : null - const activePullRequestGenerationRecord = - activePullRequestGenerationRecordCandidate && - activePullRequestGenerationRecordCandidate.context.repoId === repo?.id && - activePullRequestGenerationRecordCandidate.context.branch === branch - ? activePullRequestGenerationRecordCandidate - : null const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ connectionId: activeConnectionId, worktreePath: activeWorktreePath }) - const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) + ) + const ownerSettings = useMemo<AppState['settings']>( + () => + !settings + ? settings + : runtimeEnvironmentId + ? { ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : { ...settings, activeRuntimeEnvironmentId: null }, + [runtimeEnvironmentId, settings] + ) const repoConnectionId = repo?.connectionId?.trim() || null const sshConnectionStatus = useAppStore((s) => repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined @@ -588,7 +537,7 @@ export default function ChecksPanel(): React.JSX.Element { setHostedReviewCreationSnapshot(null) setGitStatusSnapshot(null) setGitStatusRefreshNonce((value) => value + 1) - pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS + pollIntervalRef.current = 30_000 prevChecksRef.current = '' conflictSummaryRefreshKeyRef.current = null refreshRequestKeyRef.current = null @@ -602,11 +551,25 @@ export default function ChecksPanel(): React.JSX.Element { const isFolder = repo ? isFolderRepo(repo) : false const prCacheKey = repo && branch - ? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId) + ? getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) : '' const hostedReviewCacheKey = repo && branch - ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId) + ? getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) : '' const refreshContextKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}` if (refreshContextKey !== refreshContextKeyRef.current) { @@ -648,7 +611,8 @@ export default function ChecksPanel(): React.JSX.Element { repo.id, prChecksCacheSuffix(prNumber, pr?.prRepo), settings, - repo.connectionId + repo.connectionId, + repo.executionHostId ) : '' const commentsCacheKey = @@ -658,7 +622,8 @@ export default function ChecksPanel(): React.JSX.Element { repo.id, prCommentsCacheSuffix(prNumber, pr?.prRepo), settings, - repo.connectionId + repo.connectionId, + repo.executionHostId ) : '' const checksFetchedAt = useAppStore((s) => @@ -678,7 +643,7 @@ export default function ChecksPanel(): React.JSX.Element { runtimeEnvironmentId, connectionId: repoConnectionId, branch, - base: hostedReviewCreationBaseRef, + base: repo.worktreeBaseRef ?? null, hasUncommittedChanges: gitStatusSnapshot?.contextKey === panelContextKey ? gitStatusSnapshot.hasUncommittedChanges @@ -731,143 +696,16 @@ export default function ChecksPanel(): React.JSX.Element { // moved, the embedded composer should push before creating the review. setCreatePrPushFirst(true) const connectionId = activeConnectionId ?? undefined - await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) - }, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus]) - const handleGeneratePullRequestFieldsForActive = useCallback( - async ( - fields: PullRequestGenerationFields, - fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides - ): Promise<void> => { - if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) { - return - } - const generationKey = activePullRequestGenerationKey - if ( - useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' - ) { - return - } - const requestId = allocatePullRequestGenerationRequestId() - const context: PullRequestGenerationContext = { - worktreeId: activeWorktreeId, - worktreePath: activeWorktreePath, - connectionId: activeConnectionId ?? undefined, - requestId, - repoId: repo.id, - branch - } - const seed = { ...fields } - // Why: Checks stays mounted across tab switches, but the composer itself - // can unmount when eligibility refreshes; keep generation tied to the branch. - setPullRequestGenerationRecord( - generationKey, - createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) - ) - - try { - const result = await generateRuntimePullRequestFields( - { - settings: useAppStore.getState().settings, - worktreeId: context.worktreeId, - worktreePath: context.worktreePath, - connectionId: context.connectionId - }, - { - base: stripBaseRef((seed.base ?? '').trim()), - title: seed.title, - body: seed.body, - draft: seed.draft - }, - overrides - ) - if (result.branchChangedByPreparation) { - await handleBranchChangedByPullRequestGeneration() - } - if (result.success) { - useAppStore.getState().recordFeatureInteraction('ai-pr-generation') - } - updatePullRequestGenerationRecord(generationKey, (record) => { - if (!result.success) { - return resolvePullRequestGenerationFailure({ - record, - requestId, - canceled: result.canceled, - error: result.canceled ? null : result.error - }) - } - if (!record) { - return null - } - return resolvePullRequestGenerationSuccess({ - record, - requestId, - result: { - base: stripBaseRef(result.fields.base), - title: result.fields.title, - body: result.fields.body, - draft: result.fields.draft - } - }) - }) - } catch (error) { - updatePullRequestGenerationRecord(generationKey, (record) => - resolvePullRequestGenerationFailure({ - record, - requestId, - error: - error instanceof Error ? error.message : 'Failed to generate pull request details' - }) - ) - } - }, - [ - activeConnectionId, - activePullRequestGenerationKey, - activeWorktreeId, - activeWorktreePath, - allocatePullRequestGenerationRequestId, - branch, - handleBranchChangedByPullRequestGeneration, - repo, - setPullRequestGenerationRecord, - updatePullRequestGenerationRecord - ] - ) - const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => { - if (!activePullRequestGenerationKey) { - return - } - const record = prGenerationRecords[activePullRequestGenerationKey] - if (!record || record.status !== 'running') { - return - } - const generationKey = activePullRequestGenerationKey - updatePullRequestGenerationRecord(generationKey, (current) => { - if (!current || current.context.requestId !== record.context.requestId) { - return null - } - return resolvePullRequestGenerationCancel(current) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings }) - void cancelRuntimeGeneratePullRequestFields({ - settings: useAppStore.getState().settings, - worktreeId: record.context.worktreeId, - worktreePath: record.context.worktreePath, - connectionId: record.context.connectionId - }).catch((error) => { - updatePullRequestGenerationRecord(generationKey, (current) => { - if (!current || current.context.requestId !== record.context.requestId) { - return null - } - return { - ...current, - status: 'failed', - error: error instanceof Error ? error.message : 'Failed to stop pull request generation', - hydrated: false - } - }) - }) - }, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord]) + }, [ + activeConnectionId, + activeWorktree?.path, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings + ]) const prCreationDefaults = useMemo(() => { if (!settings) { return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS @@ -890,12 +728,12 @@ export default function ChecksPanel(): React.JSX.Element { prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS }) }, [repo, settings]) - const createComposerOpen = shouldOpenChecksPanelCreateComposer({ - activeReview, - isFolder, - branch, - hostedReviewCreation - }) + const createComposerOpen = + !activeReview && + !isFolder && + Boolean(branch) && + (hostedReviewCreation?.canCreate === true || + hostedReviewCreation?.blockedReason === 'needs_push') const { aiGenerationEnabled: prAiGenerationEnabled, base: prBase, @@ -916,8 +754,7 @@ export default function ChecksPanel(): React.JSX.Element { generateDisabled: prGenerateDisabled, generateDisabledReason: prGenerateDisabledReason, handleGenerate: handleGeneratePullRequestFields, - handleCancelGenerate: handleCancelGeneratePullRequestFields, - applyGeneratedFields: applyGeneratedPullRequestFields + handleCancelGenerate: handleCancelGeneratePullRequestFields } = useCreatePullRequestDialogFields({ open: createComposerOpen, repoId: repo?.id ?? '', @@ -926,60 +763,11 @@ export default function ChecksPanel(): React.JSX.Element { branch, eligibility: hostedReviewCreation, repo, - settings, + settings: ownerSettings, submitting: isCreatingPr, prCreationDefaults, - onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration, - generation: { - generating: activePullRequestGenerationRecord?.status === 'running', - generateError: activePullRequestGenerationRecord?.error ?? null, - onGenerate: (fields, fieldRevisions, overrides) => { - void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides) - }, - onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive - } + onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration }) - - useEffect(() => { - if ( - !activePullRequestGenerationKey || - !activePullRequestGenerationRecord || - activePullRequestGenerationRecord.status !== 'succeeded' || - !activePullRequestGenerationRecord.result || - activePullRequestGenerationRecord.hydrated - ) { - return - } - if ( - !shouldHydratePullRequestGenerationResult({ - record: activePullRequestGenerationRecord - }) - ) { - return - } - applyGeneratedPullRequestFields( - activePullRequestGenerationRecord.result, - activePullRequestGenerationRecord.seedFieldRevisions - ) - updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => { - if ( - !record || - record.context.requestId !== activePullRequestGenerationRecord.context.requestId - ) { - return null - } - return { - ...record, - hydrated: true - } - }) - }, [ - activePullRequestGenerationKey, - activePullRequestGenerationRecord, - applyGeneratedPullRequestFields, - updatePullRequestGenerationRecord - ]) - const handlePrBaseChange = useCallback( (value: string): void => { setCreatePrError(null) @@ -1125,7 +913,7 @@ export default function ChecksPanel(): React.JSX.Element { shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot ) const context = { - settings: useAppStore.getState().settings, + settings: ownerSettings, worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, connectionId @@ -1208,6 +996,7 @@ export default function ChecksPanel(): React.JSX.Element { gitStatusRefreshNonce, isFolder, isPanelVisible, + ownerSettings, panelContextKey, repo, repoConnectionId, @@ -1227,9 +1016,10 @@ export default function ChecksPanel(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: repo.path, + repoId: repo.id, ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}), branch, - base: hostedReviewCreationBaseRef, + base: repo.worktreeBaseRef ?? null, hasUncommittedChanges, hasUpstream: remoteStatus?.hasUpstream, ahead: remoteStatus?.ahead, @@ -1267,7 +1057,6 @@ export default function ChecksPanel(): React.JSX.Element { getHostedReviewCreationEligibility, gitStatusReadyForPanelContext, hasUncommittedChanges, - hostedReviewCreationBaseRef, hostedReviewCreationRequestKey, isFolder, isPanelVisible, @@ -1366,13 +1155,14 @@ export default function ChecksPanel(): React.JSX.Element { } setChecks(result) - const poll = nextChecksPanelPollInterval({ - checks: result, - previousSignature: prevChecksRef.current, - currentIntervalMs: pollIntervalRef.current - }) - pollIntervalRef.current = poll.intervalMs - prevChecksRef.current = poll.signature + // Exponential backoff: if checks haven't changed, double the interval (cap 120s). + // If they changed, reset to 30s. + const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)) + pollIntervalRef.current = + signature === prevChecksRef.current + ? Math.min(pollIntervalRef.current * 2, 120_000) + : 30_000 + prevChecksRef.current = signature } catch (err) { if ( !isCurrentAsyncResult( @@ -1445,13 +1235,12 @@ export default function ChecksPanel(): React.JSX.Element { const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? []) setChecks(result) setComments(gitLabMRCommentsToPRComments(details?.comments)) - const poll = nextChecksPanelPollInterval({ - checks: result, - previousSignature: prevChecksRef.current, - currentIntervalMs: pollIntervalRef.current - }) - pollIntervalRef.current = poll.intervalMs - prevChecksRef.current = poll.signature + const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)) + pollIntervalRef.current = + signature === prevChecksRef.current + ? Math.min(pollIntervalRef.current * 2, 120_000) + : 30_000 + prevChecksRef.current = signature } catch (err) { if (!isCurrentAsyncResult(requestKey)) { return @@ -1488,7 +1277,7 @@ export default function ChecksPanel(): React.JSX.Element { } // Reset backoff state on PR change - pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS + pollIntervalRef.current = 30_000 prevChecksRef.current = '' // Why: PR check status is user-visible when the panel is open. Keep visible // unfocused windows fresh, but stop timers and API work while hidden. @@ -1503,7 +1292,7 @@ export default function ChecksPanel(): React.JSX.Element { return } - pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS + pollIntervalRef.current = 30_000 prevChecksRef.current = '' return installWindowVisibilityTimeoutPoller({ run: () => fetchGitLabDetails(), @@ -1755,13 +1544,14 @@ export default function ChecksPanel(): React.JSX.Element { return } setChecks(result) - const poll = nextChecksPanelPollInterval({ - checks: result, - previousSignature: prevChecksRef.current, - currentIntervalMs: pollIntervalRef.current - }) - pollIntervalRef.current = poll.intervalMs - prevChecksRef.current = poll.signature + const signature = JSON.stringify( + result.map((c) => `${c.name}:${c.status}:${c.conclusion}`) + ) + pollIntervalRef.current = + signature === prevChecksRef.current + ? Math.min(pollIntervalRef.current * 2, 120_000) + : 30_000 + prevChecksRef.current = signature }, (err) => { if (!isCurrentRequest() || !isCurrentAsyncResult(prRequestKey)) { @@ -1839,8 +1629,9 @@ export default function ChecksPanel(): React.JSX.Element { return } // Why: entering the Checks tab is automatic UI behavior, not an explicit - // user refresh. Keep check loads cacheable so the GitHub CLI cache can - // absorb visible polling; only the manual refresh button bypasses it. + // user refresh. Route PR refresh through the coordinator so rate-limit + // guards still apply; only force detail panes that the entry freshness rule + // already proved stale, so tab entry stays fresh without broad fan-out. if (isGitLabReviewContext) { void fetchHostedReviewForBranch(repo.path, branch, { force: true, @@ -1856,7 +1647,7 @@ export default function ChecksPanel(): React.JSX.Element { } enqueueGitHubPRRefresh(activeWorktreeId, 'active', 80) if (options.refreshChecks) { - void fetchChecks() + void fetchChecks({ force: true }) } if (options.refreshComments) { void fetchComments({ force: true }) @@ -1920,9 +1711,9 @@ export default function ChecksPanel(): React.JSX.Element { const refreshComments = prNumber !== null && (commentsFetchedAt === undefined || commentsFetchedAt < cutoff) - // Reset polling attention state so this entry refresh establishes a fresh - // baseline rather than colliding with the previous PR's backoff. - pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS + // Reset polling attention state so the forced fetch's signature establishes + // a fresh baseline rather than colliding with the previous PR's backoff. + pollIntervalRef.current = 30_000 prevChecksRef.current = '' handleEntryRefresh({ refreshChecks, refreshComments }) }, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleEntryRefresh]) @@ -2011,6 +1802,7 @@ export default function ChecksPanel(): React.JSX.Element { if (activeReview.provider === 'gitlab') { const result = await window.api.gl.updateMR({ repoPath: repo.path, + repoId: repo.id, iid: activeReview.number, updates: { title: nextTitle } }) @@ -2334,7 +2126,7 @@ export default function ChecksPanel(): React.JSX.Element { ), description: translate( 'auto.components.right.sidebar.ChecksPanel.abf59262fb', - 'Review the prompt before starting an agent.' + 'Review and edit the full command input before starting an agent.' ), prompt: buildResolvePullRequestConflictsPrompt({ reviewKind: activeConflictReview.provider === 'gitlab' ? 'MR' : 'PR', @@ -2709,17 +2501,11 @@ export default function ChecksPanel(): React.JSX.Element { }, [activeReview, activeWorktreeId]) const handleUnlinkPullRequest = useCallback(() => { - if (!activeWorktreeId || !activeReview) { + if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) { return } - if (activeReview.provider === 'github' && linkedPR !== null) { - void updateWorktreeMeta(activeWorktreeId, { linkedPR: null }) - return - } - if (activeReview.provider === 'gitlab' && linkedGitLabMR !== null) { - void updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: null }) - } - }, [activeReview, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta]) + void updateWorktreeMeta(activeWorktreeId, { linkedPR: null }) + }, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta]) const handleLinkAnotherPullRequest = useCallback(() => { if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') { @@ -2752,14 +2538,24 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, false, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) - await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings + }) return true } catch { return false } - }, [activeConnectionId, activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch]) + }, [ + activeConnectionId, + activeWorktree, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings, + pushBranch + ]) const handlePublishBranch = useCallback(async (): Promise<void> => { if ( @@ -2778,13 +2574,15 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, true, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) await fetchUpstreamStatus( activeWorktreeId, activeWorktree.path, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) } catch { // Store remote actions already surface the publish failure toast. @@ -2801,6 +2599,7 @@ export default function ChecksPanel(): React.JSX.Element { fetchUpstreamStatus, isPublishingBranch, isRemoteOperationActive, + ownerSettings, pushBranch ]) @@ -2914,6 +2713,7 @@ export default function ChecksPanel(): React.JSX.Element { pushed = true } const result = await createHostedReview(repo.path, { + repoId: repo.id, provider: hostedReviewCreateProvider, base, head: normalizeHostedReviewHeadRef(branch), @@ -3200,9 +3000,7 @@ export default function ChecksPanel(): React.JSX.Element { <ChecksPanelReviewHeader review={activeReview} isRefreshing={isRefreshing} - canUnlinkPullRequest={ - activeReview.provider === 'gitlab' ? linkedGitLabMR !== null : linkedPR !== null - } + canUnlinkPullRequest={linkedPR !== null} onRefresh={() => void handleRefresh()} onOpenReview={handleOpenPR} onUnlinkPullRequest={handleUnlinkPullRequest} @@ -3302,7 +3100,7 @@ export default function ChecksPanel(): React.JSX.Element { </> )} {/* Why: when the hosted review has merge conflicts and no checks have been fetched, - showing an empty checks state is misleading — checks may exist but + showing "No checks configured" is misleading — checks may exist but simply cannot run until conflicts are resolved. Hide the empty state. */} {!(activeConflictReview && checks.length === 0 && !checksLoading) && ( <ChecksList @@ -3315,10 +3113,10 @@ export default function ChecksPanel(): React.JSX.Element { <PRCommentsList comments={comments} commentsLoading={commentsLoading} + reviewKind={reviewShortLabel} commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} - selectionClearRequest={commentSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} @@ -3382,14 +3180,6 @@ export default function ChecksPanel(): React.JSX.Element { onLaunched={() => { const launchedState = agentComposerState if (launchedState?.actionId === 'resolveComments' && launchedState.commentResolution) { - const { reviewContextKey } = launchedState.commentResolution - // Why: once the selected comments are sent to the agent, the queue - // is spent even if the host later rejects thread resolution. - clearPRCommentsListSelection(reviewContextKey) - setCommentSelectionClearRequest((prev) => ({ - contextKey: reviewContextKey, - token: (prev?.token ?? 0) + 1 - })) void resolveSelectedThreadsAfterLaunch(launchedState.commentResolution).catch((err) => { console.warn('Failed to resolve selected review comments after AI launch:', err) toast.error( diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 22f4e73bc8a..32317c593ee 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -384,7 +384,7 @@ describe('ConflictSummaryCard', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline review-conflicts button treatment', () => { + it('renders abort actions with operation-specific button treatment', () => { const mergeMarkup = renderToStaticMarkup( <ConflictSummaryCard conflictOperation="merge" @@ -407,7 +407,7 @@ describe('ConflictSummaryCard', () => { ) expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"') - expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"') expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) @@ -447,7 +447,7 @@ describe('OperationBanner', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline button treatment', () => { + it('renders abort actions with operation-specific button treatment', () => { const mergeMarkup = renderToStaticMarkup( <OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} /> ) @@ -455,7 +455,7 @@ describe('OperationBanner', () => { <OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} /> ) - expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) }) diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx index 937dec11bb2..31226479460 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/lib/worktree-activation', () => ({ import { getLocalWorkspacePortSections } from './PortsPanel' import { killWorkspacePortForTarget, + mergeWorkspacePortScans, openWorkspacePortInBrowser, refreshWorkspacePortScanAfterStop, scanWorkspacePortsForTarget @@ -219,6 +220,35 @@ describe('PortsPanel runtime routing', () => { ]) }) + it('merges local and runtime scans with host-prefixed row ids', () => { + const runtimePort: WorkspacePort = { + ...workspacePort, + id: workspacePort.id, + port: 3000, + owner: { + ...workspacePort.owner, + repoId: 'runtime-repo', + worktreeId: 'runtime-repo::/srv/app', + displayName: 'runtime app', + path: '/srv/app' + } + } + + const merged = mergeWorkspacePortScans({ + 'local:all': { ...emptyScan, scannedAt: 10, ports: [workspacePort] }, + 'environment:env-1:all': { ...emptyScan, scannedAt: 20, ports: [runtimePort] } + }) + + expect(merged).toMatchObject({ platform: 'unknown', scannedAt: 20 }) + expect(merged?.ports.map((port) => port.id)).toEqual([ + `environment:env-1:all:${workspacePort.id}`, + `local:all:${workspacePort.id}` + ]) + expect( + merged?.ports.map((port) => (port.kind === 'workspace' ? port.owner.worktreeId : null)) + ).toEqual(['runtime-repo::/srv/app', 'repo::/workspace/app']) + }) + it('opens remote workspace ports in the server-side browser and binds the local page handle', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => Promise.resolve({ @@ -352,6 +382,78 @@ describe('PortsPanel runtime routing', () => { expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(2, false) }) + it('preserves an all-host projection after refreshing one host post-stop', async () => { + const setWorkspacePortScan = vi.fn() + const setWorkspacePortScanForKey = vi.fn() + const setWorkspacePortScanRefreshing = vi.fn() + const localPort: WorkspacePort = { ...workspacePort, id: 'local-port', port: 5173 } + const refreshedRemotePort: WorkspacePort = { + ...workspacePort, + id: 'remote-port', + port: 3000, + owner: { + ...workspacePort.owner, + repoId: 'runtime-repo', + worktreeId: 'runtime-repo::/srv/app', + displayName: 'runtime app', + path: '/srv/app' + } + } + const localHostScan: WorkspacePortScanResult = { + ...emptyScan, + scannedAt: 10, + ports: [localPort] + } + const remoteHostScan: WorkspacePortScanResult = { + ...emptyScan, + scannedAt: 20, + ports: [refreshedRemotePort] + } + let scanCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + return Promise.resolve({ + id: method, + ok: true, + result: compatibleStatus, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'workspacePorts.scan') { + scanCalls += 1 + return Promise.resolve({ + id: method, + ok: true, + result: remoteHostScan, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.reject(new Error(`Unexpected method ${method}`)) + }) + + await expect( + refreshWorkspacePortScanAfterStop({ + runtimeTarget: { kind: 'environment', environmentId: 'env-1' }, + setWorkspacePortScan: setWorkspacePortScan as never, + setWorkspacePortScanForKey: setWorkspacePortScanForKey as never, + getWorkspacePortScansByKey: () => ({ 'local:all': localHostScan }), + setWorkspacePortScanRefreshing: setWorkspacePortScanRefreshing as never + }) + ).resolves.toEqual({ ok: true }) + + expect(setWorkspacePortScanForKey).toHaveBeenCalledWith('environment:env-1:all', remoteHostScan) + expect(setWorkspacePortScan).toHaveBeenLastCalledWith({ + key: 'all-hosts:all', + result: expect.objectContaining({ + ports: expect.arrayContaining([ + expect.objectContaining({ port: 5173 }), + expect.objectContaining({ port: 3000 }) + ]) + }) + }) + expect(scanCalls).toBe(2) + }) + it('keeps remote workspace ports in the server-side browser when link routing is off', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => Promise.resolve({ diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index 60a37e1cbac..1226e4e6e13 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -19,6 +19,7 @@ import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { killWorkspacePortForTarget, openWorkspacePortInBrowser, @@ -160,9 +161,10 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. const settings = useAppStore((s) => s.settings) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) - const scan = useAppStore((s) => s.workspacePortScan) + const scansByKey = useAppStore((s) => s.workspacePortScansByKey) const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const [detailsPort, setDetailsPort] = useState<WorkspacePort | null>(null) const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({ @@ -170,7 +172,15 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. external: true }) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo(() => { + const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + activeWorktree?.id + ) + // Why: the Ports panel acts on the active workspace; use that workspace's + // host owner even if the sidebar is focused elsewhere. + return getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId }) + }, [activeWorktree?.id, settings]) const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` const refresh = useCallback(() => { @@ -180,6 +190,7 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. setWorkspacePortScanRefreshing(true) const promise = scanWorkspacePortsForTarget(runtimeTarget) .then((nextScan) => { + setWorkspacePortScanForKey(scanKey, nextScan) setWorkspacePortScan({ key: scanKey, result: nextScan }) }) .catch((error) => { @@ -203,11 +214,18 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. setWorkspacePortScanRefreshing(false) }) return promise - }, [activeRepo, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing]) + }, [ + activeRepo, + runtimeTarget, + scanKey, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ]) // Why: WorkspacePortScanner already owns the 30s all-worktree poll. The // panel scopes that shared result instead of starting a second scan loop. - const displayScan = scan?.key === scanKey && isVisible ? scan.result : null + const displayScan = isVisible ? (scansByKey[scanKey] ?? null) : null const toggleSection = useCallback((sectionId: string) => { setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] })) @@ -237,6 +255,8 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -251,7 +271,13 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. ) } }, - [activeRepo, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing] + [ + activeRepo, + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ] ) const handleOpenPortInBrowser = useCallback( diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index 9aa49dba9ca..d1d4bddf00b 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -8,6 +8,7 @@ import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' import { SearchHeader } from './SearchHeader' import { SearchResultsPane } from './SearchResultsPane' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { translate } from '@/i18n/i18n' const SEARCH_DEBOUNCE_MS = 300 @@ -188,7 +189,7 @@ export default function Search(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId!) ?? undefined const results = await searchRuntimeFiles( { - settings: state.settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts new file mode 100644 index 00000000000..2b64ffacc1f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE_CONTROL_SOURCE = readFileSync(join(__dirname, 'SourceControl.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('SourceControl host-context boundaries', () => { + it('snapshots PR generation host ownership and reuses it after async branch preparation', () => { + const generateSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const handleGeneratePullRequestFieldsForActive = useCallback(', + 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(' + ) + expect(generateSection).toContain('runtimeTargetSettings: activeRepoSettings') + expect(generateSection).toContain('settings: context.runtimeTargetSettings') + + const cancelSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(', + 'const {' + ) + expect(cancelSection).toContain('settings: record.context.runtimeTargetSettings') + + const refreshSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const refreshGitStatusAfterPullRequestGeneration = useCallback(', + 'useEffect(() => {' + ) + expect(refreshSection).toContain('settings: context.runtimeTargetSettings') + expect(refreshSection).not.toContain('settings: activeRepoSettings') + }) + + it('routes create-review field generation through caller-provided owner settings', () => { + const sourceControlCall = sourceBetween( + SOURCE_CONTROL_SOURCE, + '} = useCreatePullRequestDialogFields({', + 'const handleGeneratePullRequestFieldsClick = useCallback' + ) + expect(sourceControlCall).toContain('settings: activeRepoSettings') + + const hookSource = readFileSync(join(__dirname, 'useCreatePullRequestDialogFields.ts'), 'utf8') + const requestContext = sourceBetween(hookSource, 'const requestContext = {', 'const seed = {') + expect(requestContext).toContain('settings,') + expect(requestContext).not.toContain('useAppStore.getState().settings') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 25ef6c1468c..18a3c9cdece 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -122,6 +122,7 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { abortRuntimeGitMerge, abortRuntimeGitRebase, @@ -145,11 +146,6 @@ import { import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' -import { - RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS, - RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS, - RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS -} from './right-sidebar-primary-action-layout' import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' import type { GitHistoryItem } from '../../../../shared/git-history' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' @@ -160,8 +156,6 @@ import type { GitBranchCompareSummary, GitConflictOperation, GitStatusEntry, - GlobalSettings, - Repo, SourceControlViewMode, TuiAgent } from '../../../../shared/types' @@ -214,14 +208,6 @@ import { type PullRequestGenerationContext, type PullRequestGenerationFields } from '@/store/slices/pull-request-generation' -import { - createRunningCommitMessageGenerationRecord, - getCommitMessageGenerationRecordKey, - markCommitMessageGenerationHydrated, - resolveCommitMessageGenerationCancel, - resolveCommitMessageGenerationFailure, - resolveCommitMessageGenerationSuccess -} from '@/store/slices/commit-message-generation' export { appendCommitFailureCustomInstruction, @@ -238,30 +224,6 @@ export { export type SourceControlScope = 'all' | 'uncommitted' type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'> type AbortActionErrorKind = 'abort_merge' | 'abort_rebase' -type CommitMessageGenerationTargetSnapshot = { - worktreeId: string - worktreePath: string - connectionId?: string - repo: Pick<Repo, 'id' | 'sourceControlAi'> | null - settings: GlobalSettings | null - discoveryHostKey: string -} -type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & { - target?: CommitMessageGenerationTargetSnapshot -} -type PullRequestGenerationTargetSnapshot = { - worktreeId: string | null - worktreePath: string - connectionId?: string - repo: Pick<Repo, 'id' | 'sourceControlAi'> | null - repoId: string - branch: string - settings: GlobalSettings | null - discoveryHostKey: string - fields: PullRequestGenerationFields - fieldRevisions: PullRequestFieldRevisions -} - export type SourceControlActionError = { kind: RemoteOpKind | AbortActionErrorKind message: string @@ -688,6 +650,12 @@ function SourceControlInner(): React.JSX.Element { const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) const settings = useAppStore((s) => s.settings) + // Why: git/file mutations and repo metadata requests belong to the repo + // OWNER host, not the currently focused host in the sidebar. + const activeRepoSettings = useMemo( + () => getRepoOwnerRoutedSettings(settings, activeRepo ?? null), + [activeRepo, settings] + ) const updateSettings = useAppStore((s) => s.updateSettings) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -883,6 +851,15 @@ function SourceControlInner(): React.JSX.Element { const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false const confirmAction = useConfirmationDialog() const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false + // Why: parallel state to commit. Same per-worktree shape so navigating between + // worktrees mid-generation never silently cancels the in-flight request. + const generateInFlightRef = useRef<Record<string, boolean>>({}) + const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState< + Record<string, boolean> + >({}) + const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({}) + const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false + const generateError = generateErrors[activeWorktreeId ?? ''] ?? null const [hostedReviewCreationState, setHostedReviewCreationState] = useState<HostedReviewCreationState | null>(null) const createPrInFlightRef = useRef<Record<string, boolean>>({}) @@ -892,24 +869,12 @@ function SourceControlInner(): React.JSX.Element { const [createPrErrors, setCreatePrErrors] = useState<Record<string, string | null>>({}) const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null - const commitGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords) - const allocateCommitMessageGenerationRequestId = useAppStore( - (s) => s.allocateCommitMessageGenerationRequestId - ) - const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord) - const updateCommitMessageGenerationRecord = useAppStore( - (s) => s.updateCommitMessageGenerationRecord - ) - const pruneCommitMessageGenerationRecords = useAppStore( - (s) => s.pruneCommitMessageGenerationRecords - ) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) const allocatePullRequestGenerationRequestId = useAppStore( (s) => s.allocatePullRequestGenerationRequestId ) const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) - const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords) const filterInputRef = useRef<HTMLInputElement>(null) const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null @@ -936,16 +901,6 @@ function SourceControlInner(): React.JSX.Element { const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' - const activeCommitGenerationKey = getCommitMessageGenerationRecordKey( - activeWorktreeId, - worktreePath - ) - const activeCommitGenerationRecord = activeCommitGenerationKey - ? (commitGenerationRecords[activeCommitGenerationKey] ?? null) - : null - const isGenerating = activeCommitGenerationRecord?.status === 'running' - const generateError = - activeCommitGenerationRecord?.status === 'failed' ? activeCommitGenerationRecord.error : null const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath, @@ -974,7 +929,8 @@ function SourceControlInner(): React.JSX.Element { } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + // Why: route git status by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -987,6 +943,7 @@ function SourceControlInner(): React.JSX.Element { } }) }, [ + activeRepoSettings, activeWorktreeId, activeWorktree?.pushTarget, fetchUpstreamStatus, @@ -1012,7 +969,9 @@ function SourceControlInner(): React.JSX.Element { } try { await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + // Why: generation can finish after the user switches hosts; refresh + // the same host that owned the generation request. + settings: context.runtimeTargetSettings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId, @@ -1051,7 +1010,7 @@ function SourceControlInner(): React.JSX.Element { setDefaultBaseRef(null) let stale = false - void getRuntimeRepoBaseRefDefault(useAppStore.getState().settings, activeRepo.id) + void getRuntimeRepoBaseRefDefault(activeRepoSettings, activeRepo.id) .then((result) => { if (!stale) { // Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope; @@ -1073,7 +1032,7 @@ function SourceControlInner(): React.JSX.Element { return () => { stale = true } - }, [activeRepo, isBranchVisible, isFolder]) + }, [activeRepo, activeRepoSettings, isBranchVisible, isFolder]) const normalizedWorktreeBaseRef = activeWorktree?.baseRef?.trim() || null const normalizedRepoBaseRef = activeRepo?.worktreeBaseRef?.trim() || null @@ -1103,7 +1062,8 @@ function SourceControlInner(): React.JSX.Element { branchName, settings, activeRepo.id, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) : null const hostedReviewEntry = hostedReviewCacheKey @@ -1116,7 +1076,8 @@ function SourceControlInner(): React.JSX.Element { activeRepo.id, branchName, settings, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) : null const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null @@ -1321,7 +1282,7 @@ function SourceControlInner(): React.JSX.Element { handleSavePullRequestGenerationDefaults, openSourceControlAiSettings } = useSourceControlAi({ - settings, + settings: activeRepoSettings, activeRepo: activeRepo ?? null, activeWorktreeId, activeConnectionId, @@ -1339,70 +1300,17 @@ function SourceControlInner(): React.JSX.Element { openSettingsPage }) - const [commitGenerationDialogTarget, setCommitGenerationDialogTarget] = - useState<CommitMessageGenerationTargetSnapshot | null>(null) - const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] = - useState<PullRequestGenerationTargetSnapshot | null>(null) - - const createCommitMessageGenerationTarget = useCallback(() => { - if (!activeWorktreeId || !worktreePath) { - return null - } - return { - worktreeId: activeWorktreeId, - worktreePath, - connectionId: activeConnectionId ?? undefined, - repo: activeRepo ?? null, - settings, - discoveryHostKey: sourceControlAiDiscoveryHostKey - } - }, [ - activeConnectionId, - activeRepo, - activeWorktreeId, - settings, - sourceControlAiDiscoveryHostKey, - worktreePath - ]) - - const handleCommitGenerationDialogOpenChange = useCallback( - (open: boolean): void => { - setCommitGenerationDialogOpen(open) - if (!open) { - setCommitGenerationDialogTarget(null) - } - }, - [setCommitGenerationDialogOpen] - ) - - const handlePullRequestGenerationDialogOpenChange = useCallback( - (open: boolean): void => { - setPullRequestGenerationDialogOpen(open) - if (!open) { - setPullRequestGenerationDialogTarget(null) - } - }, - [setPullRequestGenerationDialogOpen] - ) - // Why: orphaned draft/error/in-flight entries accumulate when worktrees are // removed from the store (long sessions with many create/destroy cycles). // Prune them so a deleted-then-reused worktree ID doesn't inherit stale // state — especially commitInFlightRef, which would permanently disable // Commit for that ID if left stuck at `true`. useEffect(() => { - const liveWorktreeKeys = new Set<string>() - for (const worktree of worktreeMap.values()) { - liveWorktreeKeys.add(worktree.id) - if (worktree.path.trim()) { - liveWorktreeKeys.add(worktree.path) - } - } const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => { let changed = false const next: Record<string, T> = {} for (const key of Object.keys(prev)) { - if (liveWorktreeKeys.has(key)) { + if (worktreeMap.has(key)) { next[key] = prev[key] } else { changed = true @@ -1415,21 +1323,26 @@ function SourceControlInner(): React.JSX.Element { setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) + setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) + setGenerateErrors((prev) => pruneRecord(prev)) setGitHistoryByWorktree((prev) => pruneRecord(prev)) - pruneCommitMessageGenerationRecords(liveWorktreeKeys) - prunePullRequestGenerationRecords(liveWorktreeKeys) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { - if (!liveWorktreeKeys.has(key)) { + if (!worktreeMap.has(key)) { delete commitInFlightRef.current[key] } } + for (const key of Object.keys(generateInFlightRef.current)) { + if (!worktreeMap.has(key)) { + delete generateInFlightRef.current[key] + } + } for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { - if (!liveWorktreeKeys.has(key)) { + if (!worktreeMap.has(key)) { delete gitHistoryRequestByWorktreeRef.current[key] } } - }, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap]) + }, [worktreeMap]) useEffect(() => { // Why: users often finish merge/rebase conflicts in a terminal. Once git @@ -1496,7 +1409,8 @@ function SourceControlInner(): React.JSX.Element { try { const commitResult = await commitRuntimeGit( { - settings: useAppStore.getState().settings, + // Why: route the commit by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -1563,6 +1477,7 @@ function SourceControlInner(): React.JSX.Element { commitInFlightRef.current[activeWorktreeId] = false } }, [ + activeRepoSettings, activeWorktreeId, beginGitBranchCompareRequest, commitMessage, @@ -1574,223 +1489,119 @@ function SourceControlInner(): React.JSX.Element { ]) const handleGenerate = useCallback( - async (options?: CommitMessageGenerationOptions): Promise<void> => { - const target = options?.target ?? createCommitMessageGenerationTarget() - if (!target) { + async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise<void> => { + if (!activeWorktreeId || !worktreePath) { return } - const overrides: RuntimeGenerateCommitMessageOverrides = { - ...(options?.sourceControlAiResolvedParams - ? { sourceControlAiResolvedParams: options.sourceControlAiResolvedParams } - : {}), - ...(options?.sourceControlAi ? { sourceControlAi: options.sourceControlAi } : {}), - ...(options?.agentCmdOverrides ? { agentCmdOverrides: options.agentCmdOverrides } : {}) - } - const { worktreeId, worktreePath: generationWorktreePath, connectionId } = target - const generationKey = getCommitMessageGenerationRecordKey(worktreeId, generationWorktreePath) - if (!generationKey) { + if (generateInFlightRef.current[activeWorktreeId]) { return } - if ( - useAppStore.getState().commitMessageGenerationRecords[generationKey]?.status === 'running' - ) { - return - } - if (!overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) { + if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) { return } if ( - !overrides.sourceControlAiResolvedParams && + !overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok === true && isCustomAgentId(resolvedCommitMessageAi.value.params.agentId) ) { const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? '' if (!command) { - const requestId = allocateCommitMessageGenerationRequestId() - const failedRecord = resolveCommitMessageGenerationFailure({ - record: createRunningCommitMessageGenerationRecord({ - worktreeId, - worktreePath: generationWorktreePath, - connectionId, - requestId, - runtimeTargetSettings: { - activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null - } - }), - requestId, - error: translate( - 'auto.components.right.sidebar.SourceControl.e9e238b260', + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.' - ) - }) - if (failedRecord) { - setCommitMessageGenerationRecord(generationKey, failedRecord) - } + })) return } } - const requestId = allocateCommitMessageGenerationRequestId() - // Why: Stop must route to the runtime selected at generation start, even - // if the user changes settings before cancellation. - setCommitMessageGenerationRecord( - generationKey, - createRunningCommitMessageGenerationRecord({ - worktreeId, - worktreePath: generationWorktreePath, - connectionId, - requestId, - runtimeTargetSettings: { - activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null - } - }) - ) + generateInFlightRef.current[activeWorktreeId] = true + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { const result = await generateRuntimeCommitMessage( { - settings: target.settings ?? useAppStore.getState().settings, - worktreeId, - worktreePath: generationWorktreePath, + // Why: route generation by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, connectionId }, overrides ) if (!result.success) { - updateCommitMessageGenerationRecord(generationKey, (record) => - resolveCommitMessageGenerationFailure({ - record, - requestId, - canceled: result.canceled, - error: result.canceled ? null : result.error - }) - ) + // Why: cancellation is a deliberate user action, not a failure to + // surface. Clear any prior error and stay quiet. + if (result.canceled) { + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + return + } + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: result.error + })) return } + // Why: race protection — the user may have started typing into the + // textarea while the agent was running. In that case we silently drop + // the generated message rather than overwrite their in-progress edits. + setCommitDrafts((prev) => { + const current = prev[activeWorktreeId] + if (current && current.length > 0) { + return prev + } + return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message) + }) useAppStore.getState().recordFeatureInteraction('ai-commit-generation') - updateCommitMessageGenerationRecord(generationKey, (record) => - resolveCommitMessageGenerationSuccess({ - record, - requestId, - message: result.message - }) - ) + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) } catch (error) { - updateCommitMessageGenerationRecord(generationKey, (record) => - resolveCommitMessageGenerationFailure({ - record, - requestId, - error: error instanceof Error ? error.message : 'Failed to generate commit message' - }) - ) + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: + error instanceof Error ? error.message : 'Failed to generate commit message' + })) + } finally { + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + generateInFlightRef.current[activeWorktreeId] = false } }, - [ - allocateCommitMessageGenerationRequestId, - createCommitMessageGenerationTarget, - resolvedCommitMessageAi, - setCommitMessageGenerationRecord, - updateCommitMessageGenerationRecord - ] + [activeRepoSettings, activeWorktreeId, resolvedCommitMessageAi, worktreePath] ) - useEffect(() => { - if ( - !activeCommitGenerationKey || - !activeCommitGenerationRecord || - activeCommitGenerationRecord.status !== 'succeeded' || - !activeCommitGenerationRecord.message || - activeCommitGenerationRecord.hydrated - ) { - return - } - - const { - context: { worktreeId }, - message - } = activeCommitGenerationRecord - // Why: generation can finish while Source Control is showing a different - // worktree or is temporarily unmounted; hydrate the saved result when its - // originating worktree is visible again without overwriting user edits. - setCommitDrafts((prev) => { - const current = prev[worktreeId] - if (current && current.length > 0) { - return prev - } - return writeCommitDraftForWorktree(prev, worktreeId, message) - }) - updateCommitMessageGenerationRecord( - activeCommitGenerationKey, - markCommitMessageGenerationHydrated - ) - }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord]) - const handleGenerateCommitMessageClick = useCallback((): void => { - const target = createCommitMessageGenerationTarget() - if (!target) { - return - } if ( hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) && resolvedCommitMessageAi?.ok ) { - void handleGenerate({ - sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params, - target - }) + void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params }) return } - // Why: the dialog remains open while users can switch worktrees; keep it - // bound to the worktree that requested generation. - setCommitGenerationDialogTarget(target) openCommitGenerationDialog() - }, [ - activeRepo, - createCommitMessageGenerationTarget, - handleGenerate, - openCommitGenerationDialog, - resolvedCommitMessageAi, - settings - ]) + }, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings]) const handleCancelGenerate = useCallback((): void => { - if ( - !activeCommitGenerationKey || - !activeCommitGenerationRecord || - activeCommitGenerationRecord.status !== 'running' - ) { + if (!activeWorktreeId || !worktreePath) { return } - const { context } = activeCommitGenerationRecord - updateCommitMessageGenerationRecord( - activeCommitGenerationKey, - resolveCommitMessageGenerationCancel - ) + if (!generateInFlightRef.current[activeWorktreeId]) { + return + } + const connectionId = getConnectionId(activeWorktreeId) ?? undefined // Why: fire-and-forget — the in-flight generateCommitMessage promise - // resolves with `{canceled: true}` once the kill propagates, while the - // durable record lets the visible spinner clear immediately. + // resolves with `{canceled: true}` once the kill propagates, which is + // where the spinner is cleared. Awaiting here would just delay UI feedback. void cancelRuntimeGenerateCommitMessage({ - settings: context.runtimeTargetSettings, - worktreeId: context.worktreeId, - worktreePath: context.worktreePath, - connectionId: context.connectionId - }).catch((error) => { - updateCommitMessageGenerationRecord(activeCommitGenerationKey, (record) => { - if (!record || record.context.requestId !== context.requestId) { - return null - } - return { - ...record, - status: 'failed', - error: - error instanceof Error ? error.message : 'Failed to stop commit message generation', - hydrated: false - } - }) + // Why: route the cancel by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId }) - }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord]) + }, [activeRepoSettings, activeWorktreeId, worktreePath]) // Why: a single dispatcher for every remote-only action the split button or // chevron dropdown can trigger. Keeps the error-swallow pattern in one @@ -1820,7 +1631,8 @@ function SourceControlInner(): React.JSX.Element { worktreePath, true, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1832,7 +1644,9 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - forceWithLease ? { forceWithLease: true } : undefined + forceWithLease + ? { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } + : { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1843,12 +1657,20 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - { forceWithLease: true } + { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } ) return } if (kind === 'pull') { - await pullBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await pullBranch( + activeWorktreeId, + worktreePath, + connectionId, + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } + ) return } if (kind === 'fast_forward') { @@ -1856,7 +1678,8 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1865,7 +1688,10 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } ) return } @@ -1878,11 +1704,14 @@ function SourceControlInner(): React.JSX.Element { worktreePath, effectiveBaseRef, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } - await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget, { + runtimeTargetSettings: activeRepoSettings + }) setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep @@ -1905,6 +1734,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchBranch, @@ -1952,7 +1782,8 @@ function SourceControlInner(): React.JSX.Element { setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { const context = { - settings: useAppStore.getState().settings, + // Why: route the abort by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -1983,6 +1814,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, confirmAction, conflictOperation, @@ -2118,90 +1950,44 @@ function SourceControlInner(): React.JSX.Element { await refreshActiveGitStatusAfterMutation() }, [refreshActiveGitStatusAfterMutation]) - const createPullRequestGenerationTarget = useCallback( - ( - fields: PullRequestGenerationFields, - fieldRevisions: PullRequestFieldRevisions - ): PullRequestGenerationTargetSnapshot | null => { - if (!activeRepo || !worktreePath || !branchName) { - return null - } - return { - worktreeId: activeWorktreeId, - worktreePath, - connectionId: activeConnectionId ?? undefined, - repo: activeRepo, - repoId: activeRepo.id, - branch: branchName, - settings, - discoveryHostKey: sourceControlAiDiscoveryHostKey, - fields: { ...fields }, - fieldRevisions: { ...fieldRevisions } - } - }, - [ - activeConnectionId, - activeRepo, - activeWorktreeId, - branchName, - settings, - sourceControlAiDiscoveryHostKey, - worktreePath - ] - ) - const handleGeneratePullRequestFieldsForActive = useCallback( async ( fields: PullRequestGenerationFields, fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides, - targetOverride?: PullRequestGenerationTargetSnapshot + overrides?: RuntimeGeneratePullRequestFieldsOverrides ): Promise<void> => { - const target = targetOverride ?? createPullRequestGenerationTarget(fields, fieldRevisions) - if (!target) { - return - } - const generationKey = getPullRequestGenerationRecordKey({ - worktreeId: target.worktreeId, - worktreePath: target.worktreePath, - repoId: target.repoId, - branch: target.branch - }) - if (!generationKey) { + if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) { return } + const generationKey = activePullRequestGenerationKey if ( useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' ) { return } const requestId = allocatePullRequestGenerationRequestId() - // Why: Stop must route to the runtime selected at generation start, even - // if the user changes settings before cancellation. const context: PullRequestGenerationContext = { - worktreeId: target.worktreeId, - worktreePath: target.worktreePath, - connectionId: target.connectionId, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: getConnectionId(activeWorktreeId) ?? undefined, requestId, - repoId: target.repoId, - branch: target.branch, - runtimeTargetSettings: { - activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null - } + repoId: activeRepo.id, + branch: branchName, + runtimeTargetSettings: activeRepoSettings } - const seed = { ...target.fields } - // Why: SourceControl can unmount on tab/worktree switches; the record is - // keyed to the originating repo/worktree/branch so stale UI cannot retarget - // or orphan the generated hosted-review fields. + const seed = { ...fields } + // Why: SourceControl can unmount on tab switches; persisting the running + // record lets the embedded PR composer resume when the user returns. setPullRequestGenerationRecord( generationKey, - createRunningPullRequestGenerationRecord(context, seed, target.fieldRevisions) + createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) ) try { const result = await generateRuntimePullRequestFields( { - settings: target.settings ?? useAppStore.getState().settings, + // Why: route generation by the repo OWNER host, not the focused runtime. + settings: context.runtimeTargetSettings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -2255,11 +2041,16 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activePullRequestGenerationKey, + activeRepo, + activeRepoSettings, + activeWorktreeId, allocatePullRequestGenerationRequestId, - createPullRequestGenerationTarget, + branchName, refreshGitStatusAfterPullRequestGeneration, setPullRequestGenerationRecord, - updatePullRequestGenerationRecord + updatePullRequestGenerationRecord, + worktreePath ] ) @@ -2279,6 +2070,8 @@ function SourceControlInner(): React.JSX.Element { return resolvePullRequestGenerationCancel(current) }) void cancelRuntimeGeneratePullRequestFields({ + // Why: the user can switch hosts while generation runs; cancel the + // original request owner instead of the current focused host. settings: record.context.runtimeTargetSettings, worktreeId: record.context.worktreeId, worktreePath: record.context.worktreePath, @@ -2308,7 +2101,6 @@ function SourceControlInner(): React.JSX.Element { setBody: setPrBody, draft: prDraft, setDraft: setPrDraft, - fieldRevisions: prFieldRevisions, baseQuery: prBaseQuery, setBaseQuery: setPrBaseQuery, baseResults: prBaseResults, @@ -2329,7 +2121,7 @@ function SourceControlInner(): React.JSX.Element { branch: branchName, eligibility: hostedReviewCreation, repo: activeRepo ?? null, - settings, + settings: activeRepoSettings, submitting: isCreatingPr, prCreationDefaults: resolvedPrCreationDefaults, onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration, @@ -2354,36 +2146,10 @@ function SourceControlInner(): React.JSX.Element { void handleGeneratePullRequestFields() return } - const target = createPullRequestGenerationTarget( - { base: prBase, title: prTitle, body: prBody, draft: prDraft }, - prFieldRevisions - ) - if (!target) { - return - } - // Why: the agent-picker dialog can stay open while the user switches - // worktrees; keep Generate bound to the PR draft that opened it. - setPullRequestGenerationDialogTarget(target) openPullRequestGenerationDialog() - }, [ - activeRepo, - createPullRequestGenerationTarget, - handleGeneratePullRequestFields, - openPullRequestGenerationDialog, - prBase, - prBody, - prDraft, - prFieldRevisions, - prTitle, - settings - ]) + }, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog, settings]) useEffect(() => { - // Why: after a SourceControl remount, the PR composer first reseeds from - // eligibility; hydrate generated fields only after that seed exists. - if (hostedReviewCreation?.canCreate !== true) { - return - } if ( !activePullRequestGenerationKey || !activePullRequestGenerationRecord || @@ -2418,7 +2184,6 @@ function SourceControlInner(): React.JSX.Element { activePullRequestGenerationKey, activePullRequestGenerationRecord, applyGeneratedPullRequestFields, - hostedReviewCreation?.canCreate, updatePullRequestGenerationRecord ]) @@ -2439,6 +2204,7 @@ function SourceControlInner(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: activeRepo.path, + repoId: activeRepo.id, ...(worktreePath ? { worktreePath } : {}), branch: branchName, base: effectiveBaseRef ?? null, @@ -2533,6 +2299,7 @@ function SourceControlInner(): React.JSX.Element { setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { const result = await createHostedReview(activeRepo.path, { + repoId: activeRepo.id, provider: hostedReviewCreateProvider, base, head: normalizeHostedReviewHeadRef(branchName), @@ -2924,7 +2691,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2937,6 +2705,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, bulkStagePaths, clearSelection, @@ -2953,7 +2722,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2966,6 +2736,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, bulkUnstagePaths, clearSelection, @@ -2983,7 +2754,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2997,6 +2769,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, clearSelection, isExecutingBulk, @@ -3015,7 +2788,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3029,6 +2803,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, clearSelection, isExecutingBulk, @@ -3055,7 +2830,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3069,6 +2845,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, worktreePath, grouped, activeWorktreeId, @@ -3098,7 +2875,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3111,6 +2889,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, isExecutingBulk, grouped, @@ -3154,7 +2933,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3167,6 +2947,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, grouped.staged, activeWorktreeId, @@ -3213,7 +2994,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const result = await getRuntimeGitBranchCompare( { - settings: useAppStore.getState().settings, + // Why: route the branch compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3237,6 +3019,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, [ + activeRepoSettings, activeWorktreeId, beginGitBranchCompareRequest, branchName, @@ -3310,7 +3093,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(worktreeId) ?? undefined const result = await getRuntimeGitHistory( { - settings: useAppStore.getState().settings, + // Why: route the history read by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId, worktreePath, connectionId @@ -3337,6 +3121,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, [ + activeRepoSettings, activeWorktreeId, effectiveBaseRef, isBranchVisible, @@ -3393,9 +3178,11 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchUpstreamStatus, @@ -3460,7 +3247,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId) ?? undefined const result = await getRuntimeGitCommitCompare( { - settings: useAppStore.getState().settings, + // Why: route the commit compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3496,7 +3284,7 @@ function SourceControlInner(): React.JSX.Element { ) } }, - [activeWorktreeId, openCommitAllDiffs, worktreePath] + [activeRepoSettings, activeWorktreeId, openCommitAllDiffs, worktreePath] ) // Why: a note's filePath is the same relative path used by GitStatusEntry / @@ -3613,7 +3401,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await stageRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3625,7 +3414,7 @@ function SourceControlInner(): React.JSX.Element { // git operation failed silently } }, - [worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) const handleUnstage = useCallback( @@ -3637,7 +3426,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await unstageRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3649,7 +3439,7 @@ function SourceControlInner(): React.JSX.Element { // git operation failed silently } }, - [worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) // Why: split into two variants — `discardSingle` throws so bulk callers can @@ -3672,7 +3462,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await discardRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route the discard by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3685,7 +3476,7 @@ function SourceControlInner(): React.JSX.Element { relativePath: filePath }) }, - [activeWorktreeId, worktreePath] + [activeRepoSettings, activeWorktreeId, worktreePath] ) const discardMany = useCallback( @@ -3708,7 +3499,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await bulkDiscardRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route the discard by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3723,7 +3515,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, - [activeWorktreeId, worktreePath] + [activeRepoSettings, activeWorktreeId, worktreePath] ) const handleDiscard = useCallback( @@ -3769,7 +3561,8 @@ function SourceControlInner(): React.JSX.Element { bulkUnstage: (filePaths) => bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3826,6 +3619,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, worktreePath, activeWorktreeId, grouped, @@ -4724,7 +4518,7 @@ function SourceControlInner(): React.JSX.Element { )} description={translate( 'auto.components.right.sidebar.SourceControl.901140f47d', - 'Review the prompt before starting an agent.' + 'Review and edit the full command input before starting an agent.' )} baseCommandInput={resolveConflictsPrompt} worktreeId={activeWorktreeId} @@ -4754,7 +4548,7 @@ function SourceControlInner(): React.JSX.Element { /> <SourceControlTextGenerationDialog open={commitGenerationDialogOpen} - onOpenChange={handleCommitGenerationDialogOpenChange} + onOpenChange={setCommitGenerationDialogOpen} actionId="commitMessage" title={translate( 'auto.components.right.sidebar.SourceControl.6b122529d4', @@ -4765,23 +4559,17 @@ function SourceControlInner(): React.JSX.Element { 'Choose the agent and command template for this run.' )} generateLabel="Generate" - settings={commitGenerationDialogTarget?.settings ?? settings} - repo={commitGenerationDialogTarget?.repo ?? activeRepo ?? null} - discoveryHostKey={ - commitGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey - } + settings={settings} + repo={activeRepo ?? null} + discoveryHostKey={sourceControlAiDiscoveryHostKey} onGenerate={(params) => { - const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget() - if (!target) { - return - } - void handleGenerate({ sourceControlAiResolvedParams: params, target }) + void handleGenerate({ sourceControlAiResolvedParams: params }) }} onSaveDefaults={handleSaveCommitMessageGenerationDefaults} /> <SourceControlTextGenerationDialog open={pullRequestGenerationDialogOpen} - onOpenChange={handlePullRequestGenerationDialogOpenChange} + onOpenChange={setPullRequestGenerationDialogOpen} actionId="pullRequest" title={translate( 'auto.components.right.sidebar.SourceControl.1a6a6e0bc5', @@ -4792,27 +4580,11 @@ function SourceControlInner(): React.JSX.Element { 'Choose the agent and command template for this run.' )} generateLabel="Generate" - settings={pullRequestGenerationDialogTarget?.settings ?? settings} - repo={pullRequestGenerationDialogTarget?.repo ?? activeRepo ?? null} - discoveryHostKey={ - pullRequestGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey - } + settings={settings} + repo={activeRepo ?? null} + discoveryHostKey={sourceControlAiDiscoveryHostKey} onGenerate={(params) => { - const target = - pullRequestGenerationDialogTarget ?? - createPullRequestGenerationTarget( - { base: prBase, title: prTitle, body: prBody, draft: prDraft }, - prFieldRevisions - ) - if (!target) { - return - } - void handleGeneratePullRequestFieldsForActive( - target.fields, - target.fieldRevisions, - { sourceControlAiResolvedParams: params }, - target - ) + void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params }) }} onSaveDefaults={handleSavePullRequestGenerationDefaults} /> @@ -4879,7 +4651,7 @@ function CommitFailureFixSplitButton({ return ( <> <DropdownMenu> - <div className="inline-flex shrink-0 max-w-full items-stretch"> + <div className="flex shrink-0 items-stretch"> <Button type="button" variant={variant} @@ -4956,7 +4728,7 @@ function CommitFailureFixSplitButton({ )} description={translate( 'auto.components.right.sidebar.SourceControl.15b7f210d7', - 'Review the prompt before starting an agent.' + 'Choose the agent and edit the full command input before launch.' )} baseCommandInput={prompt} worktreeId={worktreeId} @@ -5272,7 +5044,7 @@ export function CommitArea({ chevron exposes the full action surface (fetch, pull, sync, publish, compound commits) without forcing morphing labels to carry every possible intent. */} - <div className={cn(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS, showComposer && 'mt-1')}> + <div className={cn(showComposer ? 'mt-1 flex items-stretch' : 'flex items-stretch')}> {/* Why: match the hosted-review action buttons in Checks (size="xs", px-3 text-[11px]) so the sidebar has a consistent action-button shape across Source Control and Checks. The primary @@ -5281,16 +5053,13 @@ export function CommitArea({ pair read as one split button instead of two detached buttons. */} <Tooltip> <TooltipTrigger asChild> - <span className="inline-flex min-w-0 max-w-full shrink"> + <span className="flex flex-1"> <Button type="button" size="xs" disabled={primaryAction.disabled} onClick={() => onPrimaryAction()} - className={cn( - 'rounded-r-none px-3 text-[11px]', - RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS - )} + className="w-full rounded-r-none px-3 text-[11px]" title={primaryAction.title} > {showSpinner ? ( @@ -5298,9 +5067,7 @@ export function CommitArea({ ) : PrimaryIcon ? ( <PrimaryIcon className="size-3.5" aria-hidden="true" /> ) : null} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {primaryAction.label} - </span> + {primaryAction.label} </Button> </span> </TooltipTrigger> @@ -5524,7 +5291,7 @@ export function CommitArea({ id="commit-area-remote-error" role="alert" aria-live="polite" - className="mt-1 min-w-0 text-[11px] leading-4 break-words text-destructive [overflow-wrap:anywhere]" + className="mt-1 text-[11px] text-destructive" > {remoteActionError} </p> @@ -5992,6 +5759,14 @@ function DiffCommentsInlineList({ ) } +function conflictAbortButtonVariant( + conflictOperation: GitConflictOperation +): 'outline' | 'destructive' { + // Why: aborting a rebase is the escape hatch for this state, so it should + // match the quiet outline conflict-review action instead of reading as red. + return conflictOperation === 'rebase' ? 'outline' : 'destructive' +} + export function ConflictSummaryCard({ conflictOperation, unresolvedCount, @@ -6038,12 +5813,12 @@ export function ConflictSummaryCard({ </div> </div> </div> - <div className="mt-2 flex flex-col items-start"> + <div className="mt-2"> <Button type="button" variant="default" size="sm" - className="h-7 text-xs" + className="h-7 w-full text-xs" disabled={isResolvingWithAI} onClick={onResolveWithAI} > @@ -6058,7 +5833,7 @@ export function ConflictSummaryCard({ type="button" variant="outline" size="sm" - className="mt-1.5 h-7 text-xs" + className="mt-1.5 h-7 w-full text-xs" onClick={onReview} > <GitMerge className="size-3.5" /> @@ -6067,11 +5842,9 @@ export function ConflictSummaryCard({ {(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? ( <Button type="button" - // Why: abort is the escape hatch for this state, so match the quiet - // outline conflict-review action instead of reading as destructive. - variant="outline" + variant={conflictAbortButtonVariant(conflictOperation)} size="sm" - className="mt-1.5 h-7 text-xs" + className="mt-1.5 h-7 w-full text-xs" disabled={isResolvingWithAI || isAbortingOperation} onClick={() => onAbortOperation(conflictOperation)} > @@ -6113,28 +5886,24 @@ export function OperationBanner({ return ( <div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2"> - <div className="flex items-center gap-2"> + <div className="flex items-center justify-center gap-2"> <Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" /> <span className="text-xs font-medium text-foreground">{label}</span> </div> {(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? ( - <div className="mt-2 flex flex-col items-start"> - <Button - type="button" - // Why: abort is the escape hatch for this state, so match the quiet - // outline conflict-review action instead of reading as destructive. - variant="outline" - size="sm" - className="h-7 text-xs" - disabled={isAbortingOperation} - onClick={() => onAbortOperation(conflictOperation)} - > - {isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null} - {conflictOperation === 'rebase' - ? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase') - : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')} - </Button> - </div> + <Button + type="button" + variant={conflictAbortButtonVariant(conflictOperation)} + size="sm" + className="mt-2 h-7 w-full text-xs" + disabled={isAbortingOperation} + onClick={() => onAbortOperation(conflictOperation)} + > + {isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null} + {conflictOperation === 'rebase' + ? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase') + : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')} + </Button> ) : null} </div> ) diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.ts b/src/renderer/src/components/right-sidebar/active-checks-status.ts index 4aab1785016..f3db9b1a2f7 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -39,14 +39,16 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat activeRepo.id, branch, state.settings, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) const hostedReviewCacheKey = getHostedReviewCacheKey( activeRepo.path, branch, state.settings, activeRepo.id, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) const hostedReview = state.hostedReviewCache?.[hostedReviewCacheKey]?.data ?? null if (hostedReview && hostedReview.provider !== 'github') { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index b618b8eaf1f..4051469e0c0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -83,10 +83,7 @@ import { RightPanelCommentComposer, type RightPanelCommentSubmitResult } from './right-panel-comment-composer' -import { - type PRCommentsListSelectionClearRequest, - usePRCommentsListSelection -} from './pr-comments-list-selection' +import { usePRCommentsListSelection } from './pr-comments-list-selection' import { translate } from '@/i18n/i18n' export const PullRequestIcon = GitPullRequest @@ -1232,7 +1229,7 @@ export function ChecksList({ <div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground"> {translate( 'auto.components.right.sidebar.checks.panel.content.991f50c7e4', - 'No checks reported yet' + 'No checks configured' )} </div> ) : !checksExpanded ? null : ( @@ -1983,10 +1980,10 @@ function scrollElementBottomIntoView(element: HTMLElement): void { export function PRCommentsList({ comments, commentsLoading, + reviewKind = 'PR', commentsDisabled, commentsDisabledReason, selectionContextKey, - selectionClearRequest, resolveCommentsWithAIDisabled, resolveCommentsWithAIDisabledReason, onAddComment, @@ -1998,10 +1995,10 @@ export function PRCommentsList({ }: { comments: PRComment[] commentsLoading: boolean + reviewKind?: 'PR' | 'MR' commentsDisabled?: boolean commentsDisabledReason?: string selectionContextKey?: string - selectionClearRequest?: PRCommentsListSelectionClearRequest | null resolveCommentsWithAIDisabled?: boolean resolveCommentsWithAIDisabledReason?: string onAddComment?: (body: string) => Promise<RightPanelCommentSubmitResult> @@ -2026,7 +2023,7 @@ export function PRCommentsList({ addGroupToSelection, clearSelection, toggleGroupSelection - } = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest) + } = usePRCommentsListSelection(comments, selectionContextKey) const visibleComments = React.useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] @@ -2168,7 +2165,7 @@ export function PRCommentsList({ return ( <div className="border-t border-border"> {/* Header */} - <div className="sticky top-0 z-10 flex flex-col gap-2.5 border-b border-border bg-background px-3 py-2.5"> + <div className="flex flex-col gap-2.5 border-b border-border px-3 py-2.5"> <div className="flex min-w-0 items-center gap-2"> <MessageSquare className="size-3.5 text-muted-foreground" /> <span className="text-[11px] font-medium text-foreground"> @@ -2189,7 +2186,8 @@ export function PRCommentsList({ className="text-muted-foreground hover:text-foreground" aria-label={translate( 'auto.components.right.sidebar.checks.panel.content.d7a2f9c401', - 'Send all unresolved' + 'Send unresolved {{value0}} comments', + { value0: reviewKind } )} disabled={commentsLoading || resolveCommentsWithAIDisabled} title={ @@ -2207,7 +2205,8 @@ export function PRCommentsList({ ? resolveCommentsWithAIDisabledReason : translate( 'auto.components.right.sidebar.checks.panel.content.d7a2f9c401', - 'Send all unresolved' + 'Send unresolved {{value0}} comments', + { value0: reviewKind } )} </TooltipContent> </Tooltip> diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts new file mode 100644 index 00000000000..27ae54c2b51 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = process.cwd() + +function source(path: string): string { + return readFileSync(join(root, path), 'utf8') +} + +describe('right sidebar file/git runtime ownership boundaries', () => { + it.each([ + 'src/renderer/src/components/right-sidebar/useFileExplorerTree.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerImport.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', + 'src/renderer/src/components/right-sidebar/useFileDuplicate.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts', + 'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts', + 'src/renderer/src/components/right-sidebar/Search.tsx', + 'src/renderer/src/components/quick-open-file-list.ts' + ])('%s routes file/git requests by the selected worktree owner', (path) => { + const text = source(path) + + expect(text).toMatch( + /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/ + ) + expect(text).not.toContain('settings: useAppStore.getState().settings') + expect(text).not.toContain('const settings = useAppStore.getState().settings') + }) + + it('derives owner settings through the shared worktree runtime owner helper', () => { + const text = source('src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts') + + expect(text).toContain('getSettingsForWorktreeRuntimeOwner') + expect(text).toContain('useAppStore.getState()') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts new file mode 100644 index 00000000000..eddc593d31f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts @@ -0,0 +1,12 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { useAppStore } from '@/store' + +export function getRightSidebarWorktreeRuntimeSettings( + worktreeId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const store = useAppStore.getState() + // Why: right-sidebar file/git actions operate on the selected workspace. + // Route by that workspace owner so global focused-host changes cannot retarget them. + return getSettingsForWorktreeRuntimeOwner(store, worktreeId) +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index bba4521ae80..1613f3c8b54 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -76,7 +76,9 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined) + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { + runtimeTargetSettings: undefined + }) }) it('falls back to explicit upstream refresh for legacy status payloads', async () => { @@ -103,7 +105,9 @@ describe('refreshGitStatusForWorktree', () => { branch: 'refs/heads/main' }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2') + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { + runtimeTargetSettings: undefined + }) }) it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 17d25d58bf5..79c3408da9e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -17,7 +17,8 @@ export type GitStatusRefreshDeps = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: { runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null } ) => Promise<void> } @@ -56,7 +57,9 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status reports Git's configured upstream. Source Control // actions for PR-created worktrees must instead reconcile with Orca's // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: settings + }) return } if (status.upstreamStatus) { @@ -68,11 +71,15 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status has counts but cannot tell stale post-rebase // upstream commits from real remote work. Writing it first makes the // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) return } deps.setUpstreamStatus(worktreeId, status.upstreamStatus) return } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) } diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index e1a496c7619..121c8241504 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -6,16 +6,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import { clearPRCommentsListSelection } from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' -import { - clearPRCommentsListSelection, - type PRCommentsListSelectionClearRequest -} from './pr-comments-list-selection' let container: HTMLDivElement let root: Root beforeEach(() => { + clearPRCommentsListSelection('review:42') container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -26,8 +24,6 @@ afterEach(() => { root.unmount() }) container.remove() - clearPRCommentsListSelection('review:42') - clearPRCommentsListSelection('review:43') }) function comment(overrides: Partial<PRComment>): PRComment { @@ -44,8 +40,6 @@ function comment(overrides: Partial<PRComment>): PRComment { function renderList(props: { comments: PRComment[] - selectionContextKey?: string - selectionClearRequest?: PRCommentsListSelectionClearRequest | null onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void }): void { act(() => { @@ -54,8 +48,7 @@ function renderList(props: { <PRCommentsList comments={props.comments} commentsLoading={false} - selectionContextKey={props.selectionContextKey ?? 'review:42'} - selectionClearRequest={props.selectionClearRequest} + selectionContextKey="review:42" onResolveSelectedCommentsWithAI={props.onResolveSelectedCommentsWithAI ?? vi.fn()} /> </TooltipProvider> @@ -63,21 +56,25 @@ function renderList(props: { }) } -function remountList(): void { - act(() => { - root.unmount() - }) - root = createRoot(container) -} - function clickButton(label: string): void { - const button = [...container.querySelectorAll('button')].find( - (candidate) => - candidate.textContent?.includes(label) || - candidate.getAttribute('aria-label')?.includes(label) - ) + const button = + [...container.querySelectorAll('button')].find( + (candidate) => + candidate.textContent === label || candidate.getAttribute('aria-label') === label + ) ?? + [...container.querySelectorAll('button')].find( + (candidate) => + candidate.textContent?.includes(label) || + candidate.getAttribute('aria-label')?.includes(label) + ) if (!button) { - throw new Error(`Button not found: ${label}`) + const availableButtons = [...container.querySelectorAll('button')] + .map( + (candidate) => + candidate.getAttribute('aria-label') ?? candidate.textContent?.trim() ?? '<unlabeled>' + ) + .join(', ') + throw new Error(`Button not found: ${label}. Available buttons: ${availableButtons}`) } act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -101,13 +98,13 @@ describe('PRCommentsList comment resolution selection', () => { ] }) - expect(hasButton('Send all unresolved')).toBe(false) + expect(hasButton('Send unresolved PR comments')).toBe(false) renderList({ comments: [comment({ id: 4 })] }) - expect(hasButton('Send all unresolved')).toBe(true) + expect(hasButton('Send unresolved PR comments')).toBe(true) expect(container.textContent).toContain('Add') }) @@ -145,7 +142,7 @@ describe('PRCommentsList comment resolution selection', () => { }) clickButton('Humans') - clickButton('Send all unresolved') + clickButton('Send unresolved PR comments') expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1) const selectedGroups = onResolveSelectedCommentsWithAI.mock.calls[0]?.[0] as PRCommentGroup[] @@ -176,7 +173,7 @@ describe('PRCommentsList comment resolution selection', () => { onResolveSelectedCommentsWithAI }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Send 1 queued comments') @@ -200,7 +197,7 @@ describe('PRCommentsList comment resolution selection', () => { onResolveSelectedCommentsWithAI }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Send 1 queued comments') @@ -218,7 +215,7 @@ describe('PRCommentsList comment resolution selection', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Clear queued comments') @@ -227,66 +224,11 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) - it('keeps queued comments when the comments list remounts', () => { - const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] - renderList({ comments }) - clickButton('Add') - - remountList() - renderList({ comments }) - - expect(hasButton('Send 1 queued comments')).toBe(true) - }) - - it('restores the queued comments for the matching review context after switching contexts', () => { - const review42Comments = [ - comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false }) - ] - renderList({ comments: review42Comments, selectionContextKey: 'review:42' }) - clickButton('Add') - - renderList({ - comments: [comment({ id: 2, threadId: 'thread-2', path: 'src/b.ts', isResolved: false })], - selectionContextKey: 'review:43' - }) - expect(hasButton('Send 1 queued comments')).toBe(false) - - renderList({ comments: review42Comments, selectionContextKey: 'review:42' }) - - expect(hasButton('Send 1 queued comments')).toBe(true) - }) - - it('does not drop persisted queued comments while comments reload empty', () => { - const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] - renderList({ comments }) - clickButton('Add') - - renderList({ comments: [] }) - renderList({ comments }) - - expect(hasButton('Send 1 queued comments')).toBe(true) - }) - - it('clears persisted queued comments when the launch path marks them sent', () => { - const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] - renderList({ comments }) - clickButton('Add') - expect(hasButton('Send 1 queued comments')).toBe(true) - - clearPRCommentsListSelection('review:42') - renderList({ - comments, - selectionClearRequest: { contextKey: 'review:42', token: 1 } - }) - - expect(hasButton('Send 1 queued comments')).toBe(false) - }) - it('exits selection mode when refresh leaves no eligible loaded threads', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] }) - clickButton('Add') + clickButton('Add comment to resolve list') renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: true })] diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 525d2460f7f..f5d7c4d726b 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -60,6 +60,7 @@ export function useHostedReviewActions({ const result = isGitLab ? await window.api.gl.mergeMR({ repoPath: repo.path, + repoId: repo.id, iid: review.number, method }) @@ -158,8 +159,16 @@ export function useHostedReviewActions({ try { const result = isGitLab ? isClosing - ? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number }) - : await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number }) + ? await window.api.gl.closeMR({ + repoPath: repo.path, + repoId: repo.id, + iid: review.number + }) + : await window.api.gl.reopenMR({ + repoPath: repo.path, + repoId: repo.id, + iid: review.number + }) : await window.api.gh.updatePRState({ repoPath: repo.path, repoId: repo.id, diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 085d39ffa87..4be16c0ba73 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -346,7 +346,9 @@ export function useCreatePullRequestDialogFields({ generationRequestIdRef.current = requestId const connectionId = getConnectionId(worktreeId) ?? undefined const requestContext = { - settings: useAppStore.getState().settings, + // Why: PR generation belongs to the visible worktree owner. Global + // focused-host changes must not retarget an in-flight generation. + settings, worktreeId, worktreePath, connectionId @@ -418,6 +420,7 @@ export function useCreatePullRequestDialogFields({ generation, generateDisabled, onBranchChangedByGeneration, + settings, title, worktreeId, worktreePath diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 82ef96b1353..e7e7737294e 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -2,9 +2,9 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { basename, dirname, joinPath } from '@/lib/path' import type { TreeNode } from './file-explorer-types' -import { useAppStore } from '@/store' import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' import { getConnectionId } from '@/lib/connection-context' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -42,9 +42,8 @@ export function useFileDuplicate({ const ext = dotIndex > 0 ? name.slice(dotIndex) : '' const run = async (): Promise<void> => { - const settings = useAppStore.getState().settings const context = { - settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index c4f932cc931..79784b72c01 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -12,6 +12,7 @@ import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-f import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { renameRuntimePath } from '@/runtime/runtime-file-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { @@ -233,7 +234,7 @@ export function useFileExplorerDragDrop({ try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const fileContext = { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 2b314c536cf..8e0951cf9bf 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -2,9 +2,9 @@ import { useEffect, useRef } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' -import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { translate } from '@/i18n/i18n' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerImportParams = { worktreePath: string | null @@ -63,10 +63,9 @@ export function useFileExplorerImport({ void (async () => { try { - const settings = useAppStore.getState().settings const { results } = await importExternalPathsToRuntime( { - settings, + settings: getRightSidebarWorktreeRuntimeSettings(wtId), worktreeId: wtId, worktreePath: worktreePathRef.current, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 480ecca2a54..aae50da62dc 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -11,6 +11,7 @@ import type { TreeNode } from './file-explorer-types' import type { FileExplorerRowProjection } from './file-explorer-row-projection' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -110,7 +111,7 @@ export function useFileExplorerInlineInput({ const run = async (): Promise<void> => { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const fileContext = { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index f9b32378ecf..7481bb4c877 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -6,8 +6,8 @@ import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' -import { useAppStore } from '@/store' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerTreeResult = { dirCache: Record<string, DirCache> @@ -63,7 +63,7 @@ export function useFileExplorerTree( const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const entries = await readRuntimeDirectory( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -133,7 +133,7 @@ export function useFileExplorerTree( const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined return statRuntimePath( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index 7839a3b3372..7b87cc5402a 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { isDotfileRelativePath } from './file-explorer-entries' import type { DirCache, TreeNode } from './file-explorer-types' import { @@ -162,7 +163,7 @@ export function useFileExplorerVisibleRowProjection( const connectionId = getConnectionId(activeWorktreeId) ?? undefined void getRuntimeGitIgnoredPaths( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index 6aae3550b35..36145455e8b 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { FsChangedPayload } from '../../../../shared/types' import { canonicalizeFileExplorerWatchPath, + getFileExplorerWatchRuntimeEnvironmentId, getExternalFileChangeRelativePath, payloadRequiresDeferredTreeRefresh } from './useFileExplorerWatch' +import type { AppState } from '@/store/types' describe('getExternalFileChangeRelativePath', () => { it('returns a worktree-relative file path for external file updates', () => { @@ -134,3 +136,70 @@ describe('payloadRequiresDeferredTreeRefresh', () => { expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false) }) }) + +describe('getFileExplorerWatchRuntimeEnvironmentId', () => { + function makeState(args: { + activeRuntimeEnvironmentId?: string | null + executionHostId?: AppState['repos'][number]['executionHostId'] + connectionId?: string | null + }): Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'> { + return { + settings: { + activeRuntimeEnvironmentId: args.activeRuntimeEnvironmentId ?? null + } as AppState['settings'], + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: args.connectionId ?? null, + executionHostId: args.executionHostId + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktree' + } as AppState['worktreesByRepo'][string][number] + ] + } + } + } + + it('uses the active runtime for legacy unowned active worktrees', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ activeRuntimeEnvironmentId: 'focused-runtime' }), + 'wt-1' + ) + ).toBe('focused-runtime') + }) + + it('uses the explicit runtime owner when another host is focused', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ + activeRuntimeEnvironmentId: 'focused-runtime', + executionHostId: 'runtime:owner-runtime' + }), + 'wt-1' + ) + ).toBe('owner-runtime') + }) + + it('keeps explicitly local active worktrees local when a runtime is focused', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ + activeRuntimeEnvironmentId: 'focused-runtime', + executionHostId: 'local' + }), + 'wt-1' + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 5f926604e81..23c718a09d5 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -16,6 +16,8 @@ import { } from './file-explorer-watcher-reconcile' import { useAppStore } from '@/store' import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import type { AppState } from '@/store/types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' type UseFileExplorerWatchParams = { worktreePath: string | null @@ -87,6 +89,13 @@ export function payloadRequiresDeferredTreeRefresh( return payload.events.some((evt) => evt.kind === 'rename') } +export function getFileExplorerWatchRuntimeEnvironmentId( + state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>, + activeWorktreeId: string | null +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) +} + /** * Reconciles File Explorer state on filesystem events for the active worktree. * @@ -110,7 +119,11 @@ export function useFileExplorerWatch({ dragSourcePath, isNativeDragOver }: UseFileExplorerWatchParams): void { - const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + // Why: Explorer subscriptions are for the selected worktree. Host focus is + // only a default for legacy untagged worktrees, not an ownership signal. + const activeRuntimeEnvironmentId = useAppStore((s) => + getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId) + ) // Keep refs for values accessed inside the event handler to avoid // re-subscribing the IPC listener on every render. diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index a07e9e8042c..9f045022168 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -163,7 +163,15 @@ describe('useGitStatusPolling', () => { }) expect(state.setUpstreamStatus).not.toHaveBeenCalled() - expect(state.fetchUpstreamStatus).toHaveBeenCalledWith(worktree.id, '/repo', undefined) + expect(state.fetchUpstreamStatus).toHaveBeenCalledWith( + worktree.id, + '/repo', + undefined, + undefined, + { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + } + ) }) it('passes the explicit push target to upstream refreshes', async () => { @@ -182,7 +190,10 @@ describe('useGitStatusPolling', () => { worktree.id, '/repo', undefined, - pushTarget + pushTarget, + { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + } ) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 3e5013bc628..d3e09f6fae0 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -9,6 +9,7 @@ import { refreshGitStatusForWorktree } from './git-status-refresh' import { createCoalescedPollRunner } from './coalesced-poll-runner' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' const POLL_INTERVAL_MS = 3000 @@ -93,7 +94,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { try { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -175,7 +176,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { continue } const op = (await getRuntimeGitConflictOperation({ - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(id), worktreeId: id, worktreePath: path, connectionId diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 650d85b231d..08121326730 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -33,6 +33,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' +import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' export { getAgentsPaneSearchEntries } from './agents-search' @@ -612,6 +613,7 @@ export function AgentsPane({ ) const defaultAgent = settings.defaultTuiAgent + const agentOwnership = getSettingOwnershipSummary('agentLaunchDefaults') const cmdOverrides = settings.agentCmdOverrides ?? {} const agentDefaultArgs = settings.agentDefaultArgs ?? {} const agentDefaultEnv = settings.agentDefaultEnv ?? {} @@ -695,10 +697,7 @@ export function AgentsPane({ <section className="space-y-4"> <SettingsSubsectionHeader title={translate('auto.components.settings.AgentsPane.385212c7a1', 'Default Agent')} - description={translate( - 'auto.components.settings.AgentsPane.9b175d0f5e', - 'Pre-selected agent when opening a new workspace.' - )} + description={agentOwnership.description} /> <div className="flex flex-wrap gap-2"> diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index 6c0513e5dbe..8e668ebd4e8 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '../ui/scroll-area' import { Button } from '../ui/button' import { Input } from '../ui/input' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -23,8 +24,8 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repoId) ) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index ae43e19ef74..30a747291d6 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useMemo, useRef, useState, type MutableRefObject } from 'react' import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' import { matchesSettingsSearch } from './settings-search' @@ -16,7 +16,15 @@ import { createBrowserHomePageDraftState, resolveBrowserHomePageDraftState } from './browser-home-page-draft-state' +import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers' +import { translate } from '@/i18n/i18n' export { getBrowserPaneCombinedSearchEntries } type BrowserPaneProps = { @@ -45,6 +53,12 @@ export function BrowserPane({ const browserDefaultZoomLevel = useAppStore((s) => s.browserDefaultZoomLevel) const setBrowserDefaultZoomLevel = useAppStore((s) => s.setBrowserDefaultZoomLevel) const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const repos = useAppStore((s) => s.repos) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId) @@ -87,6 +101,54 @@ export function BrowserPane({ const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription({ isMac }) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const browserSessionHostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }) + .filter((host) => host.kind === 'local' || host.kind === 'runtime') + .map((host) => ({ + id: host.id, + label: host.label, + detail: + host.kind === 'local' + ? translate('auto.components.settings.BrowserPane.86b7c83fee', 'This computer') + : translate( + 'auto.components.settings.BrowserPane.c0f85056d9', + 'Browser profiles on this Orca server.' + ) + })), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const selectedBrowserSessionHostId = getSettingsFocusedExecutionHostId(settings) + const selectBrowserSessionHost = useCallback( + (hostId: ExecutionHostId) => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + void switchRuntimeEnvironment(parsed.environmentId) + return + } + if (parsed?.kind === 'local') { + void switchRuntimeEnvironment(null) + } + }, + [switchRuntimeEnvironment] + ) const requestSessionCookieScrollFrame = (callback: FrameRequestCallback): void => { let completed = false @@ -171,7 +233,10 @@ export function BrowserPane({ detectedBrowsers={detectedBrowsers} importState={browserSessionImportState} defaultBrowserSessionProfileId={defaultBrowserSessionProfileId} + hostOptions={browserSessionHostOptions} + selectedHostId={selectedBrowserSessionHostId} onAddProfile={() => setNewProfileDialogOpen(true)} + onSelectHost={selectBrowserSessionHost} onSelectDefaultProfile={() => setDefaultBrowserSessionProfileId(null)} onSelectProfile={setDefaultBrowserSessionProfileId} /> diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx index 7f7d78c7e0f..f1c39ca8fec 100644 --- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx +++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx @@ -2,17 +2,28 @@ import { Plus } from 'lucide-react' import type { BrowserSessionProfile } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { BrowserProfileRow, type BrowserProfileRowProps } from './BrowserProfileRow' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' +type BrowserSessionHostOption = { + id: ExecutionHostId + label: string + detail: string +} + type BrowserSessionCookiesSectionProps = { defaultProfile: BrowserSessionProfile | undefined nonDefaultProfiles: BrowserSessionProfile[] detectedBrowsers: BrowserProfileRowProps['detectedBrowsers'] importState: BrowserProfileRowProps['importState'] defaultBrowserSessionProfileId: string | null + hostOptions: readonly BrowserSessionHostOption[] + selectedHostId: ExecutionHostId onAddProfile: () => void + onSelectHost: (hostId: ExecutionHostId) => void onSelectDefaultProfile: () => void onSelectProfile: (profileId: string) => void } @@ -23,10 +34,14 @@ export function BrowserSessionCookiesSection({ detectedBrowsers, importState, defaultBrowserSessionProfileId, + hostOptions, + selectedHostId, onAddProfile, + onSelectHost, onSelectDefaultProfile, onSelectProfile }: BrowserSessionCookiesSectionProps): React.JSX.Element { + const selectedHost = hostOptions.find((host) => host.id === selectedHostId) ?? hostOptions[0] return ( <SearchableSetting id="browser-session-cookies" @@ -68,6 +83,38 @@ export function BrowserSessionCookiesSection({ </Button> </div> + {hostOptions.length > 1 ? ( + <div className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2"> + <div className="min-w-0 space-y-0.5"> + <Label className="text-xs"> + {translate('auto.components.settings.BrowserPane.5e19a692f7', 'Host')} + </Label> + <p className="truncate text-[11px] text-muted-foreground"> + {selectedHost?.detail ?? + translate( + 'auto.components.settings.BrowserPane.6480776a03', + 'Browser profiles for the selected host.' + )} + </p> + </div> + <Select + value={selectedHostId} + onValueChange={(value) => onSelectHost(value as ExecutionHostId)} + > + <SelectTrigger size="sm" className="max-w-48"> + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {hostOptions.map((host) => ( + <SelectItem key={host.id} value={host.id}> + {host.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : null} + <div className="space-y-2"> <BrowserProfileRow profile={ diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 45aa8d3af78..34fa5eae8ad 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -1,8 +1,22 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FolderOpen, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' import type { GlobalSettings } from '../../../../shared/types' +import { + ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { + AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + ensureOrcaCliAvailableForAgentSkillTerminal, + isOrcaCliAvailableOnPath +} from '@/lib/agent-skill-cli-prerequisite' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { @@ -15,7 +29,14 @@ import { } from '../ui/dialog' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' -import { CliAgentSkillSetup } from './CliAgentSkillSetup' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { + buildSkillInstallCommandForRuntime, + CliSkillRuntimeControl, + ensureWslCliAvailableForAgentSkillTerminal, + getAgentSkillTerminalShellOverride, + getSelectedAgentRuntime +} from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' import { translate } from '@/i18n/i18n' @@ -68,6 +89,40 @@ export function CliSection({ const [dialogOpen, setDialogOpen] = useState(false) const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) const mountedRef = useMountedRef() + const agentRuntime = useMemo( + () => + getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading), + [settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform] + ) + const cliSkillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>( + () => (agentRuntime.runtime === 'wsl' ? { runtime: 'wsl' } : undefined), + [agentRuntime.runtime] + ) + const { + installed: cliSkillDetected, + loading: cliSkillLoading, + error: cliSkillError, + refresh: refreshCliSkill + } = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { + discoveryTarget: cliSkillDiscoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + const cliSkillInstallCommand = buildSkillInstallCommandForRuntime( + ORCA_CLI_SKILL_INSTALL_COMMAND, + agentRuntime + ) + const cliSkillTerminalShellOverride = getAgentSkillTerminalShellOverride( + currentPlatform, + settings, + agentRuntime + ) + const getCliSkillPrerequisiteStatus = useCallback( + () => + agentRuntime.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus() + : window.api.cli.getInstallStatus(), + [agentRuntime.runtime] + ) const handleStatusChange = useCallback( (nextStatus: CliInstallStatus): void => { @@ -84,14 +139,7 @@ export function CliSection({ handleStatusChange(await window.api.cli.getInstallStatus()) } catch (error) { if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.CliSection.7baec27029', - 'Failed to load CLI status.' - ) - ) + toast.error(error instanceof Error ? error.message : translate("auto.components.settings.CliSection.7baec27029", "Failed to load CLI status.")) } } finally { if (mountedRef.current) { @@ -119,24 +167,12 @@ export function CliSection({ if (mountedRef.current) { setStatus(next) setDialogOpen(false) - toast.success( - translate( - 'auto.components.settings.CliSection.9cbcd31338', - 'Registered `{{value0}}` in PATH.', - { value0: next.commandName } - ) - ) + toast.success(translate("auto.components.settings.CliSection.9cbcd31338", "Registered `{{value0}}` in PATH.", { value0: next.commandName })) } } catch (error) { if (mountedRef.current) { toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.CliSection.a2b13efa94', - 'Failed to register `{{value0}}` in PATH.', - { value0: commandName } - ) + error instanceof Error ? error.message : translate("auto.components.settings.CliSection.a2b13efa94", "Failed to register `{{value0}}` in PATH.", { value0: commandName }) ) } } finally { @@ -153,24 +189,12 @@ export function CliSection({ if (mountedRef.current) { setStatus(next) setDialogOpen(false) - toast.success( - translate( - 'auto.components.settings.CliSection.af5540930c', - 'Removed `{{value0}}` from PATH.', - { value0: next.commandName } - ) - ) + toast.success(translate("auto.components.settings.CliSection.af5540930c", "Removed `{{value0}}` from PATH.", { value0: next.commandName })) } } catch (error) { if (mountedRef.current) { toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.CliSection.d77352f2df', - 'Failed to remove `{{value0}}` from PATH.', - { value0: commandName } - ) + error instanceof Error ? error.message : translate("auto.components.settings.CliSection.d77352f2df", "Failed to remove `{{value0}}` from PATH.", { value0: commandName }) ) } } finally { @@ -183,29 +207,18 @@ export function CliSection({ return ( <section className="space-y-4" data-settings-section="cli"> <div className="space-y-1"> - <h2 className="text-sm font-semibold"> - {translate('auto.components.settings.CliSection.c5c0f2641d', 'Orca CLI')} - </h2> + <h2 className="text-sm font-semibold">{translate("auto.components.settings.CliSection.c5c0f2641d", "Orca CLI")}</h2> <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.CliSection.6930feda9e', - 'Use Orca from your terminal to open the app, manage worktrees, and interact with Orca terminals.' - )} - </p> + {translate("auto.components.settings.CliSection.6930feda9e", "Use Orca from your terminal to open the app, manage worktrees, and interact with Orca terminals.")}</p> </div> <div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4"> <div className="flex items-center justify-between gap-4"> <div className="space-y-0.5"> - <Label> - {translate('auto.components.settings.CliSection.38edbb5721', 'Shell command')} - </Label> + <Label>{translate("auto.components.settings.CliSection.38edbb5721", "Shell command")}</Label> <p className="text-xs text-muted-foreground"> {loading - ? translate( - 'auto.components.settings.CliSection.d363e5929b', - 'Checking CLI registration…' - ) + ? translate("auto.components.settings.CliSection.d363e5929b", "Checking CLI registration…") : (status?.detail ?? getInstallDescription(currentPlatform))} </p> </div> @@ -218,17 +231,13 @@ export function CliSection({ size="icon-xs" onClick={() => void refreshStatus()} disabled={loading || busyAction !== null} - aria-label={translate( - 'auto.components.settings.CliSection.52e640f3a0', - 'Refresh CLI status' - )} + aria-label={translate("auto.components.settings.CliSection.52e640f3a0", "Refresh CLI status")} > <RefreshCw className="size-3.5" /> </Button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={6}> - {translate('auto.components.settings.CliSection.5dae812f50', 'Refresh')} - </TooltipContent> + {translate("auto.components.settings.CliSection.5dae812f50", "Refresh")}</TooltipContent> </Tooltip> </TooltipProvider> {!isBrowserManaged ? ( @@ -253,29 +262,20 @@ export function CliSection({ {status?.commandPath ? ( <p className="text-xs text-muted-foreground"> - {translate('auto.components.settings.CliSection.15eaad0d31', 'Command path:')}{' '} + {translate("auto.components.settings.CliSection.15eaad0d31", "Command path:")}{' '} <code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code> </p> ) : null} - {status?.state === 'stale' && status.currentTarget ? ( + {status?.state === "stale" && status.currentTarget ? ( <p className="text-xs text-amber-600 dark:text-amber-400"> - {translate( - 'auto.components.settings.CliSection.b0c310ab46', - 'Existing launcher target:' - )} - <code>{status.currentTarget}</code> + {translate("auto.components.settings.CliSection.b0c310ab46", "Existing launcher target:")}<code>{status.currentTarget}</code> </p> ) : null} - {status?.state === 'installed' && !status.pathConfigured && status.pathDirectory ? ( + {status?.state === "installed" && !status.pathConfigured && status.pathDirectory ? ( <p className="text-xs text-amber-600 dark:text-amber-400"> - {status.pathDirectory}{' '} - {translate( - 'auto.components.settings.CliSection.7f2747f7dd', - 'is not currently visible on PATH for this shell.' - )} - </p> + {status.pathDirectory} {translate("auto.components.settings.CliSection.7f2747f7dd", "is not currently visible on PATH for this shell.")}</p> ) : null} {!loading && !isSupported && !isBrowserManaged && status?.detail ? ( @@ -298,15 +298,47 @@ export function CliSection({ </div> {!isBrowserManaged ? ( - <CliAgentSkillSetup - currentPlatform={currentPlatform} - settings={settings} - updateSettings={updateSettings} - wslSupportedPlatform={wslSupportedPlatform} - wslAvailable={wslAvailable} - wslCapabilitiesLoading={wslCapabilitiesLoading} - onHostStatusChange={handleStatusChange} - /> + <div className="border-t border-border/60 pt-3"> + <div className="space-y-0.5"> + <Label>{translate("auto.components.settings.CliSection.04873eea3e", "Agent skills")}</Label> + <p className="text-xs text-muted-foreground"> + {translate("auto.components.settings.CliSection.36a6f919ba", "Give agents Orca-aware workspace, terminal, and progress workflows.")}</p> + </div> + + <CliSkillRuntimeControl + runtime={agentRuntime} + updateSettings={updateSettings} + wslSupportedPlatform={wslSupportedPlatform} + wslAvailable={wslAvailable} + wslCapabilitiesLoading={wslCapabilitiesLoading} + /> + + <AgentSkillSetupPanel + className="mt-3" + variant="inline" + title={translate("auto.components.settings.CliSection.6053cf736c", "CLI skill")} + description={translate("auto.components.settings.CliSection.e8012c03a1", "Enables agents to use Orca workspace, terminal, and progress commands.")} + command={cliSkillInstallCommand} + terminalTitle="CLI skill setup" + terminalAriaLabel="CLI skill install terminal" + terminalWorktreeId={`settings-cli-skill-terminal-${agentRuntime.runtime}`} + terminalShellOverride={cliSkillTerminalShellOverride} + installed={cliSkillDetected} + loading={cliSkillLoading} + error={cliSkillError} + preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={getCliSkillPrerequisiteStatus} + isPrerequisiteAvailable={isOrcaCliAvailableOnPath} + onBeforeOpenTerminal={async () => { + await (agentRuntime.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal() + : ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: handleStatusChange + })) + }} + onRecheck={refreshCliSkill} + /> + </div> ) : null} </div> @@ -317,33 +349,18 @@ export function CliSection({ <DialogHeader> <DialogTitle> {isEnabled - ? translate( - 'auto.components.settings.CliSection.14444243ba', - 'Remove `{{value0}}` from PATH?', - { value0: commandName } - ) - : translate( - 'auto.components.settings.CliSection.fa87db3d6e', - 'Register `{{value0}}` in PATH?', - { value0: commandName } - )} + ? translate("auto.components.settings.CliSection.14444243ba", "Remove `{{value0}}` from PATH?", { value0: commandName }) + : translate("auto.components.settings.CliSection.fa87db3d6e", "Register `{{value0}}` in PATH?", { value0: commandName })} </DialogTitle> <DialogDescription> {isEnabled - ? translate( - 'auto.components.settings.CliSection.a030816e3e', - 'This removes the shell command symlink. Orca itself remains installed.' - ) - : translate( - 'auto.components.settings.CliSection.aa6536977e', - 'Orca will register {{value0}} so the command works from your terminal.', - { value0: status?.commandPath ?? commandName } - )} + ? translate("auto.components.settings.CliSection.a030816e3e", "This removes the shell command symlink. Orca itself remains installed.") + : translate("auto.components.settings.CliSection.aa6536977e", "Orca will register {{value0}} so the command works from your terminal.", { value0: status?.commandPath ?? commandName })} </DialogDescription> </DialogHeader> {status?.commandPath ? ( <p className="text-xs text-muted-foreground"> - {translate('auto.components.settings.CliSection.a4aafe46e3', 'Target path:')}{' '} + {translate("auto.components.settings.CliSection.a4aafe46e3", "Target path:")}{' '} <code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code> </p> ) : null} @@ -353,19 +370,18 @@ export function CliSection({ onClick={() => setDialogOpen(false)} disabled={busyAction !== null} > - {translate('auto.components.settings.CliSection.8671e406f0', 'Cancel')} - </Button> + {translate("auto.components.settings.CliSection.8671e406f0", "Cancel")}</Button> <Button onClick={() => void (isEnabled ? handleRemove() : handleInstall())} disabled={busyAction !== null || !isSupported} > - {busyAction === 'remove' - ? translate('auto.components.settings.CliSection.068552b191', 'Removing…') - : busyAction === 'install' - ? translate('auto.components.settings.CliSection.b0fca411a0', 'Registering…') + {busyAction === "remove" + ? translate("auto.components.settings.CliSection.068552b191", "Removing…") + : busyAction === "install" + ? translate("auto.components.settings.CliSection.b0fca411a0", "Registering…") : isEnabled - ? translate('auto.components.settings.CliSection.9a5f8a4568', 'Remove') - : translate('auto.components.settings.CliSection.d00df2e397', 'Register')} + ? translate("auto.components.settings.CliSection.9a5f8a4568", "Remove") + : translate("auto.components.settings.CliSection.d00df2e397", "Register")} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 00dca53528e..9b8ebc6d9ff 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -24,7 +24,9 @@ import { Label } from '../ui/label' import { SearchableSetting } from './SearchableSetting' import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults' import { matchesSettingsSearch } from './settings-search' +import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' +import { HostedReviewCreationDefaults } from './HostedReviewCreationDefaults' type CommitMessageAiPaneProps = { settings: GlobalSettings @@ -99,6 +101,7 @@ export function CommitMessageAiPane({ const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery) const searchQuery = settingsSearchQuery ?? storeSearchQuery const config = readSettings(settings) + const ownership = getSettingOwnershipSummary('sourceControlAiDefaults') const settingsWriteQueueRef = useRef<Promise<void>>(Promise.resolve()) const localWriteConfig = (patch: SourceControlAiSettingsPatch): Promise<void> => { @@ -150,24 +153,51 @@ export function CommitMessageAiPane({ if ( matchesSettingsSearch(searchQuery, { - title: translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions"), - description: - translate("auto.components.settings.CommitMessageAiPane.7bcad2b200", "Adds action recipes for Source Control commit, pull request, branch-name, and fix actions."), - keywords: [translate("auto.components.settings.CommitMessageAiPane.0b7eafe55f", "ai"), translate("auto.components.settings.CommitMessageAiPane.ca433708cb", "commit"), translate("auto.components.settings.CommitMessageAiPane.8cd2be0948", "message"), translate("auto.components.settings.CommitMessageAiPane.34d0348e34", "generate"), translate("auto.components.settings.CommitMessageAiPane.4ec89c319e", "agent"), translate("auto.components.settings.CommitMessageAiPane.d54c64163d", "enabled")] + title: translate( + 'auto.components.settings.CommitMessageAiPane.d5b45a3628', + 'Show Source Control AI actions' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.7bcad2b200', + 'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.' + ), + keywords: [ + translate('auto.components.settings.CommitMessageAiPane.0b7eafe55f', 'ai'), + translate('auto.components.settings.CommitMessageAiPane.ca433708cb', 'commit'), + translate('auto.components.settings.CommitMessageAiPane.8cd2be0948', 'message'), + translate('auto.components.settings.CommitMessageAiPane.34d0348e34', 'generate'), + translate('auto.components.settings.CommitMessageAiPane.4ec89c319e', 'agent'), + translate('auto.components.settings.CommitMessageAiPane.d54c64163d', 'enabled') + ] }) ) { sections.push( <SearchableSetting key="enabled" - title={translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions")} - description={translate("auto.components.settings.CommitMessageAiPane.7bcad2b200", "Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.")} + title={translate( + 'auto.components.settings.CommitMessageAiPane.d5b45a3628', + 'Show Source Control AI actions' + )} + description={translate( + 'auto.components.settings.CommitMessageAiPane.7bcad2b200', + 'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.' + )} keywords={['ai', 'commit', 'message', 'generate', 'agent', 'enabled']} className="flex items-center justify-between gap-4 py-2" > <div className="space-y-1"> - <Label>{translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions")}</Label> + <Label> + {translate( + 'auto.components.settings.CommitMessageAiPane.d5b45a3628', + 'Show Source Control AI actions' + )} + </Label> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.CommitMessageAiPane.2339a89104", "Adds AI buttons that run the selected agent with the command template for that action.")}</p> + {translate( + 'auto.components.settings.CommitMessageAiPane.2339a89104', + 'Adds AI buttons that run the selected agent with the command template for that action.' + )} + </p> </div> <button role="switch" @@ -203,23 +233,55 @@ export function CommitMessageAiPane({ config.enabled && (customCommandInUse || matchesSettingsSearch(searchQuery, { - title: translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command"), - description: translate("auto.components.settings.CommitMessageAiPane.1ef29f8c29", "Command line Orca runs when a text recipe uses Custom command."), - keywords: [translate("auto.components.settings.CommitMessageAiPane.25350d670f", "custom"), translate("auto.components.settings.CommitMessageAiPane.54038660e0", "command"), translate("auto.components.settings.CommitMessageAiPane.407d28bde6", "cli"), translate("auto.components.settings.CommitMessageAiPane.1df7d71313", "binary"), translate("auto.components.settings.CommitMessageAiPane.a69e1fe91a", "prompt"), translate("auto.components.settings.CommitMessageAiPane.fc1a525fa5", "placeholder")] + title: translate( + 'auto.components.settings.CommitMessageAiPane.47e45cbd5a', + 'Custom command' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.1ef29f8c29', + 'Command line Orca runs when a text recipe uses Custom command.' + ), + keywords: [ + translate('auto.components.settings.CommitMessageAiPane.25350d670f', 'custom'), + translate('auto.components.settings.CommitMessageAiPane.54038660e0', 'command'), + translate('auto.components.settings.CommitMessageAiPane.407d28bde6', 'cli'), + translate('auto.components.settings.CommitMessageAiPane.1df7d71313', 'binary'), + translate('auto.components.settings.CommitMessageAiPane.a69e1fe91a', 'prompt'), + translate('auto.components.settings.CommitMessageAiPane.fc1a525fa5', 'placeholder') + ] })) ) { sections.push( <SearchableSetting key="custom-command" - title={translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command")} - description={translate("auto.components.settings.CommitMessageAiPane.1ef29f8c29", "Command line Orca runs when a text recipe uses Custom command.")} + title={translate( + 'auto.components.settings.CommitMessageAiPane.47e45cbd5a', + 'Custom command' + )} + description={translate( + 'auto.components.settings.CommitMessageAiPane.1ef29f8c29', + 'Command line Orca runs when a text recipe uses Custom command.' + )} keywords={['custom', 'command', 'cli', 'binary', 'prompt', 'placeholder']} className="space-y-2 py-2" > <div className="space-y-0.5"> - <Label htmlFor="source-control-ai-custom-command">{translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command")}</Label> + <Label htmlFor="source-control-ai-custom-command"> + {translate('auto.components.settings.CommitMessageAiPane.47e45cbd5a', 'Custom command')} + </Label> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.CommitMessageAiPane.4f722a5f53", "Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use")}<code className="font-mono">{translate("auto.components.settings.CommitMessageAiPane.b8b6fd55b4", "{prompt}")}</code> {translate("auto.components.settings.CommitMessageAiPane.3f1b26cc91", "to pass the command input as an argument; otherwise Orca pipes it on stdin.")}</p> + {translate( + 'auto.components.settings.CommitMessageAiPane.4f722a5f53', + 'Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use' + )} + <code className="font-mono"> + {translate('auto.components.settings.CommitMessageAiPane.b8b6fd55b4', '{prompt}')} + </code>{' '} + {translate( + 'auto.components.settings.CommitMessageAiPane.3f1b26cc91', + 'to pass the command input as an argument; otherwise Orca pipes it on stdin.' + )} + </p> </div> <Input id="source-control-ai-custom-command" @@ -228,7 +290,10 @@ export function CommitMessageAiPane({ autoCapitalize="off" value={config.customAgentCommand} onChange={(event) => onCustomCommandChange(event.target.value)} - placeholder={translate("auto.components.settings.CommitMessageAiPane.15b60d54b2", "e.g. ollama run llama3.1 {prompt}")} + placeholder={translate( + 'auto.components.settings.CommitMessageAiPane.15b60d54b2', + 'e.g. ollama run llama3.1 {prompt}' + )} className="h-8 font-mono text-xs" /> </SearchableSetting> @@ -238,89 +303,33 @@ export function CommitMessageAiPane({ if ( config.enabled && matchesSettingsSearch(searchQuery, { - title: translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults"), - description: translate("auto.components.settings.CommitMessageAiPane.e9d46a544d", "Defaults used when the hosted-review composer opens."), + title: translate( + 'auto.components.settings.CommitMessageAiPane.2dafc7646e', + 'Hosted-review creation defaults' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.e9d46a544d', + 'Defaults used when the hosted-review composer opens.' + ), keywords: [ - translate("auto.components.settings.CommitMessageAiPane.19e10a12bb", "hosted review"), - translate("auto.components.settings.CommitMessageAiPane.b388463881", "pull request"), - translate("auto.components.settings.CommitMessageAiPane.fdee745b87", "merge request"), - translate("auto.components.settings.CommitMessageAiPane.02bab6542c", "pr"), - translate("auto.components.settings.CommitMessageAiPane.ebed4d2a29", "draft"), - translate("auto.components.settings.CommitMessageAiPane.6c84ba6de3", "template"), - translate("auto.components.settings.CommitMessageAiPane.34d0348e34", "generate"), - translate("auto.components.settings.CommitMessageAiPane.2c5436c018", "open") + translate('auto.components.settings.CommitMessageAiPane.19e10a12bb', 'hosted review'), + translate('auto.components.settings.CommitMessageAiPane.b388463881', 'pull request'), + translate('auto.components.settings.CommitMessageAiPane.fdee745b87', 'merge request'), + translate('auto.components.settings.CommitMessageAiPane.02bab6542c', 'pr'), + translate('auto.components.settings.CommitMessageAiPane.ebed4d2a29', 'draft'), + translate('auto.components.settings.CommitMessageAiPane.6c84ba6de3', 'template'), + translate('auto.components.settings.CommitMessageAiPane.34d0348e34', 'generate'), + translate('auto.components.settings.CommitMessageAiPane.2c5436c018', 'open') ] }) ) { const prDefaults = config.prCreationDefaults ?? {} - const rows: { - key: keyof NonNullable<SourceControlAiSettings['prCreationDefaults']> - label: string - description: string - }[] = [ - { - key: 'draft', - label: translate("auto.components.settings.CommitMessageAiPane.6ba48f07a4", "Draft by default"), - description: translate("auto.components.settings.CommitMessageAiPane.e001734396", "Create hosted reviews as drafts unless changed in the composer.") - }, - { - key: 'useTemplate', - label: translate("auto.components.settings.CommitMessageAiPane.d8b6764d79", "Use review template when available"), - description: translate("auto.components.settings.CommitMessageAiPane.6278c0ce43", "Prefer repository pull request templates when no description is set.") - }, - { - key: 'generateDetailsOnOpen', - label: translate("auto.components.settings.CommitMessageAiPane.d5f0de6309", "Generate details when opening Create PR"), - description: translate("auto.components.settings.CommitMessageAiPane.b27b0809f3", "Run hosted-review detail generation once when the composer opens.") - }, - { - key: 'openAfterCreate', - label: translate("auto.components.settings.CommitMessageAiPane.7662715213", "Open hosted review after creation"), - description: translate("auto.components.settings.CommitMessageAiPane.b125eabffa", "Open the created hosted review in your browser after submit.") - } - ] sections.push( - <SearchableSetting + <HostedReviewCreationDefaults key="pr-creation-defaults" - title={translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults")} - description={translate("auto.components.settings.CommitMessageAiPane.e9d46a544d", "Defaults used when the hosted-review composer opens.")} - keywords={[ - 'hosted review', - 'pull request', - 'merge request', - 'pr', - 'draft', - 'template', - 'generate', - 'open' - ]} - className="space-y-3 px-1 py-2" - > - <div className="space-y-0.5"> - <Label>{translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults")}</Label> - <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.CommitMessageAiPane.347094560b", "Used by repositories that inherit global hosted-review defaults.")}</p> - </div> - <div className="space-y-2"> - {rows.map((row) => ( - <label - key={row.key} - className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2" - > - <span className="space-y-0.5"> - <span className="block text-xs font-medium text-foreground">{row.label}</span> - <span className="block text-[11px] text-muted-foreground">{row.description}</span> - </span> - <input - type="checkbox" - checked={prDefaults[row.key] === true} - onChange={(event) => onPrDefaultChange(row.key, event.target.checked)} - className="mt-0.5 size-4 rounded border-border accent-primary" - /> - </label> - ))} - </div> - </SearchableSetting> + prDefaults={prDefaults} + onPrDefaultChange={onPrDefaultChange} + /> ) } @@ -331,9 +340,13 @@ export function CommitMessageAiPane({ className="space-y-4 border-t border-border/40 pt-4" > <div className="space-y-0.5"> - <h3 className="text-sm font-semibold">{translate("auto.components.settings.CommitMessageAiPane.ad66ff886d", "Source Control AI defaults")}</h3> - <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.CommitMessageAiPane.841ed9884a", "Used by repositories that have not customized Source Control AI.")}</p> + <h3 className="text-sm font-semibold"> + {translate( + 'auto.components.settings.CommitMessageAiPane.ad66ff886d', + 'Source Control AI defaults' + )} + </h3> + <p className="text-xs text-muted-foreground">{ownership.description}</p> </div> {sections} </div> diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 01164d6657b..8e43a9a7f09 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -1,11 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ExternalLink, MonitorCog, RefreshCw, ShieldCheck } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { + Accessibility, + Camera, + ExternalLink, + MonitorCog, + RefreshCw, + ShieldCheck +} from 'lucide-react' import { toast } from 'sonner' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' -import type { GlobalSettings } from '../../../../shared/types' import type { ComputerUsePermissionId, - ComputerUsePermissionState + ComputerUsePermissionState, + ComputerUsePermissionStatus } from '../../../../shared/computer-use-permissions-types' import { COMPUTER_USE_SKILL_INSTALL_COMMAND, @@ -13,8 +19,7 @@ import { } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal, - isOrcaCliAvailableOnPath + ensureOrcaCliAvailableForAgentSkillTerminal } from '@/lib/agent-skill-cli-prerequisite' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, @@ -24,32 +29,57 @@ import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Badge } from '../ui/badge' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { - buildSkillInstallCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getAgentSkillTerminalShellOverride, - getSkillDiscoveryTargetForRuntime -} from './CliSkillRuntimeSetup' -import { getDesktopPlatformFromUserAgent } from './GeneralPane' -import { - COMPUTER_USE_PERMISSIONS, - getComputerUsePermissionStatusClass, - getComputerUsePermissionStatusLabel -} from './computer-use-permission-definitions' -import { getComputerUseSkillRuntime } from './computer-use-skill-runtime' -import { getComputerUseSummary } from './computer-use-summary' import { translate } from '@/i18n/i18n' export { getComputerUsePaneSearchEntries } from './computer-use-search' -type ComputerUsePaneProps = { - currentPlatform?: string - settings?: GlobalSettings | null - wslSupportedPlatform?: boolean - wslAvailable?: boolean - wslCapabilitiesLoading?: boolean +type PermissionDefinition = { + id: ComputerUsePermissionId + labelKey: string + labelDefault: string + descriptionKey: string + descriptionDefault: string + icon: ReactNode } -export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Element { +const PERMISSIONS: PermissionDefinition[] = [ + { + id: 'accessibility', + labelKey: 'auto.components.settings.ComputerUsePane.6b5a2cd3a5', + labelDefault: 'Accessibility', + descriptionKey: 'auto.components.settings.ComputerUsePane.4d03dec2d0', + descriptionDefault: 'Read app interface trees and perform requested actions.', + icon: <Accessibility className="size-4" /> + }, + { + id: 'screenshots', + labelKey: 'auto.components.settings.ComputerUsePane.07bbe4c4cb', + labelDefault: 'Screenshots', + descriptionKey: 'auto.components.settings.ComputerUsePane.0c9a33f468', + descriptionDefault: 'Capture app windows so agents can inspect visual state.', + icon: <Camera className="size-4" /> + } +] + +function statusLabel(status: ComputerUsePermissionStatus | undefined): string { + switch (status) { + case 'granted': + return 'Granted' + case 'unsupported': + return 'macOS only' + case 'not-granted': + case undefined: + return 'Not enabled' + } +} + +function statusClass(status: ComputerUsePermissionStatus | undefined): string { + if (status === 'granted') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + } + return 'border-border bg-muted text-muted-foreground' +} + +export function ComputerUsePane(): React.JSX.Element { const [platform, setPlatform] = useState<NodeJS.Platform | null>(null) const [states, setStates] = useState<ComputerUsePermissionState[]>([]) const [loading, setLoading] = useState(true) @@ -60,35 +90,12 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele const permissionOperationSequence = useRef(0) const mountedRef = useRef(true) const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(null) - const currentPlatform = - props.currentPlatform ?? - (typeof navigator === 'undefined' - ? 'other' - : getDesktopPlatformFromUserAgent(navigator.userAgent)) - const skillRuntime = useMemo(() => getComputerUseSkillRuntime(props), [props]) - const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>( - () => getSkillDiscoveryTargetForRuntime(skillRuntime), - [skillRuntime] - ) - const skillInstallCommand = buildSkillInstallCommandForRuntime( - COMPUTER_USE_SKILL_INSTALL_COMMAND, - skillRuntime - ) - const skillTerminalShellOverride = props.settings - ? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime) - : undefined - const getSkillPrerequisiteStatus = () => - skillRuntime.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus() - : window.api.cli.getInstallStatus() - const { installed: computerUseSkillDetected, loading: computerUseSkillLoading, error: computerUseSkillError, refresh: refreshComputerUseSkill } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { - discoveryTarget: skillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -96,21 +103,30 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele () => new Map(states.map((state) => [state.id, state.status] as const)), [states] ) - const grantedCount = COMPUTER_USE_PERMISSIONS.filter( + const grantedCount = PERMISSIONS.filter( (permission) => stateById.get(permission.id) === 'granted' ).length - const allGranted = grantedCount === COMPUTER_USE_PERMISSIONS.length + const allGranted = grantedCount === PERMISSIONS.length const checking = loading && states.length === 0 const setupUnavailable = helperUnavailableReason !== null const resetAccessDisabled = resetting || loading || states.length === 0 || pendingId !== null || setupUnavailable - const { title: summaryTitle, description: summaryDescription } = getComputerUseSummary({ - checking, - setupUnavailable, - allGranted, - helperUnavailableReason, - requiredPermissionCount: COMPUTER_USE_PERMISSIONS.length - grantedCount - }) + const summaryTitle = checking + ? 'Checking Computer Use access.' + : setupUnavailable + ? 'Computer Use is unavailable.' + : allGranted + ? 'Computer Use is ready.' + : 'Finish setup to use local apps.' + const summaryDescription = checking + ? 'Orca is checking macOS privacy permissions for the Computer Use helper.' + : setupUnavailable + ? `Computer Use permissions are unavailable because ${helperUnavailableReason}.` + : allGranted + ? 'Agents can inspect and operate app windows when you ask.' + : `${PERMISSIONS.length - grantedCount} permission${ + PERMISSIONS.length - grantedCount === 1 ? '' : 's' + } required before agents can operate app windows.` useEffect(() => { mountedRef.current = true @@ -299,7 +315,7 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele <div className="space-y-2"> <div className="divide-y divide-border/60 rounded-lg border border-border/60"> - {COMPUTER_USE_PERMISSIONS.map((permission) => { + {PERMISSIONS.map((permission) => { const status = stateById.get(permission.id) const pending = pendingId === permission.id @@ -312,16 +328,20 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele <div className="mt-0.5 text-muted-foreground">{permission.icon}</div> <div className="min-w-0 space-y-1"> <div className="flex flex-wrap items-center gap-2"> - <span className="text-sm font-medium">{permission.label}</span> + <span className="text-sm font-medium"> + {translate(permission.labelKey, permission.labelDefault)} + </span> <span - className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${getComputerUsePermissionStatusClass( + className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass( status )}`} > - {getComputerUsePermissionStatusLabel(status)} + {statusLabel(status)} </span> </div> - <p className="text-xs text-muted-foreground">{permission.description}</p> + <p className="text-xs text-muted-foreground"> + {translate(permission.descriptionKey, permission.descriptionDefault)} + </p> </div> </div> <div className="flex w-28 shrink-0 justify-end"> @@ -371,23 +391,18 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele 'auto.components.settings.ComputerUsePane.1735461723', 'Enables agents to inspect and operate local desktop apps.' )} - command={skillInstallCommand} + command={COMPUTER_USE_SKILL_INSTALL_COMMAND} terminalTitle="Computer Use setup" terminalAriaLabel="Computer Use skill install terminal" - terminalWorktreeId={`settings-computer-use-skill-terminal-${skillRuntime.runtime}`} - terminalShellOverride={skillTerminalShellOverride} + terminalWorktreeId="settings-computer-use-skill-terminal" installed={computerUseSkillDetected} loading={computerUseSkillLoading} error={computerUseSkillError} icon={<MonitorCog className="size-5" />} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={getSkillPrerequisiteStatus} - isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('computer-use-setup') - await (skillRuntime.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal() - : ensureOrcaCliAvailableForAgentSkillTerminal()) + await ensureOrcaCliAvailableForAgentSkillTerminal() }} onRecheck={refreshComputerUseSkill} /> diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 9686ac70781..4405810eca3 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -1,12 +1,9 @@ import type React from 'react' -import { FolderOpen } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' -import { Button } from '../ui/button' -import { Input } from '../ui/input' -import { Label } from '../ui/label' import { OpenInMenuSetting } from './OpenInMenuSetting' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' +import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' import { translate } from '@/i18n/i18n' type GeneralWorkspaceSettingsSectionProps = { @@ -18,13 +15,6 @@ export function GeneralWorkspaceSettingsSection({ settings, updateSettings }: GeneralWorkspaceSettingsSectionProps): React.JSX.Element { - const handleBrowseWorkspace = async (): Promise<void> => { - const path = await window.api.repos.pickFolder() - if (path) { - updateSettings({ workspaceDir: path }) - } - } - return ( <section key="workspace" className="space-y-4"> <SettingsSubsectionHeader @@ -38,50 +28,7 @@ export function GeneralWorkspaceSettingsSection({ )} /> - <SearchableSetting - title={translate( - 'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc', - 'Workspace Directory' - )} - description={translate( - 'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f', - 'Root directory where workspace folders are created.' - )} - keywords={['workspace', 'folder', 'path', 'worktree']} - className="space-y-2" - > - <Label> - {translate( - 'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc', - 'Workspace Directory' - )} - </Label> - <div className="flex gap-2"> - <Input - value={settings.workspaceDir} - onChange={(e) => updateSettings({ workspaceDir: e.target.value })} - className="flex-1 text-xs" - /> - <Button - variant="outline" - size="sm" - onClick={handleBrowseWorkspace} - className="shrink-0 gap-1.5" - > - <FolderOpen className="size-3.5" /> - {translate( - 'auto.components.settings.GeneralWorkspaceSettingsSection.5567191a6e', - 'Browse' - )} - </Button> - </div> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f', - 'Root directory where workspace folders are created.' - )} - </p> - </SearchableSetting> + <WorkspaceDirectorySetting settings={settings} updateSettings={updateSettings} /> <SearchableSetting title={translate( diff --git a/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx new file mode 100644 index 00000000000..3f4b1ac7298 --- /dev/null +++ b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx @@ -0,0 +1,128 @@ +import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +type HostedReviewDefaultKey = keyof NonNullable<SourceControlAiSettings['prCreationDefaults']> + +const KEYWORDS = [ + 'hosted review', + 'pull request', + 'merge request', + 'pr', + 'draft', + 'template', + 'generate', + 'open' +] + +function getHostedReviewDefaultRows(): { + key: HostedReviewDefaultKey + label: string + description: string +}[] { + return [ + { + key: 'draft', + label: translate( + 'auto.components.settings.CommitMessageAiPane.6ba48f07a4', + 'Draft by default' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.e001734396', + 'Create hosted reviews as drafts unless changed in the composer.' + ) + }, + { + key: 'useTemplate', + label: translate( + 'auto.components.settings.CommitMessageAiPane.d8b6764d79', + 'Use review template when available' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.6278c0ce43', + 'Prefer repository pull request templates when no description is set.' + ) + }, + { + key: 'generateDetailsOnOpen', + label: translate( + 'auto.components.settings.CommitMessageAiPane.d5f0de6309', + 'Generate details when opening Create PR' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.b27b0809f3', + 'Run hosted-review detail generation once when the composer opens.' + ) + }, + { + key: 'openAfterCreate', + label: translate( + 'auto.components.settings.CommitMessageAiPane.7662715213', + 'Open hosted review after creation' + ), + description: translate( + 'auto.components.settings.CommitMessageAiPane.b125eabffa', + 'Open the created hosted review in your browser after submit.' + ) + } + ] +} + +export function HostedReviewCreationDefaults({ + prDefaults, + onPrDefaultChange +}: { + prDefaults: NonNullable<SourceControlAiSettings['prCreationDefaults']> + onPrDefaultChange: (key: HostedReviewDefaultKey, value: boolean) => void +}): React.JSX.Element { + return ( + <SearchableSetting + key="pr-creation-defaults" + title={translate( + 'auto.components.settings.CommitMessageAiPane.2dafc7646e', + 'Hosted-review creation defaults' + )} + description={translate( + 'auto.components.settings.CommitMessageAiPane.e9d46a544d', + 'Defaults used when the hosted-review composer opens.' + )} + keywords={KEYWORDS} + className="space-y-3 px-1 py-2" + > + <div className="space-y-0.5"> + <Label> + {translate( + 'auto.components.settings.CommitMessageAiPane.2dafc7646e', + 'Hosted-review creation defaults' + )} + </Label> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.CommitMessageAiPane.347094560b', + 'Used by repositories that inherit global hosted-review defaults.' + )} + </p> + </div> + <div className="space-y-2"> + {getHostedReviewDefaultRows().map((row) => ( + <label + key={row.key} + className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2" + > + <span className="space-y-0.5"> + <span className="block text-xs font-medium text-foreground">{row.label}</span> + <span className="block text-[11px] text-muted-foreground">{row.description}</span> + </span> + <input + type="checkbox" + checked={prDefaults[row.key] === true} + onChange={(event) => onPrDefaultChange(row.key, event.target.checked)} + className="mt-0.5 size-4 rounded border-border accent-primary" + /> + </label> + ))} + </div> + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index a7a895ef577..de48b535e3d 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -14,9 +14,6 @@ import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' export function ManageSessionsSection(): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) const [sessions, setSessions] = useState<PtyManagementSession[]>([]) const [isRefreshing, setIsRefreshing] = useState(true) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) @@ -72,14 +69,6 @@ export function ManageSessionsSection(): React.JSX.Element { }, []) const refresh = useCallback(async (): Promise<PtyManagementSession[]> => { - if (activeRuntimeEnvironmentId?.trim()) { - if (isMounted.current) { - setSessions([]) - setIsRefreshing(false) - setHasLoadedOnce(true) - } - return [] - } setIsRefreshing(true) try { const result = await window.api.pty.management.listSessions() @@ -108,7 +97,7 @@ export function ManageSessionsSection(): React.JSX.Element { setHasLoadedOnce(true) } } - }, [activeRuntimeEnvironmentId]) + }, []) useEffect(() => { void refresh() @@ -192,40 +181,6 @@ export function ManageSessionsSection(): React.JSX.Element { const isBusy = busyKind !== null || daemonActions.isBusy - if (activeRuntimeEnvironmentId?.trim()) { - return ( - <section className="space-y-4"> - <div className="space-y-1"> - <h3 className="text-sm font-semibold"> - {translate( - 'auto.components.settings.ManageSessionsSection.d1b80fd5cd', - 'Manage Sessions' - )} - </h3> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.ManageSessionsSection.ad467eaadc', - 'Session management is unavailable while a remote runtime server is active.' - )} - </p> - </div> - <SearchableSetting - title={getManageSessionsSearchEntries()[0].title} - description={getManageSessionsSearchEntries()[0].description} - keywords={getManageSessionsSearchEntries()[0].keywords} - className="space-y-3" - > - <div className="rounded-lg border border-border/60 px-3 py-3 text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.ManageSessionsSection.9c940434af', - 'Switch back to the local runtime to restart or kill local daemon sessions.' - )} - </div> - </SearchableSetting> - </section> - ) - } - return ( <section className="space-y-4"> <div className="space-y-1"> diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index 10f897625dd..2c9f2012d99 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -1,12 +1,9 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { ArrowRightLeft, GitBranch, ListChecks, Workflow } from 'lucide-react' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' -import type { GlobalSettings } from '../../../../shared/types' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal, - isOrcaCliAvailableOnPath + ensureOrcaCliAvailableForAgentSkillTerminal } from '@/lib/agent-skill-cli-prerequisite' import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples' @@ -23,15 +20,6 @@ import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCovera import { OrchestrationExampleDialog } from './OrchestrationExamplesDialog' import { OrchestrationSkillPromptDialog } from './OrchestrationSkillPromptDialog' import { translate } from '@/i18n/i18n' -import { - buildSkillInstallCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getAgentSkillTerminalShellOverride, - getSelectedAgentRuntime, - getSkillDiscoveryTargetForRuntime, - type LocalAgentRuntime -} from './CliSkillRuntimeSetup' -import { getDesktopPlatformFromUserAgent } from './GeneralPane' const EXAMPLE_ICONS = { handoff: ArrowRightLeft, @@ -41,55 +29,11 @@ const EXAMPLE_ICONS = { 'child-worktrees': Workflow } as const -type OrchestrationPaneProps = { - currentPlatform?: string - settings?: GlobalSettings | null - wslSupportedPlatform?: boolean - wslAvailable?: boolean - wslCapabilitiesLoading?: boolean -} - -function getOrchestrationSkillRuntime(props: OrchestrationPaneProps): LocalAgentRuntime { - if (!props.settings) { - return { - runtime: 'host', - label: translate('auto.components.settings.OrchestrationPane.thisDevice', 'This device') - } - } - return getSelectedAgentRuntime( - props.settings, - props.wslSupportedPlatform ?? false, - props.wslAvailable ?? false, - props.wslCapabilitiesLoading ?? false - ) -} - -export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX.Element { +export function OrchestrationPane(): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const showOrchestration = matchesSettingsSearch(searchQuery, getOrchestrationPaneSearchEntries()) const [selectedExampleId, setSelectedExampleId] = useState<string | null>(null) const [skillPromptOpen, setSkillPromptOpen] = useState(false) - const currentPlatform = - props.currentPlatform ?? - (typeof navigator === 'undefined' - ? 'other' - : getDesktopPlatformFromUserAgent(navigator.userAgent)) - const skillRuntime = useMemo(() => getOrchestrationSkillRuntime(props), [props]) - const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>( - () => getSkillDiscoveryTargetForRuntime(skillRuntime), - [skillRuntime] - ) - const skillInstallCommand = buildSkillInstallCommandForRuntime( - ORCHESTRATION_SKILL_INSTALL_COMMAND, - skillRuntime - ) - const skillTerminalShellOverride = props.settings - ? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime) - : undefined - const getSkillPrerequisiteStatus = () => - skillRuntime.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus() - : window.api.cli.getInstallStatus() const { installed: orchestrationSkillDetected, @@ -98,7 +42,6 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX skills: discoveredSkills, refresh: refreshOrchestrationSkill } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { - discoveryTarget: skillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -128,23 +71,18 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX 'auto.components.settings.OrchestrationPane.9bedd2a6e5', 'Enables agents to hand off context and coordinate work through Orca.' )} - command={skillInstallCommand} + command={ORCHESTRATION_SKILL_INSTALL_COMMAND} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" - terminalWorktreeId={`settings-orchestration-skill-terminal-${skillRuntime.runtime}`} - terminalShellOverride={skillTerminalShellOverride} + terminalWorktreeId="settings-orchestration-skill-terminal" installed={orchestrationSkillDetected} loading={orchestrationSkillLoading} error={orchestrationSkillError} icon={<Workflow className="size-5" />} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={getSkillPrerequisiteStatus} - isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await (skillRuntime.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal() - : ensureOrcaCliAvailableForAgentSkillTerminal()) + await ensureOrcaCliAvailableForAgentSkillTerminal() }} actionHint={ <p className="text-[12px] leading-snug text-muted-foreground"> @@ -175,7 +113,7 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX /> <OrchestrationSkillPromptDialog - command={skillInstallCommand} + command={ORCHESTRATION_SKILL_INSTALL_COMMAND} open={skillPromptOpen} onOpenChange={setSkillPromptOpen} /> diff --git a/src/renderer/src/components/settings/ProviderHostScopeControl.tsx b/src/renderer/src/components/settings/ProviderHostScopeControl.tsx new file mode 100644 index 00000000000..4326ee3428f --- /dev/null +++ b/src/renderer/src/components/settings/ProviderHostScopeControl.tsx @@ -0,0 +1,49 @@ +import { ServerCog } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import type { ProviderAccountScope, ProviderRateLimitScope } from './provider-account-scope' +import { translate } from '@/i18n/i18n' + +type ProviderHostScopeControlProps = { + labelPrefix: string + scope: ProviderAccountScope | ProviderRateLimitScope + className?: string +} + +export function ProviderHostScopeControl({ + labelPrefix, + scope, + className +}: ProviderHostScopeControlProps): React.JSX.Element { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + + const openHostsSettings = (): void => { + openSettingsPage() + openSettingsTarget({ pane: 'servers', repoId: null, sectionId: 'default-runtime' }) + } + + return ( + <div className={className}> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <span className="font-medium text-foreground"> + {translate( + 'auto.components.settings.ProviderHostScopeControl.scope_label', + '{{value0}}: {{value1}}', + { value0: labelPrefix, value1: scope.label } + )} + </span> + <div className="mt-0.5 text-muted-foreground">{scope.description}</div> + </div> + <Button type="button" variant="ghost" size="sm" onClick={openHostsSettings}> + <ServerCog className="size-3.5" /> + {translate( + 'auto.components.settings.ProviderHostScopeControl.change_host', + 'Open Remote Servers' + )} + </Button> + </div> + </div> + ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx new file mode 100644 index 00000000000..bffc71f2d82 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -0,0 +1,163 @@ +import { Pencil, Trash2 } from 'lucide-react' +import type { + Repo, + TerminalQuickCommand, + TerminalQuickCommandScope +} from '../../../../shared/types' +import { + getTerminalQuickCommandBody, + getTerminalQuickCommandScope, + isTerminalAgentQuickCommand +} from '../../../../shared/terminal-quick-commands' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { RepoBadgeMark } from '../repo/RepoBadgeLabel' +import { getQuickCommandRepoLabel } from './QuickCommandsScopeFilter' + +function getScopeLabel( + scope: TerminalQuickCommandScope, + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> +): string { + if (scope.type === 'global') { + return 'Global' + } + const repo = repoById.get(scope.repoId) + return repo ? getQuickCommandRepoLabel(repo) : 'Missing project' +} + +function QuickCommandRow({ + command, + repoById, + onEdit, + onRemove +}: { + command: TerminalQuickCommand + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + const scope = getTerminalQuickCommandScope(command) + return ( + <div className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs"> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <div className="truncate text-sm font-medium"> + {command.label || + translate('auto.components.settings.QuickCommandsPane.2bb9e38e93', 'Untitled')} + </div> + <Badge variant="outline" className="max-w-44 gap-1.5"> + {scope.type === 'repo' ? ( + <> + <RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} /> + <span className="truncate">{getScopeLabel(scope, repoById)}</span> + </> + ) : ( + <span className="truncate">{getScopeLabel(scope, repoById)}</span> + )} + </Badge> + </div> + <div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80"> + {isTerminalAgentQuickCommand(command) ? ( + <span className="shrink-0 text-muted-foreground"> + <AgentIcon agent={command.agent} size={12} /> + </span> + ) : null} + <span className={cn('truncate', isTerminalAgentQuickCommand(command) ? '' : 'font-mono')}> + {isTerminalAgentQuickCommand(command) + ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` + : getTerminalQuickCommandBody(command) || + translate( + 'auto.components.settings.QuickCommandsPane.0252ddd578', + 'No command text' + )} + </span> + </div> + </div> + <div className="shrink-0 text-[11px] font-medium text-foreground/75"> + {isTerminalAgentQuickCommand(command) + ? translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent') + : command.appendEnter + ? translate('auto.components.settings.QuickCommandsPane.9b3e338d62', 'Enter') + : translate('auto.components.settings.QuickCommandsPane.9fcfc29519', 'Insert')} + </div> + <Button + type="button" + variant="ghost" + size="icon-sm" + aria-label={translate( + 'auto.components.settings.QuickCommandsPane.7d90fd5299', + 'Edit {{value0}}', + { + value0: command.label || 'quick command' + } + )} + onClick={() => onEdit(command)} + > + <Pencil /> + </Button> + <Button + type="button" + variant="ghost" + size="icon-sm" + aria-label={translate( + 'auto.components.settings.QuickCommandsPane.8764c6e9e4', + 'Remove {{value0}}', + { + value0: command.label || 'quick command' + } + )} + onClick={() => onRemove(command)} + className="text-muted-foreground hover:text-destructive" + > + <Trash2 /> + </Button> + </div> + ) +} + +export function QuickCommandsList({ + commands, + visibleCommands, + repoById, + onEdit, + onRemove +}: { + commands: TerminalQuickCommand[] + visibleCommands: TerminalQuickCommand[] + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + return ( + <div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20"> + {visibleCommands.length === 0 ? ( + <div className="px-3 py-6 text-sm text-muted-foreground"> + {commands.length === 0 + ? translate( + 'auto.components.settings.QuickCommandsPane.38d61927e6', + 'No quick commands saved.' + ) + : translate( + 'auto.components.settings.QuickCommandsPane.3eb9897ab0', + 'No commands in the selected scopes.' + )} + </div> + ) : ( + <div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek"> + {visibleCommands.map((command) => ( + <QuickCommandRow + key={command.id} + command={command} + repoById={repoById} + onEdit={onEdit} + onRemove={onRemove} + /> + ))} + </div> + )} + </div> + ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index acba1735bc0..4b70c72c900 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -1,31 +1,19 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react' -import type { - GlobalSettings, - Repo, - TerminalQuickCommand, - TerminalQuickCommandScope -} from '../../../../shared/types' -import { - getTerminalQuickCommandBody, - getTerminalQuickCommandScope, - isTerminalAgentQuickCommand -} from '../../../../shared/terminal-quick-commands' +import { Plus } from 'lucide-react' +import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types' +import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' import { useAppStore } from '../../store' -import { Badge } from '../ui/badge' import { Button } from '../ui/button' -import { Command, CommandItem, CommandList } from '../ui/command' import { Label } from '../ui/label' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' -import RepoBadgeLabel, { RepoBadgeMark } from '../repo/RepoBadgeLabel' -import { cn } from '@/lib/utils' import { useConfirmationDialog } from '@/components/confirmation-dialog' -import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' +import { QuickCommandsList } from './QuickCommandsList' +import { GLOBAL_SCOPE_KEY, QuickCommandsScopeFilter } from './QuickCommandsScopeFilter' type QuickCommandsPaneProps = { settings: GlobalSettings @@ -33,8 +21,6 @@ type QuickCommandsPaneProps = { addCommandIntentSignal?: number } -const GLOBAL_SCOPE_KEY = '__global__' - type EditorState = | { mode: 'add' @@ -46,10 +32,6 @@ type EditorState = } | null -function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string { - return repo.displayName || repo.path -} - export function shouldOpenQuickCommandAddIntent( addCommandIntentSignal: number | undefined, consumedAddIntentSignal: number @@ -57,17 +39,6 @@ export function shouldOpenQuickCommandAddIntent( return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal) } -function getScopeLabel( - scope: TerminalQuickCommandScope, - repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> -): string { - if (scope.type === 'global') { - return 'Global' - } - const repo = repoById.get(scope.repoId) - return repo ? getRepoLabel(repo) : 'Missing project' -} - export function QuickCommandsPane({ settings, updateSettings, @@ -76,6 +47,7 @@ export function QuickCommandsPane({ const repos = useAppStore((s) => s.repos) const activeRepoId = useAppStore((s) => s.activeRepoId) const commands = settings.terminalQuickCommands ?? [] + const ownership = getSettingOwnershipSummary('terminalQuickCommands') const confirm = useConfirmationDialog() const [editor, setEditor] = useState<EditorState>(null) @@ -162,23 +134,6 @@ export function QuickCommandsPane({ setScopeSelection(null) } - const renderTriggerLabel = (): React.JSX.Element => { - if (showAll) { - return <span>{translate("auto.components.settings.QuickCommandsPane.c6b155911b", "All commands")}</span> - } - const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY) - const selectedRepos = repos.filter((r) => effectiveSelection.has(r.id)) - const parts: string[] = [] - if (includesGlobal) { - parts.push('Global') - } - if (selectedRepos.length > 0) { - const [first, ...rest] = selectedRepos - parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName) - } - return <span className="truncate">{parts.join(', ') || translate("auto.components.settings.QuickCommandsPane.d1d0976320", "None")}</span> - } - const saveCommand = (next: TerminalQuickCommand): void => { // Why: re-read from the store so save lands on the latest list when // multiple edit dialogs fire in quick succession. @@ -193,9 +148,16 @@ export function QuickCommandsPane({ const removeCommand = async (command: TerminalQuickCommand): Promise<void> => { const confirmed = await confirm({ - title: translate("auto.components.settings.QuickCommandsPane.3edf3deaf8", "Delete \"{{value0}}\"?", { value0: command.label || 'Untitled' }), - description: translate("auto.components.settings.QuickCommandsPane.3d9dc558e8", "This quick command will be removed from your saved list."), - confirmLabel: translate("auto.components.settings.QuickCommandsPane.ec1ed99e70", "Delete"), + title: translate( + 'auto.components.settings.QuickCommandsPane.3edf3deaf8', + 'Delete "{{value0}}"?', + { value0: command.label || 'Untitled' } + ), + description: translate( + 'auto.components.settings.QuickCommandsPane.3d9dc558e8', + 'This quick command will be removed from your saved list.' + ), + confirmLabel: translate('auto.components.settings.QuickCommandsPane.ec1ed99e70', 'Delete'), confirmVariant: 'destructive' }) if (!confirmed) { @@ -214,9 +176,10 @@ export function QuickCommandsPane({ <div className="space-y-3"> <div className="flex items-center justify-between gap-3 py-2"> <div className="space-y-1"> - <Label>{translate("auto.components.settings.QuickCommandsPane.f91b649324", "Saved Commands")}</Label> - <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.QuickCommandsPane.c36912efd5", "Run them from the Quick Commands button in the tab bar, or right-click inside any terminal.")}</p> + <Label> + {translate('auto.components.settings.QuickCommandsPane.f91b649324', 'Saved Commands')} + </Label> + <p className="text-xs text-muted-foreground">{ownership.description}</p> </div> <Button type="button" @@ -225,172 +188,27 @@ export function QuickCommandsPane({ onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })} > <Plus /> - {translate("auto.components.settings.QuickCommandsPane.5aacc8f7dc", "Add Command")}</Button> + {translate('auto.components.settings.QuickCommandsPane.5aacc8f7dc', 'Add Command')} + </Button> </div> - <div className="flex flex-wrap items-center gap-2"> - <Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - role="combobox" - aria-expanded={scopePopoverOpen} - className="h-8 min-w-52 justify-between px-3 text-xs font-normal" - > - {renderTriggerLabel()} - <ChevronsUpDown className="size-3.5 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent - align="start" - className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" - > - <Command> - <div className="border-b border-border"> - <button - type="button" - onClick={handleSelectAll} - onMouseDown={(event) => event.preventDefault()} - className={cn( - 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', - showAll && 'opacity-80' - )} - > - <Check - className={cn( - 'size-3 text-muted-foreground', - showAll ? 'opacity-70' : 'opacity-0' - )} - /> - <span>{translate("auto.components.settings.QuickCommandsPane.c6b155911b", "All commands")}</span> - </button> - </div> - <CommandList> - <CommandItem - value={GLOBAL_SCOPE_KEY} - onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - <Check - className={cn( - 'size-3 text-muted-foreground', - effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0' - )} - /> - <span>{translate("auto.components.settings.QuickCommandsPane.8c877dec41", "Global")}</span> - </CommandItem> - {repos.map((repo) => { - const isSelected = effectiveSelection.has(repo.id) - return ( - <CommandItem - key={repo.id} - value={repo.id} - onSelect={() => toggleScope(repo.id)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - <Check - className={cn( - 'size-3 text-muted-foreground', - isSelected ? 'opacity-70' : 'opacity-0' - )} - /> - <RepoBadgeLabel - name={getRepoLabel(repo)} - color={repo.badgeColor} - className="max-w-full" - /> - </CommandItem> - ) - })} - </CommandList> - </Command> - </PopoverContent> - </Popover> - </div> + <QuickCommandsScopeFilter + repos={repos} + effectiveSelection={effectiveSelection} + showAll={showAll} + scopePopoverOpen={scopePopoverOpen} + setScopePopoverOpen={setScopePopoverOpen} + handleSelectAll={handleSelectAll} + toggleScope={toggleScope} + /> - <div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20"> - {visibleCommands.length === 0 ? ( - <div className="px-3 py-6 text-sm text-muted-foreground"> - {commands.length === 0 - ? translate("auto.components.settings.QuickCommandsPane.38d61927e6", "No quick commands saved.") - : translate("auto.components.settings.QuickCommandsPane.3eb9897ab0", "No commands in the selected scopes.")} - </div> - ) : ( - <div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek"> - {visibleCommands.map((command) => { - const scope = getTerminalQuickCommandScope(command) - return ( - <div - key={command.id} - className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs" - > - <div className="min-w-0 flex-1"> - <div className="flex min-w-0 items-center gap-2"> - <div className="truncate text-sm font-medium"> - {command.label || translate("auto.components.settings.QuickCommandsPane.2bb9e38e93", "Untitled")} - </div> - <Badge variant="outline" className="max-w-44 gap-1.5"> - {scope.type === 'repo' ? ( - <> - <RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} /> - <span className="truncate">{getScopeLabel(scope, repoById)}</span> - </> - ) : ( - <span className="truncate">{getScopeLabel(scope, repoById)}</span> - )} - </Badge> - </div> - <div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80"> - {isTerminalAgentQuickCommand(command) ? ( - <span className="shrink-0 text-muted-foreground"> - <AgentIcon agent={command.agent} size={12} /> - </span> - ) : null} - <span - className={cn( - 'truncate', - isTerminalAgentQuickCommand(command) ? '' : 'font-mono' - )} - > - {isTerminalAgentQuickCommand(command) - ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` - : getTerminalQuickCommandBody(command) || translate("auto.components.settings.QuickCommandsPane.0252ddd578", "No command text")} - </span> - </div> - </div> - <div className="shrink-0 text-[11px] font-medium text-foreground/75"> - {isTerminalAgentQuickCommand(command) - ? translate("auto.components.settings.QuickCommandsPane.4ccc63da87", "Agent") - : command.appendEnter - ? translate("auto.components.settings.QuickCommandsPane.9b3e338d62", "Enter") - : translate("auto.components.settings.QuickCommandsPane.9fcfc29519", "Insert")} - </div> - <Button - type="button" - variant="ghost" - size="icon-sm" - aria-label={translate("auto.components.settings.QuickCommandsPane.7d90fd5299", "Edit {{value0}}", { value0: command.label || 'quick command' })} - onClick={() => setEditor({ mode: 'edit', command })} - > - <Pencil /> - </Button> - <Button - type="button" - variant="ghost" - size="icon-sm" - aria-label={translate("auto.components.settings.QuickCommandsPane.8764c6e9e4", "Remove {{value0}}", { value0: command.label || 'quick command' })} - onClick={() => void removeCommand(command)} - className="text-muted-foreground hover:text-destructive" - > - <Trash2 /> - </Button> - </div> - ) - })} - </div> - )} - </div> + <QuickCommandsList + commands={commands} + visibleCommands={visibleCommands} + repoById={repoById} + onEdit={(command) => setEditor({ mode: 'edit', command })} + onRemove={(command) => void removeCommand(command)} + /> {editor !== null ? ( <TerminalQuickCommandDialog diff --git a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx new file mode 100644 index 00000000000..e845b9dd0b4 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx @@ -0,0 +1,161 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import type { Dispatch, SetStateAction } from 'react' +import type { Repo } from '../../../../shared/types' +import { Button } from '../ui/button' +import { Command, CommandItem, CommandList } from '../ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import RepoBadgeLabel from '../repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export const GLOBAL_SCOPE_KEY = '__global__' + +export function getQuickCommandRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string { + return repo.displayName || repo.path +} + +function ScopeTriggerLabel({ + showAll, + effectiveSelection, + repos +}: { + showAll: boolean + effectiveSelection: ReadonlySet<string> + repos: Repo[] +}): React.JSX.Element { + if (showAll) { + return ( + <span> + {translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')} + </span> + ) + } + const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY) + const selectedRepos = repos.filter((repo) => effectiveSelection.has(repo.id)) + const parts: string[] = [] + if (includesGlobal) { + parts.push('Global') + } + if (selectedRepos.length > 0) { + const [first, ...rest] = selectedRepos + parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName) + } + return ( + <span className="truncate"> + {parts.join(', ') || + translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')} + </span> + ) +} + +export function QuickCommandsScopeFilter({ + repos, + effectiveSelection, + showAll, + scopePopoverOpen, + setScopePopoverOpen, + handleSelectAll, + toggleScope +}: { + repos: Repo[] + effectiveSelection: ReadonlySet<string> + showAll: boolean + scopePopoverOpen: boolean + setScopePopoverOpen: Dispatch<SetStateAction<boolean>> + handleSelectAll: () => void + toggleScope: (key: string) => void +}): React.JSX.Element { + return ( + <div className="flex flex-wrap items-center gap-2"> + <Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={scopePopoverOpen} + className="h-8 min-w-52 justify-between px-3 text-xs font-normal" + > + <ScopeTriggerLabel + showAll={showAll} + effectiveSelection={effectiveSelection} + repos={repos} + /> + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <div className="border-b border-border"> + <button + type="button" + onClick={handleSelectAll} + onMouseDown={(event) => event.preventDefault()} + className={cn( + 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', + showAll && 'opacity-80' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + showAll ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate( + 'auto.components.settings.QuickCommandsPane.c6b155911b', + 'All commands' + )} + </span> + </button> + </div> + <CommandList> + <CommandItem + value={GLOBAL_SCOPE_KEY} + onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')} + </span> + </CommandItem> + {repos.map((repo) => { + const isSelected = effectiveSelection.has(repo.id) + return ( + <CommandItem + key={repo.id} + value={repo.id} + onSelect={() => toggleScope(repo.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + isSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <RepoBadgeLabel + name={getQuickCommandRepoLabel(repo)} + color={repo.badgeColor} + className="max-w-full" + /> + </CommandItem> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx new file mode 100644 index 00000000000..092bd558376 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react' +import { type ExecutionHostId } from '../../../../shared/execution-host' +import type { + ProjectHostSetup, + ProjectHostSetupCreateResult, + ProjectHostSetupResult +} from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import type { SetupHostOption } from './repository-host-setup-options' + +type RepositoryHostSetupActionsProps = { + repoDisplayName: string + selectedProjectHostSetup: ProjectHostSetup + setupHostOptions: SetupHostOption[] + setupProjectExistingFolder: (args: { + projectId: string + hostId: ExecutionHostId + path: string + kind: 'git' | 'folder' + displayName: string + }) => Promise<ProjectHostSetupResult | null> + setupProjectClone: (args: { + projectId: string + hostId: ExecutionHostId + url: string + destination: string + displayName: string + }) => Promise<ProjectHostSetupResult | null> + createProjectHostSetup: (args: { + projectId: string + hostId: ExecutionHostId + displayName: string + setupState: 'not-set-up' + setupMethod: 'provisioned' + }) => Promise<ProjectHostSetupCreateResult | null> + onOpenSetup: (repoId: string) => void +} + +export function RepositoryHostSetupActions({ + repoDisplayName, + selectedProjectHostSetup, + setupHostOptions, + setupProjectExistingFolder, + setupProjectClone, + createProjectHostSetup, + onOpenSetup +}: RepositoryHostSetupActionsProps): React.JSX.Element | null { + const [selectedSetupHostId, setSelectedSetupHostId] = useState<ExecutionHostId | null>(null) + const [setupPath, setSetupPath] = useState('') + const [setupKind, setSetupKind] = useState<'git' | 'folder'>('git') + const [cloneUrl, setCloneUrl] = useState('') + const [cloneDestination, setCloneDestination] = useState('') + const [isSettingUp, setIsSettingUp] = useState(false) + const [isCloning, setIsCloning] = useState(false) + const [isCreatingPendingSetup, setIsCreatingPendingSetup] = useState(false) + const defaultSetupHostOption = + setupHostOptions.find((option) => option.isAvailable) ?? setupHostOptions[0] ?? null + const setupTargetHostId = selectedSetupHostId ?? defaultSetupHostOption?.id ?? null + const setupTargetHostOption = + setupHostOptions.find((option) => option.id === setupTargetHostId) ?? null + const canUseSetupTargetHost = setupTargetHostOption?.isAvailable ?? false + + if (setupHostOptions.length === 0) { + return null + } + + return ( + <div className="space-y-3 rounded-md border border-border bg-muted/20 p-3"> + <div className="space-y-1"> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.setupProjectOnHost', + 'Set up on another host' + )} + </Label> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RepositoryPane.setupProjectOnHostHelp', + 'Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.' + )} + </p> + </div> + <div className="max-w-48"> + <Select + value={setupTargetHostId ?? undefined} + onValueChange={(value) => setSelectedSetupHostId(value as ExecutionHostId)} + > + <SelectTrigger className="h-9 min-w-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {setupHostOptions.map((option) => ( + <SelectItem key={option.id} value={option.id} disabled={!option.isAvailable}> + <span className="min-w-0"> + <span className="block truncate">{option.label}</span> + {!option.isAvailable ? ( + <span className="block truncate text-[11px] text-muted-foreground"> + {option.detail} + </span> + ) : null} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]"> + <Input + value={setupPath} + onChange={(event) => setSetupPath(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.setupExistingFolderPathPlaceholder', + '/path/to/project/on/host' + )} + className="h-9 min-w-0" + /> + <Select + value={setupKind} + onValueChange={(value) => setSetupKind(value as 'git' | 'folder')} + > + <SelectTrigger className="h-9 w-32 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="git"> + {translate('auto.components.settings.RepositoryPane.setupKindGit', 'Git repo')} + </SelectItem> + <SelectItem value="folder"> + {translate('auto.components.settings.RepositoryPane.setupKindFolder', 'Folder')} + </SelectItem> + </SelectContent> + </Select> + <Button + type="button" + size="sm" + disabled={!canUseSetupTargetHost || !setupPath.trim() || isSettingUp} + onClick={async () => { + if (!setupTargetHostId || !canUseSetupTargetHost || !setupPath.trim()) { + return + } + setIsSettingUp(true) + const result = await setupProjectExistingFolder({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + path: setupPath.trim(), + kind: setupKind, + displayName: repoDisplayName + }) + setIsSettingUp(false) + if (result) { + setSetupPath('') + setSelectedSetupHostId(null) + onOpenSetup(result.repo.id) + } + }} + > + {isSettingUp + ? translate('auto.components.settings.RepositoryPane.settingUpHost', 'Importing...') + : translate('auto.components.settings.RepositoryPane.setupHost', 'Import')} + </Button> + </div> + <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> + <Input + value={cloneUrl} + onChange={(event) => setCloneUrl(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneUrlPlaceholder', + 'Repository URL' + )} + className="h-9 min-w-0" + /> + <Input + value={cloneDestination} + onChange={(event) => setCloneDestination(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneDestinationPlaceholder', + '/destination/on/host' + )} + className="h-9 min-w-0" + /> + <Button + type="button" + size="sm" + disabled={ + !canUseSetupTargetHost || !cloneUrl.trim() || !cloneDestination.trim() || isCloning + } + onClick={async () => { + if ( + !setupTargetHostId || + !canUseSetupTargetHost || + !cloneUrl.trim() || + !cloneDestination.trim() + ) { + return + } + setIsCloning(true) + const result = await setupProjectClone({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + url: cloneUrl.trim(), + destination: cloneDestination.trim(), + displayName: repoDisplayName + }) + setIsCloning(false) + if (result) { + setCloneUrl('') + setCloneDestination('') + setSelectedSetupHostId(null) + onOpenSetup(result.repo.id) + } + }} + > + {isCloning + ? translate('auto.components.settings.RepositoryPane.cloningHost', 'Cloning...') + : translate('auto.components.settings.RepositoryPane.cloneHost', 'Clone')} + </Button> + </div> + <div className="flex justify-end"> + <Button + type="button" + variant="outline" + size="sm" + disabled={!canUseSetupTargetHost || isCreatingPendingSetup} + onClick={async () => { + if (!setupTargetHostId || !canUseSetupTargetHost) { + return + } + setIsCreatingPendingSetup(true) + const result = await createProjectHostSetup({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + displayName: repoDisplayName, + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + setIsCreatingPendingSetup(false) + if (result) { + setSelectedSetupHostId(null) + } + }} + > + {isCreatingPendingSetup + ? translate( + 'auto.components.settings.RepositoryPane.creatingPendingSetup', + 'Creating...' + ) + : translate( + 'auto.components.settings.RepositoryPane.createPendingSetup', + 'Track setup' + )} + </Button> + </div> + </div> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx new file mode 100644 index 00000000000..f0be673da8d --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -0,0 +1,606 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' + +let container: HTMLDivElement +let root: Root + +function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +function makeProject({ id, ...overrides }: Partial<Project> & Pick<Project, 'id'>): Project { + return { + id, + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: ['local-repo', 'remote-repo'], + createdAt: 100, + updatedAt: 100, + ...overrides + } +} + +function makeSetup( + overrides: Partial<ProjectHostSetup> & + Pick<ProjectHostSetup, 'id' | 'projectId' | 'repoId' | 'hostId' | 'path'> +): ProjectHostSetup { + return { + displayName: 'Orca', + kind: 'git', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 100, + updatedAt: 100, + ...overrides + } +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +function renderSection(repo: Repo): void { + act(() => { + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + }) +} + +function typeIntoInput(input: HTMLInputElement, value: string): void { + act(() => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('RepositoryHostSetupsSection', () => { + it('shows a viewing-host selector when the project has multiple settings-backed hosts', () => { + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + useAppStore.setState({ + repos: [localRepo, remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]) + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Viewing host') + expect(container.textContent).toContain('Local Mac') + }) + + it('opens the selected host setup settings pane through the setup repo id', () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + useAppStore.setState({ + repos: [localRepo, remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }) + ], + openSettingsPage, + openSettingsTarget + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('openclaw 2') + const openButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Open' + ) + expect(openButton).toBeTruthy() + + act(() => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('removes independent setup metadata instead of opening an empty repo target', async () => { + const deleteProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '' + }) + }) + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ], + openSettingsPage, + openSettingsTarget, + deleteProjectHostSetup + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Path pending') + const removeButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove' + ) + expect(removeButton).toBeTruthy() + + await act(async () => { + removeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'gpu-setup' }) + expect(openSettingsPage).not.toHaveBeenCalled() + expect(openSettingsTarget).not.toHaveBeenCalled() + }) + + it('sets up the project on another known host from an existing folder path', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const setupProjectExistingFolder = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }), + repo: makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), + openSettingsPage, + openSettingsTarget, + setupProjectExistingFolder + }) + + renderSection(localRepo) + const pathInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="/path/to/project/on/host"]' + ) + expect(pathInput).toBeTruthy() + typeIntoInput(pathInput!, '/home/alice/orca') + + const importButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Import' + ) + expect(importButton).toBeTruthy() + + await act(async () => { + importButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(setupProjectExistingFolder).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + path: '/home/alice/orca', + kind: 'git', + displayName: 'Orca' + }) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('clones the project onto another known host from settings', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const setupProjectClone = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }), + repo: makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), + openSettingsPage, + openSettingsTarget, + setupProjectClone + }) + + renderSection(localRepo) + const urlInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="Repository URL"]' + ) + const destinationInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="/destination/on/host"]' + ) + expect(urlInput).toBeTruthy() + expect(destinationInput).toBeTruthy() + typeIntoInput(urlInput!, 'https://github.com/stablyai/orca.git') + typeIntoInput(destinationInput!, '/home/alice') + + const cloneButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Clone' + ) + expect(cloneButton).toBeTruthy() + + await act(async () => { + cloneButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(setupProjectClone).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/alice', + displayName: 'Orca' + }) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('creates pending setup metadata for a known host without requiring a path', async () => { + const createProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: 'gpu' } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + } + } + ] + ]), + createProjectHostSetup + }) + + renderSection(localRepo) + + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + expect(trackButton).toBeTruthy() + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + displayName: 'Orca', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + + it('shows unsupported runtime hosts without enabling setup actions', async () => { + const createProjectHostSetup = vi.fn() + const setupProjectClone = vi.fn() + const setupProjectExistingFolder = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: null } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.7.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [] + } + } + ] + ]), + createProjectHostSetup, + setupProjectClone, + setupProjectExistingFolder + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Update Orca on this host to set up projects') + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + expect(trackButton?.disabled).toBe(true) + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).not.toHaveBeenCalled() + expect(setupProjectClone).not.toHaveBeenCalled() + expect(setupProjectExistingFolder).not.toHaveBeenCalled() + }) + + it('offers inactive runtime hosts discovered from hydrated runtime status', async () => { + const createProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: null } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + } + } + ] + ]), + createProjectHostSetup + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('gpu') + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + displayName: 'Orca', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx new file mode 100644 index 00000000000..f149567f6aa --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from 'react' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import type { Repo } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { getProjectHostSetupProjectionFromState } from '../../store/selectors' +import { cn } from '../../lib/utils' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { SettingsBadge } from './SettingsFormControls' +import { matchesSettingsSearch } from './settings-search' +import type { SettingsSearchEntry } from './settings-search' +import { translate } from '@/i18n/i18n' +import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options' +import { RepositoryHostSetupActions } from './RepositoryHostSetupActions' + +type RepositoryHostSetupsSectionProps = { + repo: Repo + forceVisible: boolean + searchQuery: string + searchEntries: SettingsSearchEntry[] +} + +export function RepositoryHostSetupsSection({ + repo, + forceVisible, + searchQuery, + searchEntries +}: RepositoryHostSetupsSectionProps): React.JSX.Element | null { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder) + const setupProjectClone = useAppStore((state) => state.setupProjectClone) + const createProjectHostSetup = useAppStore((state) => state.createProjectHostSetup) + const deleteProjectHostSetup = useAppStore((state) => state.deleteProjectHostSetup) + const repos = useAppStore((state) => state.repos) + const sshTargetLabels = useAppStore((state) => state.sshTargetLabels) + const sshConnectionStates = useAppStore((state) => state.sshConnectionStates) + const settings = useAppStore((state) => state.settings) + const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const projectHostSetupProjection = useAppStore((state) => + getProjectHostSetupProjectionFromState(state) + ) + const selectedProjectHostSetup = projectHostSetupProjection.setups.find( + (setup) => setup.repoId === repo.id + ) + const projectHostSetups = selectedProjectHostSetup + ? projectHostSetupProjection.setups.filter( + (setup) => setup.projectId === selectedProjectHostSetup.projectId + ) + : [] + const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) + const setupHostOptions = buildSetupHostOptions({ + projectHostSetups, + hostOptions + }) + const hostOptionById = new Map(hostOptions.map((option) => [option.id, option])) + const [deletingSetupId, setDeletingSetupId] = useState<string | null>(null) + const openSetup = (repoId: string) => { + openSettingsPage() + openSettingsTarget({ pane: 'repo', repoId }) + } + + if ( + (projectHostSetups.length <= 1 && setupHostOptions.length === 0) || + (!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries)) + ) { + return null + } + + return ( + <SearchableSetting + title={translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')} + description={translate( + 'auto.components.settings.RepositoryPane.availableHostsDescription', + 'Hosts where this project is set up.' + )} + keywords={[repo.displayName, 'host', 'ssh', 'remote', 'vm', 'path']} + className="space-y-3" + forceVisible={forceVisible} + > + <div className="space-y-1"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <Label className="text-sm font-semibold"> + {translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')} + </Label> + {openableProjectHostSetups.length > 1 ? ( + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground"> + {translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')} + </span> + <Select + value={repo.id} + onValueChange={(repoId) => { + if (repoId === repo.id) { + return + } + openSetup(repoId) + }} + > + <SelectTrigger className="h-8 w-44 min-w-0 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {openableProjectHostSetups.map((setup) => ( + <SelectItem key={setup.id} value={setup.repoId}> + <span className="block min-w-0 truncate"> + {hostOptionById.get(setup.hostId)?.label ?? + getExecutionHostLabel(setup.hostId)} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : null} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RepositoryPane.availableHostsHelp', + 'Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.' + )} + </p> + </div> + <div className="divide-y divide-border rounded-md border border-border"> + {projectHostSetups.map((setup) => { + const isCurrentSetup = setup.repoId === repo.id + const canOpenSetup = setup.repoId.trim().length > 0 + const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id + return ( + <div + key={setup.id} + className={cn( + 'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors', + isCurrentSetup ? 'bg-muted/30' : '' + )} + > + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <span className="truncate text-sm font-medium"> + {hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)} + </span> + <SettingsBadge tone={setup.setupState === 'ready' ? 'accent' : 'muted'}> + {getSetupStateLabel(setup.setupState)} + </SettingsBadge> + </div> + <p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"> + {setup.path || + translate( + 'auto.components.settings.RepositoryPane.setupPathPending', + 'Path pending' + )} + </p> + </div> + {isCurrentSetup ? ( + <SettingsBadge> + {translate('auto.components.settings.RepositoryPane.currentSetup', 'Current')} + </SettingsBadge> + ) : null} + {!isCurrentSetup && canOpenSetup ? ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + openSetup(setup.repoId) + }} + > + {translate('auto.components.settings.RepositoryPane.openSetup', 'Open')} + </Button> + ) : null} + {canRemoveSetup ? ( + <Button + type="button" + variant="outline" + size="sm" + onClick={async () => { + setDeletingSetupId(setup.id) + await deleteProjectHostSetup({ setupId: setup.id }) + setDeletingSetupId(null) + }} + > + {translate('auto.components.settings.RepositoryPane.removeSetup', 'Remove')} + </Button> + ) : null} + </div> + ) + })} + </div> + {selectedProjectHostSetup ? ( + <RepositoryHostSetupActions + repoDisplayName={repo.displayName} + selectedProjectHostSetup={selectedProjectHostSetup} + setupHostOptions={setupHostOptions} + setupProjectExistingFolder={setupProjectExistingFolder} + setupProjectClone={setupProjectClone} + createProjectHostSetup={createProjectHostSetup} + onOpenSetup={openSetup} + /> + ) : null} + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 50ea2d598c5..f7b2b924663 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -10,6 +10,7 @@ import { Label } from '../ui/label' import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon' import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { useMountedRef } from '@/hooks/useMountedRef' import { RepositoryIconColorSection } from './RepositoryIconColorSection' import { RepositoryIconTabs } from './RepositoryIconTabs' @@ -29,8 +30,10 @@ export function RepositoryIconPicker({ const [loadingGitHub, setLoadingGitHub] = useState(false) const [resetting, setResetting] = useState(false) const mountedRef = useMountedRef() - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + // Why: resolve this repo's upstream/avatar on the host that owns it, not the + // focused runtime. + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repo.id) ) const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 40dce362ce2..f23de9a28cd 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import { Button } from '../ui/button' -import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { Trash2 } from 'lucide-react' @@ -19,6 +18,8 @@ import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './RepositoryIconPicker' import { getRepositoryPaneSearchEntries } from './repository-search' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' +import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' import { translate } from '@/i18n/i18n' export { getRepositoryPaneSearchEntries } @@ -36,61 +37,6 @@ type RepositoryPaneProps = { removeProject: (repoId: string) => void } -type RepoTextDraft = { repoId: string; text: string } - -// Why: updateRepo persists via async IPC before the store value updates, so a -// store-controlled input resets mid-IME-composition (Hangul decomposes into -// jamo). Keep keystrokes in local draft state; persist stays per-keystroke. -export function RepoSettingsDraftInput({ - repoId, - storeValue, - onTextChange, - ...inputProps -}: { - repoId: string - storeValue: string - onTextChange: (text: string) => void -} & Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'>): React.JSX.Element { - const [draft, setDraft] = useState<RepoTextDraft>({ repoId, text: storeValue }) - const pendingStoreEchoesRef = useRef<string[]>([]) - - useEffect(() => { - setDraft((current) => { - if (current.repoId !== repoId) { - pendingStoreEchoesRef.current = [] - return { repoId, text: storeValue } - } - if (storeValue === current.text) { - pendingStoreEchoesRef.current = [] - return current - } - const pendingEchoIndex = pendingStoreEchoesRef.current.indexOf(storeValue) - if (pendingEchoIndex !== -1) { - // Why: queued updateRepo calls can echo older input text after newer - // keystrokes; accepting that echo re-cancels active IME composition. - pendingStoreEchoesRef.current.splice(0, pendingEchoIndex + 1) - return current - } - pendingStoreEchoesRef.current = [] - return { repoId, text: storeValue } - }) - }, [repoId, storeValue]) - - const text = draft.repoId === repoId ? draft.text : storeValue - return ( - <Input - {...inputProps} - value={text} - onChange={(e) => { - const nextText = e.target.value - pendingStoreEchoesRef.current.push(nextText) - setDraft({ repoId, text: nextText }) - onTextChange(nextText) - }} - /> - ) -} - export function matchesRepositoryIdentitySearch(query: string, repo: Repo): boolean { const normalizedQuery = normalizeSettingsSearchQuery(query) if (!normalizedQuery) { @@ -201,6 +147,7 @@ export function RepositoryPane({ const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs') const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks') const sourceControlAiEntries = allEntries.filter((entry) => entry.title === 'Git AI Author') + const hostSetupEntries = allEntries.filter((entry) => entry.title === 'Available Hosts') const removeProjectLabel = confirmingRemove === repo.id ? 'Confirm Remove Project' : 'Remove Project' @@ -228,20 +175,37 @@ export function RepositoryPane({ <section key="identity" className="relative space-y-8"> <div className="flex items-start justify-between gap-4"> <div className="space-y-1 pr-12"> - <h3 className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.499a437335", "Identity")}</h3> + <h3 className="text-sm font-semibold"> + {translate('auto.components.settings.RepositoryPane.499a437335', 'Identity')} + </h3> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.b0a0c14a1c", "Project-specific display details for the sidebar and tabs.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.b0a0c14a1c', + 'Project-specific display details for the sidebar and tabs.' + )} + </p> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.323debba71", "Type:")}<span className="text-foreground">{getRepoKindLabel(repo)}</span> + {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')} + <span className="text-foreground">{getRepoKindLabel(repo)}</span> </p> {isFolder ? ( <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.ee5a290616", "Opened as folder. Git features are unavailable for this workspace.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.ee5a290616', + 'Opened as folder. Git features are unavailable for this workspace.' + )} + </p> ) : null} </div> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.0909e5d650", "Remove Project")} - description={translate("auto.components.settings.RepositoryPane.170624bdfb", "Remove this project from Orca.")} + title={translate( + 'auto.components.settings.RepositoryPane.0909e5d650', + 'Remove Project' + )} + description={translate( + 'auto.components.settings.RepositoryPane.170624bdfb', + 'Remove this project from Orca.' + )} keywords={[repo.displayName, 'delete', 'project', 'repository']} className="absolute top-0 right-0 z-10 w-auto max-w-none" forceVisible={forceFullPaneForRepoMatch} @@ -267,14 +231,18 @@ export function RepositoryPane({ </div> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.c7ef4415de", "Display Name")} - description={translate("auto.components.settings.RepositoryPane.b0a0c14a1c", "Project-specific display details for the sidebar and tabs.")} + title={translate('auto.components.settings.RepositoryPane.c7ef4415de', 'Display Name')} + description={translate( + 'auto.components.settings.RepositoryPane.b0a0c14a1c', + 'Project-specific display details for the sidebar and tabs.' + )} keywords={[repo.displayName, repo.path, 'project name', 'repository name']} className="space-y-2" forceVisible={forceFullPaneForRepoMatch} > <Label htmlFor={`repo-display-name-${repo.id}`} className="text-sm font-semibold"> - {translate("auto.components.settings.RepositoryPane.c7ef4415de", "Display Name")}</Label> + {translate('auto.components.settings.RepositoryPane.c7ef4415de', 'Display Name')} + </Label> <RepoSettingsDraftInput id={`repo-display-name-${repo.id}`} repoId={repo.id} @@ -285,8 +253,11 @@ export function RepositoryPane({ </SearchableSetting> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.26fef02bf3", "Project Icon")} - description={translate("auto.components.settings.RepositoryPane.e641c359de", "Project icon and color used in the sidebar and tabs.")} + title={translate('auto.components.settings.RepositoryPane.26fef02bf3', 'Project Icon')} + description={translate( + 'auto.components.settings.RepositoryPane.e641c359de', + 'Project icon and color used in the sidebar and tabs.' + )} keywords={[ repo.displayName, repo.path, @@ -306,14 +277,32 @@ export function RepositoryPane({ {!isFolder ? ( <> + <RepositoryHostSetupsSection + repo={repo} + forceVisible={forceFullPaneForRepoMatch} + searchQuery={searchQuery} + searchEntries={hostSetupEntries} + /> + <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.f88db4fece", "Default Worktree Base")} - description={translate("auto.components.settings.RepositoryPane.8984d06520", "Default base branch or ref when creating worktrees.")} + title={translate( + 'auto.components.settings.RepositoryPane.f88db4fece', + 'Default Worktree Base' + )} + description={translate( + 'auto.components.settings.RepositoryPane.8984d06520', + 'Default base branch or ref when creating worktrees.' + )} keywords={[repo.displayName, 'base ref', 'branch']} className="space-y-3" forceVisible={forceFullPaneForRepoMatch} > - <Label className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.f88db4fece", "Default Worktree Base")}</Label> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.f88db4fece', + 'Default Worktree Base' + )} + </Label> <BaseRefPicker repoId={repo.id} currentBaseRef={repo.worktreeBaseRef} @@ -323,8 +312,14 @@ export function RepositoryPane({ </SearchableSetting> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.e9bd57a336", "Worktree Location")} - description={translate("auto.components.settings.RepositoryPane.e63bb96a9b", "Project-specific directory for new worktrees.")} + title={translate( + 'auto.components.settings.RepositoryPane.e9bd57a336', + 'Worktree Location' + )} + description={translate( + 'auto.components.settings.RepositoryPane.e63bb96a9b', + 'Project-specific directory for new worktrees.' + )} keywords={[ repo.displayName, 'worktree path', @@ -337,7 +332,12 @@ export function RepositoryPane({ forceVisible={forceFullPaneForRepoMatch} > <div className="flex items-center justify-between gap-3"> - <Label className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.e9bd57a336", "Worktree Location")}</Label> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.e9bd57a336', + 'Worktree Location' + )} + </Label> {repo.worktreeBasePath ? ( <Button type="button" @@ -345,7 +345,8 @@ export function RepositoryPane({ size="sm" onClick={() => updateRepo(repo.id, { worktreeBasePath: undefined })} > - {translate("auto.components.settings.RepositoryPane.8ccacbeb5a", "Use Global")}</Button> + {translate('auto.components.settings.RepositoryPane.8ccacbeb5a', 'Use Global')} + </Button> ) : null} </div> <RepoSettingsDraftInput @@ -358,7 +359,11 @@ export function RepositoryPane({ className="h-9 text-sm" /> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.15a99d9b9f", "Relative paths resolve from this project root.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.15a99d9b9f', + 'Relative paths resolve from this project root.' + )} + </p> </SearchableSetting> </> ) : null} diff --git a/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx b/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx index e97134a7d34..e7d49a6e9a0 100644 --- a/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx +++ b/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RepoSettingsDraftInput } from './RepositoryPane' +import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' let container: HTMLDivElement let root: Root diff --git a/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx b/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx new file mode 100644 index 00000000000..c58c2343b59 --- /dev/null +++ b/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx @@ -0,0 +1,58 @@ +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import { Input } from '../ui/input' + +type RepoTextDraft = { repoId: string; text: string } + +// Why: updateRepo persists via async IPC before the store value updates, so a +// store-controlled input resets mid-IME-composition (Hangul decomposes into +// jamo). Keep keystrokes in local draft state; persist stays per-keystroke. +export function RepoSettingsDraftInput({ + repoId, + storeValue, + onTextChange, + ...inputProps +}: { + repoId: string + storeValue: string + onTextChange: (text: string) => void +} & Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'>): React.JSX.Element { + const [draft, setDraft] = useState<RepoTextDraft>({ repoId, text: storeValue }) + const pendingStoreEchoesRef = useRef<string[]>([]) + + useEffect(() => { + setDraft((current) => { + if (current.repoId !== repoId) { + pendingStoreEchoesRef.current = [] + return { repoId, text: storeValue } + } + if (storeValue === current.text) { + pendingStoreEchoesRef.current = [] + return current + } + const pendingEchoIndex = pendingStoreEchoesRef.current.indexOf(storeValue) + if (pendingEchoIndex !== -1) { + // Why: queued updateRepo calls can echo older input text after newer + // keystrokes; accepting that echo re-cancels active IME composition. + pendingStoreEchoesRef.current.splice(0, pendingEchoIndex + 1) + return current + } + pendingStoreEchoesRef.current = [] + return { repoId, text: storeValue } + }) + }, [repoId, storeValue]) + + const text = draft.repoId === repoId ? draft.text : storeValue + return ( + <Input + {...inputProps} + value={text} + onChange={(e) => { + const nextText = e.target.value + pendingStoreEchoesRef.current.push(nextText) + setDraft({ repoId, text: nextText }) + onTextChange(nextText) + }} + /> + ) +} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 3dccec319e6..6f5f771379c 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -34,6 +34,7 @@ import { completeRepoActionRecipe, readInheritedCommandTemplate } from './repository-source-control-ai-labels' +import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' export { @@ -89,6 +90,7 @@ export function RepositorySourceControlAiSection({ }: RepositorySourceControlAiSectionProps): React.JSX.Element { const mountedRef = useMountedRef() const settings = useAppStore((state) => state.settings) + const ownership = getSettingOwnershipSummary('repositorySourceControlAi') const source = normalizeSourceControlAiSettings( settings?.sourceControlAi, settings?.commitMessageAi @@ -302,12 +304,7 @@ export function RepositorySourceControlAiSection({ 'Source Control AI' )} </h3> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.RepositorySourceControlAiSection.8b8bc5913a', - 'Repository action recipes. Global settings are used until this repository customizes them.' - )} - </p> + <p className="text-xs text-muted-foreground">{ownership.description}</p> {saveError ? <p className="text-xs text-destructive">{saveError}</p> : null} </div> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts new file mode 100644 index 00000000000..ad4f4dca632 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { + evaluateHostDetails, + getActiveServerModeDescription, + getHostDetailsDescription, + getHostDetailsSummary, + getHostModelCapabilitySummary, + getRuntimeCapabilitiesSummary, + type RuntimeHostDetails +} from './RuntimeEnvironmentsPane' + +function details(overrides: Partial<RuntimeHostDetails>): RuntimeHostDetails { + return { + status: 'ready', + runtimeStatus: null, + compatibility: null, + error: null, + ...overrides + } +} + +describe('RuntimeEnvironmentsPane host details', () => { + it('summarizes loading, error, compatible, and blocked hosts', () => { + expect(getHostDetailsSummary(undefined)).toBe('Checking…') + expect(getHostDetailsSummary(details({ status: 'error', error: 'offline' }))).toBe( + 'Status unavailable' + ) + expect( + getHostDetailsSummary( + details({ + compatibility: { + kind: 'ok', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + } + }) + ) + ).toBe('Compatible') + expect( + getHostDetailsSummary( + details({ + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + requiredServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + } + }) + ) + ).toBe('Update server') + expect( + getHostDetailsSummary( + details({ + compatibility: { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION, + requiredClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + } + }) + ) + ).toBe('Update client') + }) + + it('evaluates runtime protocol compatibility from status aliases', () => { + expect( + evaluateHostDetails({ + runtimeId: 'runtime-old', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + minCompatibleMobileVersion: 0 + }) + ).toMatchObject({ kind: 'blocked', reason: 'server-too-old' }) + }) + + it('explains blocked runtime compatibility with required protocol versions', () => { + expect( + getHostDetailsDescription( + details({ + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + requiredServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + } + }) + ) + ).toContain('client requires server protocol') + }) + + it('summarizes runtime capabilities by name with overflow count', () => { + expect( + getRuntimeCapabilitiesSummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: ['runtime.environments.v1', 'terminal.multiplex.v1'] + }) + ).toBe('runtime.environments.v1, terminal.multiplex.v1') + + expect( + getRuntimeCapabilitiesSummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [ + 'runtime.environments.v1', + 'browser.screencast.v1', + 'terminal.multiplex.v1', + 'project-host-setup.v1' + ] + }) + ).toBe('runtime.environments.v1, browser.screencast.v1, terminal.multiplex.v1 +1') + }) + + it('summarizes Host model capability support for version-skewed servers', () => { + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0 + }) + ).toBe('Host model support: checking server capabilities') + + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + }) + ).toBe('Host model support: ready') + + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ).toBe('Host model support: update server for task source context, workspace run context') + }) + + it('explains that selecting a saved server is the explicit default Host mode', () => { + expect(getActiveServerModeDescription(true)).toContain('default Host') + expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') + expect(getActiveServerModeDescription(false)).toContain('default Host') + expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime') + }) +}) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 087f281345f..8e1a433de76 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -1,12 +1,35 @@ /* eslint-disable max-lines -- Why: the server settings pane keeps active server selection, saved server mutation, and confirmation dialogs together so the state transitions stay auditable. */ -import { Loader2, Plus, RefreshCw, Share2, Trash2 } from 'lucide-react' +import { + AlertTriangle, + ChevronDown, + Loader2, + Plus, + RefreshCw, + Server, + ServerOff, + Share2, + Trash2 +} from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import type { GlobalSettings } from '../../../../shared/types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { + describeRuntimeCompatBlock, + evaluateRuntimeCompat, + type RuntimeCompatVerdict +} from '../../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -25,7 +48,10 @@ import { getRuntimeEnvironmentsSearchEntry, getWebRuntimeEnvironmentsSearchEntry } from './runtime-environments-search' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' const LOCAL_RUNTIME_VALUE = '__local__' const NO_RUNTIME_VALUE = '__none__' @@ -37,6 +63,186 @@ type RuntimeEnvironmentsPaneProps = { allowLocalRuntime?: boolean } +export type RuntimeHostDetails = { + status: 'loading' | 'ready' | 'error' + runtimeStatus: RuntimeStatus | null + compatibility: RuntimeCompatVerdict | null + error: string | null +} + +export function evaluateHostDetails(status: RuntimeStatus): RuntimeCompatVerdict { + return evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) +} + +export function getHostDetailsSummary(details: RuntimeHostDetails | undefined): string { + if (!details || details.status === 'loading') { + return translate('auto.components.settings.RuntimeEnvironmentsPane.5120beaac6', 'Checking…') + } + if (details.status === 'error') { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.c8791efc45', + 'Status unavailable' + ) + } + if (details.compatibility?.kind === 'blocked') { + return details.compatibility.reason === 'client-too-old' + ? translate('auto.components.settings.RuntimeEnvironmentsPane.62ac182a27', 'Update client') + : translate('auto.components.settings.RuntimeEnvironmentsPane.86ed75bec8', 'Update server') + } + return translate('auto.components.settings.RuntimeEnvironmentsPane.9a91c4a0eb', 'Compatible') +} + +export function getHostDetailsDescription(details: RuntimeHostDetails | undefined): string | null { + if (!details || details.status === 'loading') { + return null + } + if (details.status === 'error') { + return details.error + } + if (details.compatibility?.kind === 'blocked') { + return describeRuntimeCompatBlock(details.compatibility) + } + return null +} + +export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | undefined): string { + const capabilities = status?.capabilities ?? [] + if (capabilities.length === 0) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.4b5c6d7e8f', + 'No capabilities reported' + ) + } + const visibleCapabilities = capabilities.slice(0, 3).join(', ') + const hiddenCount = capabilities.length - 3 + return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities +} + +export function getHostModelCapabilitySummary( + status: RuntimeStatus | null | undefined +): string | null { + if (!status) { + return null + } + const capabilities = status.capabilities + if (!capabilities) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityUnknown', + 'Host model support: checking server capabilities' + ) + } + const missing = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ].filter((capability) => !capabilities.includes(capability)) + if (missing.length === 0) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilitySupported', + 'Host model support: ready' + ) + } + const missingLabels = missing.map(getHostModelCapabilityLabel) + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityMissing', + 'Host model support: update server for {{value0}}', + { value0: missingLabels.join(', ') } + ) +} + +function getHostModelCapabilityLabel(capability: string): string { + switch (capability) { + case PROJECT_HOST_SETUP_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityProjectSetup', + 'project setup' + ) + case TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityTaskSourceContext', + 'task source context' + ) + case WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityWorkspaceRunContext', + 'workspace run context' + ) + default: + return capability + } +} + +export function getActiveServerModeDescription(allowLocalRuntime: boolean): string { + return allowLocalRuntime + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a', + "Local keeps today's desktop behavior. Selecting a saved server makes that server the default Host for server-routed projects, files, terminals, provider accounts, and browser/mobile handoff." + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8', + 'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.' + ) +} + +type RuntimeServerConnectionState = 'connected' | 'available' | 'checking' | 'disconnected' + +function getRuntimeServerConnectionState( + details: RuntimeHostDetails | undefined, + active: boolean +): RuntimeServerConnectionState { + if (!details || details.status === 'loading') { + return 'checking' + } + if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { + return 'disconnected' + } + return active ? 'connected' : 'available' +} + +function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', + 'Connected' + ) + case 'available': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverAvailable', + 'Available' + ) + case 'checking': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', + 'Checking…' + ) + case 'disconnected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', + 'Disconnected' + ) + } +} + +function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return 'bg-emerald-500' + case 'available': + return 'bg-muted-foreground/50' + case 'checking': + return 'bg-yellow-500' + case 'disconnected': + return 'bg-muted-foreground/40' + } +} + export function RuntimeEnvironmentsPane({ settings, switchRuntimeEnvironment, @@ -46,12 +252,17 @@ export function RuntimeEnvironmentsPane({ const [environments, setEnvironments] = useState<PublicKnownRuntimeEnvironment[]>([]) const [isLoading, setIsLoading] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState< + Record<string, RuntimeHostDetails> + >({}) const [switchingValue, setSwitchingValue] = useState<string | null>(null) const [removingId, setRemovingId] = useState<string | null>(null) + const [disconnectingId, setDisconnectingId] = useState<string | null>(null) const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null) const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) const [shareServerFormOpen, setShareServerFormOpen] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(false) const [switchError, setSwitchError] = useState<string | null>(null) const [removeError, setRemoveError] = useState<string | null>(null) const [name, setName] = useState('') @@ -60,7 +271,8 @@ export function RuntimeEnvironmentsPane({ const activeValue = settings.activeRuntimeEnvironmentId ?? (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE) - const isBusy = isSaving || switchingValue !== null || removingId !== null + const isBusy = + isSaving || switchingValue !== null || removingId !== null || disconnectingId !== null const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId const searchEntry = canGeneratePairingUrl ? getRuntimeEnvironmentsSearchEntry() @@ -72,9 +284,72 @@ export function RuntimeEnvironmentsPane({ } try { const nextEnvironments = await window.api.runtimeEnvironments.list() + // Why: drop store status for servers no longer saved so stale hosts don't + // linger in the sidebar registry. + useAppStore.getState().setRuntimeEnvironments(nextEnvironments) if (mountedRef.current) { setEnvironments(nextEnvironments) + setDetailsByEnvironmentId((current) => { + const next: Record<string, RuntimeHostDetails> = {} + for (const environment of nextEnvironments) { + next[environment.id] = current[environment.id] ?? { + status: 'loading', + runtimeStatus: null, + compatibility: null, + error: null + } + } + return next + }) } + await Promise.allSettled( + nextEnvironments.map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response) + // Why: feed the live status into the store so sidebar host pickers + // reflect manual refreshes, not just the settings pane. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility: evaluateHostDetails(runtimeStatus), + error: null + } + })) + } catch (error) { + // Why: record the failed probe (null status) so the sidebar can + // distinguish unreachable from never-checked. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) + } + }) + ) } catch (error) { if (mountedRef.current) { toast.error( @@ -169,7 +444,7 @@ export function RuntimeEnvironmentsPane({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Saved {{value0}}. Use Active Server to switch when ready.', + 'Saved {{value0}}. Use Advanced > Default runtime to make it the default.', { value0: result.environment.name } ) ) @@ -255,6 +530,65 @@ export function RuntimeEnvironmentsPane({ } } + const disconnectEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise<boolean> => { + setDisconnectingId(environment.id) + setSwitchError(null) + try { + if (settings.activeRuntimeEnvironmentId === environment.id) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + if (mountedRef.current) { + setSwitchError( + allowLocalRuntime + ? 'Could not switch to Local desktop. Fix the issue and try again.' + : 'Could not disconnect from this server. Fix the issue and try again.' + ) + } + return false + } + } + await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) + // Why: disconnect is non-destructive; keep the saved server but show the + // user that this live client is no longer attached to it. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (mountedRef.current) { + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: null + } + })) + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.disconnectedServer', + 'Disconnected from {{value0}}.', + { value0: environment.name } + ) + ) + } + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to disconnect server.' + if (mountedRef.current) { + setSwitchError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setDisconnectingId(null) + } + } + } + const switchToValue = async (value: string): Promise<boolean> => { if (value === NO_RUNTIME_VALUE) { return false @@ -312,94 +646,21 @@ export function RuntimeEnvironmentsPane({ keywords={searchEntry.keywords} className="space-y-4 py-2" > - <div className="space-y-2"> - <div className="space-y-1"> - <Label id="runtime-active-server-label"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541', - 'Active Server' - )} - </Label> - <p className="text-xs text-muted-foreground"> - {allowLocalRuntime - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.f75ce1c7a5', - "Local keeps today's desktop behavior. Saved servers route supported client calls through the remote runtime." - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.8cf8790697', - 'Saved servers route this browser through a paired Orca runtime.' - )} - </p> - </div> - <div className="flex flex-wrap items-center gap-2"> - <Select - value={activeValue} - onValueChange={(value) => { - if (value !== activeValue) { - setSwitchError(null) - setPendingSwitchValue(value) - } - }} - disabled={isBusy} - > - <SelectTrigger - size="sm" - className="min-w-[260px]" - aria-labelledby="runtime-active-server-label" - > - <SelectValue /> - </SelectTrigger> - <SelectContent> - {allowLocalRuntime ? ( - <SelectItem value={LOCAL_RUNTIME_VALUE}> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.78692becbd', - 'Local desktop' - )} - </SelectItem> - ) : environments.length === 0 ? ( - <SelectItem value={NO_RUNTIME_VALUE} disabled> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c', - 'No server connected' - )} - </SelectItem> - ) : null} - {environments.map((environment) => ( - <SelectItem key={environment.id} value={environment.id}> - {environment.name} - </SelectItem> - ))} - </SelectContent> - </Select> - <Button - type="button" - variant="outline" - size="icon-sm" - aria-label={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', - 'Refresh servers' - )} - title={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', - 'Refresh servers' - )} - onClick={() => void loadEnvironments()} - disabled={isLoading || isBusy} - > - {isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />} - </Button> - </div> - </div> - <div className="space-y-3"> <div className="flex items-center justify-between gap-3"> - <div className="text-sm font-medium"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.1826bd0608', - 'Saved Servers' - )} + <div className="min-w-0 space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers', + 'Connect to remote servers' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp', + 'Pair another Orca runtime, then connect when you want this client to use it as the default host.' + )} + </p> </div> {addServerFormOpen ? null : ( <Button @@ -505,7 +766,7 @@ export function RuntimeEnvironmentsPane({ </form> ) : null} - <div className="rounded-lg border border-border/50"> + <div className="rounded-lg border border-border/50 bg-card/30"> {environments.length === 0 ? ( <div className="px-3 py-4 text-sm text-muted-foreground"> {translate( @@ -518,35 +779,126 @@ export function RuntimeEnvironmentsPane({ {environments.map((environment) => ( <div key={environment.id} - className="flex items-center justify-between gap-3 px-3 py-2" + data-settings-section={environment.id} + className="flex items-center gap-3 px-4 py-3" > - <div className="min-w-0"> - <div className="truncate text-sm font-medium">{environment.name}</div> - <div className="truncate font-mono text-xs text-muted-foreground"> - {environment.endpoints[0]?.endpoint ?? - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', - 'No endpoint' - )} - </div> - </div> - <Button - type="button" - variant="ghost" - size="icon-sm" - onClick={() => { - setRemoveError(null) - setPendingRemove(environment) - }} - disabled={isBusy} - aria-label={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2', - 'Remove {{value0}}', - { value0: environment.name } - )} - > - <Trash2 /> - </Button> + {(() => { + const details = detailsByEnvironmentId[environment.id] + const detailsDescription = getHostDetailsDescription(details) + const isActive = settings.activeRuntimeEnvironmentId === environment.id + const connectionState = getRuntimeServerConnectionState(details, isActive) + const actionBusy = + switchingValue === environment.id || + disconnectingId === environment.id || + removingId === environment.id + return ( + <> + <Server className="size-4 shrink-0 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <div className="truncate text-sm font-medium">{environment.name}</div> + <span + className={cn( + 'size-2 shrink-0 rounded-full', + getRuntimeServerDotClass(connectionState) + )} + /> + <span className="text-[11px] text-muted-foreground"> + {getRuntimeServerConnectionLabel(connectionState)} + </span> + {details?.compatibility?.kind === 'blocked' ? ( + <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> + ) : details?.status === 'loading' ? ( + <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" /> + ) : null} + </div> + <p className="truncate text-xs text-muted-foreground"> + {isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Default host for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)} + </p> + {detailsDescription ? ( + <p + className={cn( + 'mt-0.5 truncate text-xs', + details?.compatibility?.kind === 'blocked' + ? 'text-destructive' + : 'text-muted-foreground' + )} + > + {detailsDescription} + </p> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-1"> + {isActive ? ( + <Button + type="button" + variant="ghost" + size="xs" + className="gap-1.5" + onClick={() => void disconnectEnvironment(environment)} + disabled={actionBusy} + > + {disconnectingId === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <ServerOff className="size-3" /> + )} + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.disconnect', + 'Disconnect' + )} + </Button> + ) : ( + <Button + type="button" + variant="ghost" + size="xs" + className="gap-1.5" + onClick={() => void switchToValue(environment.id)} + disabled={actionBusy || connectionState === 'checking'} + > + {switchingValue === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Server className="size-3" /> + )} + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connect', + 'Connect' + )} + </Button> + )} + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => { + setRemoveError(null) + setPendingRemove(environment) + }} + className="size-7 text-muted-foreground hover:text-red-400" + disabled={isBusy} + aria-label={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2', + 'Remove {{value0}}', + { value0: environment.name } + )} + > + {removingId === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Trash2 className="size-3" /> + )} + </Button> + </div> + </> + ) + })()} </div> ))} </div> @@ -554,48 +906,227 @@ export function RuntimeEnvironmentsPane({ </div> </div> - {canGeneratePairingUrl ? ( - <div className="overflow-hidden rounded-lg border border-border/50"> - <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5"> - <div className="min-w-0 space-y-0.5"> - <div className="text-sm font-medium"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', - 'Share this Orca server' - )} - </div> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', - 'Create a revocable access grant so a browser or another Orca client can connect.' - )} - </p> - </div> - <Button - type="button" - variant="outline" - size="sm" - className="gap-1.5" - onClick={() => setShareServerFormOpen((open) => !open)} + <div data-settings-section="default-runtime"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => setAdvancedOpen((current) => !current)} + className="-ml-2 text-xs" + > + {translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')} + <ChevronDown + className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} + /> + </Button> + + <div + className={cn( + 'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out', + advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' + )} + aria-hidden={!advancedOpen} + > + <div className="min-h-0"> + <div + className={cn( + 'space-y-2 px-1 pt-1 pb-1 transition-[opacity,transform] duration-150 ease-out', + advancedOpen + ? 'translate-y-0 opacity-100 delay-200' + : '-translate-y-1 opacity-0 delay-0' + )} > - <Share2 /> - {shareServerFormOpen - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c', - 'Hide Form' - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948', - 'New Link' + <div className="space-y-1"> + <Label id="runtime-active-server-label"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541', + 'Default runtime' )} - </Button> + </Label> + <p className="text-xs text-muted-foreground"> + {getActiveServerModeDescription(allowLocalRuntime)} + </p> + </div> + <div className="flex flex-wrap items-center gap-2"> + <Select + value={activeValue} + onValueChange={(value) => { + if (value !== activeValue) { + setSwitchError(null) + setPendingSwitchValue(value) + } + }} + disabled={isBusy} + > + <SelectTrigger + size="sm" + className="min-w-[260px]" + aria-labelledby="runtime-active-server-label" + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + {allowLocalRuntime ? ( + <SelectItem value={LOCAL_RUNTIME_VALUE}> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.78692becbd', + 'Local desktop' + )} + </SelectItem> + ) : environments.length === 0 ? ( + <SelectItem value={NO_RUNTIME_VALUE} disabled> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c', + 'No server connected' + )} + </SelectItem> + ) : null} + {environments.map((environment) => ( + <SelectItem key={environment.id} value={environment.id}> + {environment.name} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + type="button" + variant="outline" + size="icon-sm" + aria-label={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', + 'Refresh servers' + )} + title={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', + 'Refresh servers' + )} + onClick={() => void loadEnvironments()} + disabled={isLoading || isBusy} + > + {isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />} + </Button> + </div> + {environments.length > 0 ? ( + <div className="space-y-2 pt-2"> + <div className="text-xs font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDetails', + 'Server details' + )} + </div> + <div className="space-y-1 rounded-lg border border-border/50 bg-card/30 p-2"> + {environments.map((environment) => { + const details = detailsByEnvironmentId[environment.id] + return ( + <div + key={environment.id} + className="grid gap-1 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)]" + > + <div className="truncate font-medium text-foreground"> + {environment.name} + </div> + <div className="min-w-0 space-y-0.5"> + <div className="truncate font-mono"> + {environment.endpoints[0]?.endpoint ?? + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', + 'No endpoint' + )} + </div> + {details?.runtimeStatus ? ( + <div className="truncate"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a', + 'Protocol {{value0}}', + { + value0: + details.runtimeStatus?.runtimeProtocolVersion ?? + details.runtimeStatus?.protocolVersion ?? + 0 + } + )} + {details.runtimeStatus.hostPlatform + ? ` · ${details.runtimeStatus.hostPlatform}` + : ''} + {' · '} + {getRuntimeCapabilitiesSummary(details.runtimeStatus)} + </div> + ) : null} + {getHostModelCapabilitySummary(details?.runtimeStatus) ? ( + <div className="truncate"> + {getHostModelCapabilitySummary(details?.runtimeStatus)} + </div> + ) : null} + </div> + </div> + ) + })} + </div> + </div> + ) : null} + </div> </div> - <div className="border-t border-border/40 px-3 py-3"> - <RuntimePairingUrlGenerator - framed={false} - showHeader={false} - showGeneratorForm={shareServerFormOpen} - /> + </div> + </div> + + {canGeneratePairingUrl ? ( + <div className="space-y-3 pt-2"> + <div className="space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp', + 'Advertise this app as a server' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp', + 'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.' + )} + </p> + </div> + <div className="overflow-hidden rounded-lg border border-border/50 bg-card/30"> + <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5"> + <div className="min-w-0 space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', + 'Share this Orca server' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', + 'Create a revocable access grant so a browser or another Orca client can connect.' + )} + </p> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="gap-1.5" + onClick={() => setShareServerFormOpen((open) => !open)} + > + <Share2 /> + {shareServerFormOpen + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c', + 'Hide Form' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948', + 'New Link' + )} + </Button> + </div> + <div className="border-t border-border/40 px-3 py-3"> + <RuntimePairingUrlGenerator + framed={false} + showHeader={false} + showGeneratorForm={shareServerFormOpen} + /> + </div> </div> </div> ) : null} @@ -620,7 +1151,7 @@ export function RuntimeEnvironmentsPane({ <DialogDescription> {translate( 'auto.components.settings.RuntimeEnvironmentsPane.b2290ed203', - 'Orca will close remote terminals and browser tabs from the current server before loading projects from the next server.' + 'Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.' )} </DialogDescription> </DialogHeader> @@ -692,11 +1223,11 @@ export function RuntimeEnvironmentsPane({ ? allowLocalRuntime ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.9f7665a01b', - 'Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.' + 'Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.b2fda48c39', - 'Removing the active server disconnects this browser and closes remote terminals and browser tabs for that server.' + 'Removing the active server disconnects this browser from that host. Existing host sessions are left alone.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d', diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index f42c502df56..b765583c0a3 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' import { toast } from 'sonner' import type { GlobalSettings, OrcaHooks } from '../../../../shared/types' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' import type { SpeechModelState } from '../../../../shared/speech-types' import type { SourceControlAiSettings, @@ -17,7 +16,7 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' -import { GeneralPane, getDesktopPlatformFromUserAgent } from './GeneralPane' +import { GeneralPane } from './GeneralPane' import { BrowserPane } from './BrowserPane' import { AppearancePane } from './AppearancePane' import { InputPane } from './InputPane' @@ -87,60 +86,48 @@ import { getRuntimeTargetIdentity } from './settings-load-performance' import { translate } from '@/i18n/i18n' -import { - getSelectedAgentRuntime, - getSkillDiscoveryTargetForRuntime, - type LocalAgentRuntime -} from './CliSkillRuntimeSetup' const SETTINGS_NAV_GROUPS = [ { id: 'capabilities', - get title() { - return translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities') - } - }, - { - id: 'setup', - get title() { - return translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up') - } + titleKey: 'auto.components.settings.Settings.23c6874fdf', + titleDefault: 'AI Capabilities' }, + { id: 'setup', titleKey: 'auto.components.settings.Settings.9abb9be3bc', titleDefault: 'Set Up' }, { id: 'workflows', - get title() { - return translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows') - } + titleKey: 'auto.components.settings.Settings.e1578cd4bc', + titleDefault: 'Workflows' }, { id: 'interface', - get title() { - return translate('auto.components.settings.Settings.8bd117d669', 'Interface') - } + titleKey: 'auto.components.settings.Settings.8bd117d669', + titleDefault: 'Interface' }, { id: 'remote', - get title() { - return translate('auto.components.settings.Settings.23931df7e8', 'Remote Access') - } + titleKey: 'auto.components.settings.Settings.23931df7e8', + titleDefault: 'Remote Hosts' + }, + { + id: 'mobile', + titleKey: 'auto.components.settings.Settings.mobile_group', + titleDefault: 'Mobile' }, { id: 'security', - get title() { - return translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security') - } + titleKey: 'auto.components.settings.Settings.084d8fac5b', + titleDefault: 'Privacy & Security' }, { id: 'advanced', - get title() { - return translate('auto.components.settings.Settings.1c87f8d024', 'Advanced') - } + titleKey: 'auto.components.settings.Settings.1c87f8d024', + titleDefault: 'Advanced' }, { id: 'experimental', - get title() { - return translate('auto.components.settings.Settings.8b017f2506', 'Experimental') - } + titleKey: 'auto.components.settings.Settings.8b017f2506', + titleDefault: 'Experimental' } ] as const @@ -168,19 +155,6 @@ function getSkillNavInstallStatus(skill: { return skill.installed ? 'installed' : 'install' } -function getSettingsAgentSkillRuntime(args: { - settings: GlobalSettings | null - isWindows: boolean -}): LocalAgentRuntime { - if (!args.settings) { - return { - runtime: 'host', - label: translate('auto.components.settings.Settings.thisDevice', 'This device') - } - } - return getSelectedAgentRuntime(args.settings, args.isWindows, args.isWindows, false) -} - function hasReadyVoiceModel( settings: GlobalSettings, modelStates: readonly SpeechModelState[] @@ -276,21 +250,10 @@ function Settings(): React.JSX.Element { const isMac = isMacUserAgent() const isWebClient = isWebClientLocation() const showDesktopOnlySettings = !isWebClient - const currentPlatform = getDesktopPlatformFromUserAgent(navigator.userAgent) - const agentSkillRuntime = useMemo( - () => getSettingsAgentSkillRuntime({ settings, isWindows }), - [settings, isWindows] - ) - const agentSkillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>( - () => getSkillDiscoveryTargetForRuntime(agentSkillRuntime), - [agentSkillRuntime] - ) const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { - discoveryTarget: agentSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const computerUseSkill = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { - discoveryTarget: agentSkillDiscoveryTarget, enabled: showDesktopOnlySettings, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -968,7 +931,8 @@ function Settings(): React.JSX.Element { const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-')) const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({ - ...group, + id: group.id, + title: translate(group.titleKey, group.titleDefault), sections: generalNavSections.filter((section) => section.group === group.id) })).filter((group) => group.sections.length > 0 || group.id === 'setup') const repoNavSections = visibleNavSections @@ -995,8 +959,8 @@ function Settings(): React.JSX.Element { className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background" > <SettingsSidebar - activeSectionId={activeSectionId} settings={settings} + activeSectionId={activeSectionId} generalGroups={generalNavGroups} repoSections={repoNavSections} hasRepos={repos.length > 0} @@ -1091,15 +1055,7 @@ function Settings(): React.JSX.Element { )} searchEntries={getSectionSearchEntries('orchestration')} > - {isSectionMounted('orchestration') ? ( - <OrchestrationPane - currentPlatform={currentPlatform} - settings={settings} - wslSupportedPlatform={wslSupportedPlatform} - wslAvailable={windowsTerminalCapabilities.wslAvailable} - wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading} - /> - ) : null} + {isSectionMounted('orchestration') ? <OrchestrationPane /> : null} </SettingsSection> {showDesktopOnlySettings ? ( @@ -1116,15 +1072,7 @@ function Settings(): React.JSX.Element { )} searchEntries={getSectionSearchEntries('computer-use')} > - {isSectionMounted('computer-use') ? ( - <ComputerUsePane - currentPlatform={currentPlatform} - settings={settings} - wslSupportedPlatform={wslSupportedPlatform} - wslAvailable={windowsTerminalCapabilities.wslAvailable} - wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading} - /> - ) : null} + {isSectionMounted('computer-use') ? <ComputerUsePane /> : null} </SettingsSection> <SettingsSection @@ -1453,7 +1401,7 @@ function Settings(): React.JSX.Element { ) : translate( 'auto.components.settings.Settings.b5ee17826b', - 'Switch between local desktop mode and paired remote Orca runtimes.' + 'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.' ) } searchEntries={getSectionSearchEntries('servers')} @@ -1475,7 +1423,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.9b02492d1f', 'SSH Hosts')} description={translate( 'auto.components.settings.Settings.c2ee313198', - 'Remote SSH hosts for files, terminals, and git.' + 'Use existing machines over SSH for files, terminals, Git, and workspaces.' )} searchEntries={getSectionSearchEntries('ssh')} > diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 05fcb6d06b5..345612ca849 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -346,12 +346,12 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { <div className="flex items-center justify-between gap-3"> <div className="space-y-0.5"> <p className="text-sm font-medium"> - {translate('auto.components.settings.SshPane.94c5284560', 'Targets')} + {translate('auto.components.settings.SshPane.94c5284560', 'SSH hosts')} </p> <p className="text-xs text-muted-foreground"> {translate( 'auto.components.settings.SshPane.a7d28dff81', - 'Add a remote host to connect to it in Orca.' + 'Add an existing machine over SSH so projects and workspaces can run there.' )} </p> </div> diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx new file mode 100644 index 00000000000..62c365c82eb --- /dev/null +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react' +import { FolderOpen, RotateCcw } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import { + getEffectiveHostSetting, + getHostSettingOverride, + setHostSettingOverride, + clearHostSettingOverride +} from '../../../../shared/host-setting-overrides' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { + buildHostScopeChoices, + CLIENT_DEFAULT_SCOPE, + isHostScope, + type HostSettingScope +} from './host-scoped-setting-scope' +import { translate } from '@/i18n/i18n' + +type WorkspaceDirectorySettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial<GlobalSettings>) => void +} + +export function WorkspaceDirectorySetting({ + settings, + updateSettings +}: WorkspaceDirectorySettingProps): React.JSX.Element { + const { hostOptions } = useSidebarHostScopeOptions() + const [scope, setScope] = useState<HostSettingScope>(CLIENT_DEFAULT_SCOPE) + + const clientDefaultLabel = translate( + 'auto.components.settings.WorkspaceDirectorySetting.1a2b3c4d5e', + 'Client default' + ) + const choices = buildHostScopeChoices(hostOptions, clientDefaultLabel) + // Why: if the selected host disappears (removed/disconnected), fall back to the + // client default so the control never edits a stale host. + const activeScope = choices.some((c) => c.scope === scope) ? scope : CLIENT_DEFAULT_SCOPE + const editingHost = isHostScope(activeScope) + + const hostOverride = editingHost + ? getHostSettingOverride(settings, activeScope, 'defaultWorktreeLocation') + : undefined + const hasOverride = editingHost && hostOverride !== undefined + + // For a host scope, show its override or — as a hint — the inherited client + // default. For the client default scope, edit `workspaceDir` directly. + const value = editingHost + ? getEffectiveHostSetting( + settings, + activeScope, + 'defaultWorktreeLocation', + settings.workspaceDir + ) + : settings.workspaceDir + + const writeValue = (next: string): void => { + if (!editingHost) { + updateSettings({ workspaceDir: next }) + return + } + updateSettings({ + hostSettingOverrides: setHostSettingOverride( + settings, + activeScope, + 'defaultWorktreeLocation', + next + ) + }) + } + + const resetOverride = (): void => { + if (!editingHost) { + return + } + updateSettings({ + hostSettingOverrides: clearHostSettingOverride( + settings, + activeScope, + 'defaultWorktreeLocation' + ) + }) + } + + const handleBrowse = async (): Promise<void> => { + const path = await window.api.repos.pickFolder() + if (path) { + writeValue(path) + } + } + + // Why: only show the scope picker when at least one non-local host exists, + // matching the multi-host gating used elsewhere in the sidebar. + const showScopePicker = hostOptions.some((host) => host.id !== LOCAL_EXECUTION_HOST_ID) + + return ( + <SearchableSetting + title={translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc', + 'Workspace Directory' + )} + description={translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f', + 'Root directory where workspace folders are created.' + )} + keywords={['workspace', 'folder', 'path', 'worktree', 'host', 'override']} + className="space-y-2" + > + <div className="flex items-center justify-between gap-2"> + <Label> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc', + 'Workspace Directory' + )} + </Label> + {showScopePicker && ( + <div className="flex items-center gap-1.5"> + <span className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.WorkspaceDirectorySetting.2b3c4d5e6f', + 'Apply to' + )} + </span> + <Select + value={activeScope} + onValueChange={(next) => setScope(next as HostSettingScope)} + > + <SelectTrigger size="sm" className="h-7 w-44 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {choices.map((choice) => ( + <SelectItem key={choice.scope} value={choice.scope} className="text-xs"> + {choice.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + </div> + <div className="flex gap-2"> + <Input + value={value} + onChange={(e) => writeValue(e.target.value)} + className="flex-1 text-xs" + /> + <Button + variant="outline" + size="sm" + onClick={() => void handleBrowse()} + className="shrink-0 gap-1.5" + > + <FolderOpen className="size-3.5" /> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.5567191a6e', + 'Browse' + )} + </Button> + </div> + {editingHost && ( + <div className="flex items-center justify-between gap-2"> + <p className="text-xs text-muted-foreground"> + {hasOverride + ? translate( + 'auto.components.settings.WorkspaceDirectorySetting.3c4d5e6f7a', + 'Overrides client default' + ) + : translate( + 'auto.components.settings.WorkspaceDirectorySetting.4d5e6f7a8b', + 'Inherits the client default' + )} + </p> + {hasOverride && ( + <Button + type="button" + variant="ghost" + size="sm" + className="h-7 gap-1.5 text-xs" + onClick={resetOverride} + > + <RotateCcw className="size-3.5" /> + {translate('auto.components.settings.WorkspaceDirectorySetting.5e6f7a8b9c', 'Reset')} + </Button> + )} + </div> + )} + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f', + 'Root directory where workspace folders are created.' + )} + </p> + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 088e015d385..c97f198da24 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -163,10 +163,10 @@ export const getStatusBarToggles = createLocalizedCatalog( }, { id: 'ssh', - title: translate('auto.components.settings.appearance.search.57fb424c56', 'SSH Status'), + title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), description: translate( 'auto.components.settings.appearance.search.f17d66d0d2', - 'Show the active SSH connection status in the status bar.' + 'Show remote host connection status in the status bar.' ), keywords: [ ...translateSearchKeyword( @@ -186,7 +186,7 @@ export const getStatusBarToggles = createLocalizedCatalog( ], toggleDescription: translate( 'settings.appearance.statusBar.sshToggleDescription', - 'Show the active SSH connection. Only visible once an SSH target is configured.' + 'Show configured SSH and remote Orca hosts when any are available.' ) }, { diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx new file mode 100644 index 00000000000..520e526235c --- /dev/null +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + GitHubIntegrationCard, + GitLabIntegrationCard +} from './cli-source-control-integration-cards' + +type StoreState = { + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { current: null as StoreState | null }, + preflight: { + statuses: { + ghStatus: 'connected', + glabStatus: 'connected', + bitbucketStatus: 'not-configured', + azureDevOpsStatus: 'not-configured', + giteaStatus: 'not-configured', + bitbucketAccount: null, + azureDevOpsAccount: null, + giteaAccount: null + }, + unavailable: false, + refresh: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => { + if (!mocks.store.current) { + throw new Error('Store state was not installed') + } + return selector(mocks.store.current) + } +})) + +vi.mock('./source-control-preflight-card-status', () => ({ + usePreflightCardStatuses: () => mocks.preflight +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(card) + }) + return container +} + +describe('CLI source-control integration card account scope', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.store.current = null + mocks.preflight.statuses.ghStatus = 'connected' + mocks.preflight.statuses.glabStatus = 'connected' + mocks.preflight.unavailable = false + mocks.preflight.refresh.mockClear() + }) + + it('shows local-client ownership for connected GitHub CLI credentials', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage, + openSettingsTarget + } + + const rendered = await renderCard(<GitHubIntegrationCard />) + + expect(rendered.textContent).toContain('GitHub') + expect(rendered.textContent).toContain('Connected') + expect(rendered.textContent).toContain('Account scope: Local Mac') + expect(rendered.textContent).toContain( + 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + ) + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' + }) + }) + + it('shows remote-server ownership for GitLab CLI credential checks', async () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + mocks.preflight.statuses.glabStatus = 'not-authenticated' + + const rendered = await renderCard(<GitLabIntegrationCard />) + + expect(rendered.textContent).toContain('GitLab') + expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') + expect(rendered.textContent).toContain( + 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + ) + expect(rendered.textContent).toContain('glab auth login') + }) +}) diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx index 33332c451f8..0007e594d02 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx @@ -1,9 +1,35 @@ import { ExternalLink, Github, Gitlab, Terminal } from 'lucide-react' import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { usePreflightCardStatuses } from './source-control-preflight-card-status' import { translate } from '@/i18n/i18n' +function ProviderAccountScopeDetails({ + children +}: { + children?: React.ReactNode +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const accountScope = getProviderAccountScope(settings) + + return ( + <IntegrationCardDetails> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.cli.source.control.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={accountScope} + className="text-xs" + /> + {children} + </IntegrationCardDetails> + ) +} + export function GitHubIntegrationCard(): React.JSX.Element { const { statuses, unavailable, refresh } = usePreflightCardStatuses('gh') const status = unavailable ? 'unavailable' : statuses.ghStatus @@ -43,9 +69,9 @@ export function GitHubIntegrationCard(): React.JSX.Element { : 'Not authenticated' } > - {status !== 'checking' && !connected ? ( - <IntegrationCardDetails> - {status === 'unavailable' ? ( + <ProviderAccountScopeDetails> + {status !== 'checking' && !connected ? ( + status === 'unavailable' ? ( <> <p className="text-xs text-muted-foreground"> {translate( @@ -125,9 +151,9 @@ export function GitHubIntegrationCard(): React.JSX.Element { </Button> </div> </> - )} - </IntegrationCardDetails> - ) : null} + ) + ) : null} + </ProviderAccountScopeDetails> </IntegrationCardShell> ) } @@ -171,9 +197,9 @@ export function GitLabIntegrationCard(): React.JSX.Element { : 'Not authenticated' } > - {status !== 'checking' && !connected ? ( - <IntegrationCardDetails> - {status === 'unavailable' ? ( + <ProviderAccountScopeDetails> + {status !== 'checking' && !connected ? ( + status === 'unavailable' ? ( <> <p className="text-xs text-muted-foreground"> {translate( @@ -257,9 +283,9 @@ export function GitLabIntegrationCard(): React.JSX.Element { </Button> </div> </> - )} - </IntegrationCardDetails> - ) : null} + ) + ) : null} + </ProviderAccountScopeDetails> </IntegrationCardShell> ) } diff --git a/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts b/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts new file mode 100644 index 00000000000..a37f62978bc --- /dev/null +++ b/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + buildHostScopeChoices, + CLIENT_DEFAULT_SCOPE, + isHostScope +} from './host-scoped-setting-scope' +import type { SidebarHostOption } from '../sidebar/sidebar-host-options' + +function host(id: SidebarHostOption['id'], label: string): SidebarHostOption { + const kind = id === 'local' ? 'local' : id.startsWith('runtime:') ? 'runtime' : 'ssh' + return { + id, + label, + detail: '', + kind, + health: 'available', + presence: kind === 'local' ? 'local' : 'configured' + } +} + +describe('buildHostScopeChoices', () => { + it('lists the client default first, then non-local hosts', () => { + const choices = buildHostScopeChoices( + [host('local', 'Local Mac'), host('ssh:box', 'Box'), host('runtime:env', 'Server')], + 'Client default' + ) + expect(choices).toEqual([ + { scope: CLIENT_DEFAULT_SCOPE, label: 'Client default' }, + { scope: 'ssh:box', label: 'Box' }, + { scope: 'runtime:env', label: 'Server' } + ]) + }) + + it('excludes the local host', () => { + const choices = buildHostScopeChoices([host('local', 'Local Mac')], 'Client default') + expect(choices).toEqual([{ scope: CLIENT_DEFAULT_SCOPE, label: 'Client default' }]) + }) +}) + +describe('isHostScope', () => { + it('is false for the client default sentinel', () => { + expect(isHostScope(CLIENT_DEFAULT_SCOPE)).toBe(false) + }) + + it('is true for a real host id', () => { + expect(isHostScope('ssh:box')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/host-scoped-setting-scope.ts b/src/renderer/src/components/settings/host-scoped-setting-scope.ts new file mode 100644 index 00000000000..7eb33087c5e --- /dev/null +++ b/src/renderer/src/components/settings/host-scoped-setting-scope.ts @@ -0,0 +1,33 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { SidebarHostOption } from '../sidebar/sidebar-host-options' + +/** Sentinel scope for "edit the shared client default" rather than a host override. */ +export const CLIENT_DEFAULT_SCOPE = 'client-default' + +export type HostSettingScope = typeof CLIENT_DEFAULT_SCOPE | ExecutionHostId + +export type HostScopeChoice = { + scope: HostSettingScope + label: string +} + +/** Builds the "Apply to:" choices: the client default first, then every known + * non-local host. Local is excluded because its override and the client + * default address the same machine. */ +export function buildHostScopeChoices( + hosts: readonly SidebarHostOption[], + clientDefaultLabel: string +): HostScopeChoice[] { + const choices: HostScopeChoice[] = [{ scope: CLIENT_DEFAULT_SCOPE, label: clientDefaultLabel }] + for (const host of hosts) { + if (host.id !== LOCAL_EXECUTION_HOST_ID) { + choices.push({ scope: host.id, label: host.label }) + } + } + return choices +} + +/** A scope is host-specific when it targets a real host rather than the shared default. */ +export function isHostScope(scope: HostSettingScope): scope is ExecutionHostId { + return scope !== CLIENT_DEFAULT_SCOPE +} diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx new file mode 100644 index 00000000000..50b3990748c --- /dev/null +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { JiraIntegrationCard } from './jira-integration-card' + +type StoreState = { + jiraStatus: { + connected: boolean + sites?: { id: string; displayName: string; siteUrl: string; email?: string }[] + } + jiraStatusChecked: boolean + jiraStatusContextKey: string | null + checkJiraConnection: () => Promise<void> + disconnectJira: (siteId?: string) => Promise<void> + testJiraConnection: (siteId: string) => Promise<{ ok: boolean; error?: string }> + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { current: null as StoreState | null } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => { + if (!mocks.store.current) { + throw new Error('Store state was not installed') + } + return selector(mocks.store.current) + } +})) + +vi.mock('@/components/jira-connect-dialog', () => ({ + JiraConnectDialog: ({ onConnected }: { onConnected?: () => void }) => ( + <button type="button" data-testid="simulate-jira-connected" onClick={onConnected}> + Simulate Jira connected + </button> + ) +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function installStore(settings: StoreState['settings']): StoreState { + const state: StoreState = { + jiraStatus: { + connected: true, + sites: [ + { + id: 'site-1', + displayName: 'Acme Jira', + siteUrl: 'https://acme.atlassian.net', + email: 'jira@example.test' + } + ] + }, + jiraStatusChecked: true, + jiraStatusContextKey: getProviderRuntimeContextKey(settings), + checkJiraConnection: vi.fn(async () => {}), + disconnectJira: vi.fn(async () => {}), + testJiraConnection: vi.fn(async () => ({ ok: true })), + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + mocks.store.current = state + return state +} + +async function renderCard(): Promise<HTMLDivElement> { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(<JiraIntegrationCard />) + }) + return container +} + +describe('JiraIntegrationCard account scope', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.store.current = null + }) + + it('shows remote-server account ownership and opens Hosts settings', async () => { + const state = installStore({ activeRuntimeEnvironmentId: 'runtime-1' }) + + const rendered = await renderCard() + + expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') + expect(rendered.textContent).toContain('Acme Jira') + expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test') + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' + }) + }) +}) diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index bd27b1cc8ed..32b1a472467 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -10,6 +10,8 @@ import { } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -33,6 +35,7 @@ export function JiraIntegrationCard(): React.JSX.Element { const connected = contextMatches && jiraStatus.connected const sites = jiraStatus.sites ?? [] const siteCount = sites.length || (connected ? 1 : 0) + const accountScope = getProviderAccountScope(settings) const credentialCopy = hasRemoteProviderRuntime(settings) ? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' : 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.' @@ -106,6 +109,14 @@ export function JiraIntegrationCard(): React.JSX.Element { ) : null } > + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.task.tracker.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={accountScope} + className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs" + /> {connected && sites.length > 0 ? ( <div className="mt-3 space-y-2"> {sites.map((site) => { diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts new file mode 100644 index 00000000000..3af51857d05 --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { getProviderAccountScope, getProviderRateLimitScope } from './provider-account-scope' + +describe('getProviderAccountScope', () => { + it('describes provider accounts as client-owned without an active runtime', () => { + expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({ + label: 'Local Mac', + description: + 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + }) + }) + + it('describes provider accounts as remote-server-owned with an active runtime', () => { + expect(getProviderAccountScope({ activeRuntimeEnvironmentId: ' env-1 ' })).toEqual({ + label: 'Remote server: env-1', + description: + 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + }) + }) + + it('describes provider API budgets as host-scoped', () => { + expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({ + label: 'Local Mac', + description: + 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + }) + expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: ' env-1 ' }, 'GitLab')).toEqual({ + label: 'Remote server: env-1', + description: + 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + }) + }) +}) diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts new file mode 100644 index 00000000000..9e25f47d0af --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -0,0 +1,46 @@ +import type { GlobalSettings } from '../../../../shared/types' + +export type ProviderAccountScope = { + label: string + description: string +} + +export type ProviderRateLimitScope = { + label: string + description: string +} + +export function getProviderAccountScope( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): ProviderAccountScope { + const runtimeId = settings?.activeRuntimeEnvironmentId?.trim() + if (runtimeId) { + return { + label: `Remote server: ${runtimeId}`, + description: + 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + } + } + return { + label: 'Local Mac', + description: + 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + } +} + +export function getProviderRateLimitScope( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + providerLabel: string +): ProviderRateLimitScope { + const runtimeId = settings?.activeRuntimeEnvironmentId?.trim() + if (runtimeId) { + return { + label: `Remote server: ${runtimeId}`, + description: `${providerLabel} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.` + } + } + return { + label: 'Local Mac', + description: `${providerLabel} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.` + } +} diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx new file mode 100644 index 00000000000..a06d9f08c59 --- /dev/null +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display' +import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display' + +type StoreState = { + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { + current: { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } as StoreState + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current) +})) + +describe('provider rate-limit panels account scope', () => { + it('shows the local host scope for GitHub API budget', () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + + const markup = renderToStaticMarkup(<GitHubRateLimitPanel />) + + expect(markup).toContain('Budget scope: Local Mac') + expect(markup).toContain( + 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + ) + expect(markup).toContain('Open Remote Servers') + }) + + it('shows the remote server scope for GitLab API budget', () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + + const markup = renderToStaticMarkup(<GitLabRateLimitPanel />) + + expect(markup).toContain('Budget scope: Remote server: runtime-1') + expect(markup).toContain( + 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + ) + }) +}) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.test.ts b/src/renderer/src/components/settings/repository-host-setup-options.test.ts new file mode 100644 index 00000000000..9a48b60b5f5 --- /dev/null +++ b/src/renderer/src/components/settings/repository-host-setup-options.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { buildSetupHostOptions } from './repository-host-setup-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +] + +function runtimeHost( + overrides: Partial<ExecutionHostRegistryEntry> = {} +): ExecutionHostRegistryEntry { + return { + id: 'runtime:env-1', + kind: 'runtime', + label: 'Remote Orca', + detail: 'Orca server', + health: 'available', + ...overrides + } as ExecutionHostRegistryEntry +} + +describe('buildSetupHostOptions', () => { + it('disables runtime hosts while capabilities are unknown', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [runtimeHost()] + })[0] + ).toMatchObject({ + isAvailable: false, + detail: 'Checking host capabilities' + }) + }) + + it('enables runtime hosts that advertise project setup and workspace run support', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [ + runtimeHost({ + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ] + })[0] + ).toMatchObject({ + isAvailable: true, + detail: 'Orca server' + }) + }) + + it('disables runtime hosts that cannot run workspaces with explicit host context', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [ + runtimeHost({ + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ] + })[0] + ).toMatchObject({ + isAvailable: false, + detail: 'Update Orca on this host to set up projects' + }) + }) +}) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository-host-setup-options.ts new file mode 100644 index 00000000000..b1e571a1926 --- /dev/null +++ b/src/renderer/src/components/settings/repository-host-setup-options.ts @@ -0,0 +1,103 @@ +import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type SetupHostOption = { + id: ExecutionHostId + label: string + detail: string + isAvailable: boolean +} + +export function getSetupStateLabel(setupState: ProjectHostSetupState): string { + switch (setupState) { + case 'ready': + return translate('auto.components.settings.RepositoryPane.hostSetupStateReady', 'Ready') + case 'not-set-up': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateNotSetUp', + 'Not set up' + ) + case 'setting-up': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateSettingUp', + 'Setting up' + ) + case 'error': + return translate('auto.components.settings.RepositoryPane.hostSetupStateError', 'Error') + case 'unsupported': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateUnsupported', + 'Unsupported' + ) + } +} + +export function buildSetupHostOptions({ + projectHostSetups, + hostOptions +}: { + projectHostSetups: ProjectHostSetup[] + hostOptions: readonly ExecutionHostRegistryEntry[] +}): SetupHostOption[] { + const setupHostIds = new Set(projectHostSetups.map((setup) => setup.hostId)) + return hostOptions + .filter((host) => !setupHostIds.has(host.id)) + .map((host) => { + const availability = getHostSetupAvailability(host) + return { + id: host.id, + label: host.label || getExecutionHostLabel(host.id), + detail: availability.detail, + isAvailable: availability.isAvailable + } + }) +} + +function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { + isAvailable: boolean + detail: string +} { + if (host.health === 'blocked') { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupBlockedVersion', + 'Orca server version is incompatible' + ) + } + } + if (host.kind === 'runtime') { + const capabilities = host.capabilities + if (!capabilities) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupCheckingCapability', + 'Checking host capabilities' + ) + } + } + if ( + !capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) || + !capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY) + ) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupMissingCapability', + 'Update Orca on this host to set up projects' + ) + } + } + } + return { + isAvailable: true, + detail: host.detail + } +} diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 9373206c438..b000c53a6c8 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -86,6 +86,41 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ ) ] }, + { + title: translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), + description: translate( + 'auto.components.settings.repository.search.a1f3a2bd47', + 'Project icon and color used in the sidebar and tabs.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.6438a94c63', + 'project icon' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.b2546efab5', + 'repository icon' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.8d045419b1', 'color'), + ...translateSearchKeyword('auto.components.settings.repository.search.6d8de2f090', 'hex'), + ...translateSearchKeyword('auto.components.settings.repository.search.c1075178cf', 'badge'), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.cb4b4de666', + 'avatar' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.9dc60d7f6d', + 'github' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.1e73e840ff', 'emoji'), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.27733eb6c1', + 'favicon' + ) + ] + }, + ...(isFolder ? [] : getRepositoryGitWorktreeSearchEntries(repo)), ...(isFolder ? [] : [...getRepositoryGitAuthorSearchEntries(repo), ...getRepositoryGitHooksSearchEntries(repo)]) diff --git a/src/renderer/src/components/settings/runtime-environments-search.ts b/src/renderer/src/components/settings/runtime-environments-search.ts index 050c812ebd5..8777c01ed81 100644 --- a/src/renderer/src/components/settings/runtime-environments-search.ts +++ b/src/renderer/src/components/settings/runtime-environments-search.ts @@ -7,11 +7,11 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ title: translate( 'auto.components.settings.runtime.environments.search.3517fb2ec0', - 'Active Server' + 'Remote Orca Servers' ), description: translate( 'auto.components.settings.runtime.environments.search.4575341c77', - 'Choose local desktop, add a saved remote Orca server, or generate a pairing URL.' + 'Add a saved remote Orca server, generate a pairing URL, or adjust the advanced default runtime.' ), keywords: [ ...translateSearchKeyword( @@ -66,7 +66,7 @@ export const getWebRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ title: translate( 'auto.components.settings.runtime.environments.search.3517fb2ec0', - 'Active Server' + 'Remote Orca Servers' ), description: translate( 'auto.components.settings.runtime.environments.search.baec27aa8f', diff --git a/src/renderer/src/components/settings/setting-ownership.test.ts b/src/renderer/src/components/settings/setting-ownership.test.ts new file mode 100644 index 00000000000..49439d414f8 --- /dev/null +++ b/src/renderer/src/components/settings/setting-ownership.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { getSettingOwnershipSummary } from './setting-ownership' + +describe('getSettingOwnershipSummary', () => { + it('documents Source Control AI as client defaults with host-scoped model choices', () => { + const summary = getSettingOwnershipSummary('sourceControlAiDefaults') + + expect(summary.ownership).toBe('client-default') + expect(summary.description).toContain('shared by this client') + expect(summary.description).toContain('model choices and discovery stay scoped to the host') + }) + + it('documents repository Source Control AI as project-host setup scoped', () => { + const summary = getSettingOwnershipSummary('repositorySourceControlAi') + + expect(summary.ownership).toBe('project-host-setup') + expect(summary.description).toContain('this project setup') + }) + + it('documents agent launch defaults as client-owned with run-time host validation', () => { + const summary = getSettingOwnershipSummary('agentLaunchDefaults') + + expect(summary.ownership).toBe('client-default') + expect(summary.description).toContain('SSH and remote server launches') + expect(summary.description).toContain('validate host availability') + }) + + it('keeps workspace directories and provider accounts explicitly host-aware', () => { + expect(getSettingOwnershipSummary('workspaceDirectory').ownership).toBe('host-override') + expect(getSettingOwnershipSummary('providerAccounts').ownership).toBe('provider-host') + }) +}) diff --git a/src/renderer/src/components/settings/setting-ownership.ts b/src/renderer/src/components/settings/setting-ownership.ts new file mode 100644 index 00000000000..d18949443ad --- /dev/null +++ b/src/renderer/src/components/settings/setting-ownership.ts @@ -0,0 +1,55 @@ +export type SettingOwnership = + | 'client-default' + | 'host-override' + | 'project-host-setup' + | 'provider-host' + +type SettingOwnershipSummary = { + ownership: SettingOwnership + label: string + description: string +} + +const SUMMARIES = { + sourceControlAiDefaults: { + ownership: 'client-default', + label: 'Client default', + description: + 'Recipes, prompts, and hosted-review defaults are shared by this client; model choices and discovery stay scoped to the host where the agent runs.' + }, + repositorySourceControlAi: { + ownership: 'project-host-setup', + label: 'Project on this host', + description: + 'These overrides apply to this project setup and inherit the client Source Control AI defaults until customized.' + }, + agentLaunchDefaults: { + ownership: 'client-default', + label: 'Client default', + description: + 'Default agent, command overrides, CLI arguments, and launch environment are client preferences. SSH and remote server launches still validate host availability at run time.' + }, + terminalQuickCommands: { + ownership: 'client-default', + label: 'Client default + project scopes', + description: + 'Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.' + }, + workspaceDirectory: { + ownership: 'host-override', + label: 'Host override', + description: 'The client default is inherited until a host needs its own worktree directory.' + }, + providerAccounts: { + ownership: 'provider-host', + label: 'Provider host', + description: + 'Credentials and account checks belong to the local client or selected remote server that owns the provider integration.' + } +} satisfies Record<string, SettingOwnershipSummary> + +export type SettingOwnershipKey = keyof typeof SUMMARIES + +export function getSettingOwnershipSummary(key: SettingOwnershipKey): SettingOwnershipSummary { + return SUMMARIES[key] +} diff --git a/src/renderer/src/components/settings/ssh-search.ts b/src/renderer/src/components/settings/ssh-search.ts index 0985e906443..1f2fbb8433b 100644 --- a/src/renderer/src/components/settings/ssh-search.ts +++ b/src/renderer/src/components/settings/ssh-search.ts @@ -5,10 +5,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getSshPaneSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.ssh.search.380a788da7', 'SSH Connections'), - description: translate( - 'auto.components.settings.ssh.search.74c6d90d78', - 'Manage remote SSH targets.' - ), + description: translate('auto.components.settings.ssh.search.74c6d90d78', 'Manage SSH hosts.'), keywords: [ ...translateSearchKeyword('auto.components.settings.ssh.search.7efd17e816', 'ssh'), ...translateSearchKeyword('auto.components.settings.ssh.search.d4bcd497c7', 'remote'), @@ -19,10 +16,7 @@ export const getSshPaneSearchEntries = createLocalizedCatalog(() => [ }, { title: translate('auto.components.settings.ssh.search.f5a691bb6c', 'Add SSH Target'), - description: translate( - 'auto.components.settings.ssh.search.62826efbe9', - 'Add a new remote SSH target.' - ), + description: translate('auto.components.settings.ssh.search.62826efbe9', 'Add a new SSH host.'), keywords: [ ...translateSearchKeyword('auto.components.settings.ssh.search.7efd17e816', 'ssh'), ...translateSearchKeyword('auto.components.settings.ssh.search.f7b6383aec', 'add'), diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 10449193110..c32d777809f 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { LinearIntegrationCard } from './task-tracker-integration-cards' @@ -14,25 +14,16 @@ type StoreState = { linearStatusChecked: boolean linearStatusContextKey: string | null disconnectLinear: () => Promise<void> - disconnectLinearWorkspace: () => Promise<void> - checkLinearConnection: () => Promise<void> - testLinearConnection: () => Promise<{ ok: boolean; error?: string }> - settings: { - activeRuntimeEnvironmentId: string | null - localAgentRuntime?: 'host' | 'wsl' - localAgentWslDistro?: string | null - terminalWindowsShell?: string - terminalWindowsWslDistro?: string | null - } + disconnectLinearWorkspace: (workspaceId?: string) => Promise<void> + checkLinearConnection: (force?: boolean) => Promise<void> + testLinearConnection: (workspaceId: string) => Promise<{ ok: boolean; error?: string }> + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void } const mocks = vi.hoisted(() => ({ - store: { current: null as StoreState | null }, - panelProps: [] as Record<string, unknown>[], - skillRefresh: vi.fn(async () => {}), - useInstalledAgentSkill: vi.fn(), - ensureCli: vi.fn(async () => {}), - ensureWslCli: vi.fn(async () => {}) + store: { current: null as StoreState | null } })) vi.mock('@/store', () => ({ @@ -44,33 +35,6 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/hooks/useInstalledAgentSkills', () => ({ - GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], - useInstalledAgentSkill: mocks.useInstalledAgentSkill -})) - -vi.mock('@/lib/agent-skill-cli-prerequisite', () => ({ - AGENT_SKILL_CLI_PREREQUISITE_NOTICE: 'CLI registration notice', - ensureOrcaCliAvailableForAgentSkillTerminal: mocks.ensureCli, - isOrcaCliAvailableOnPath: (status: { state?: string; pathConfigured?: boolean } | null) => - status?.state === 'installed' && status.pathConfigured === true -})) - -vi.mock('./CliSkillRuntimeSetup', () => ({ - buildSkillInstallCommandForRuntime: ( - command: string, - runtime: { runtime: string; wslDistro?: string | null } - ) => - runtime.runtime === 'wsl' - ? `wsl.exe${runtime.wslDistro ? ` -d '${runtime.wslDistro}'` : ''} -- bash -lc '${command}'` - : command, - ensureWslCliAvailableForAgentSkillTerminal: mocks.ensureWslCli, - getWslCliDistroRequest: (runtime?: { runtime: string; wslDistro?: string | null }) => - runtime?.runtime === 'wsl' && runtime.wslDistro?.trim() - ? { distro: runtime.wslDistro.trim() } - : undefined -})) - vi.mock('@/components/linear-api-key-dialog', () => ({ LinearApiKeyDialog: ({ onConnected }: { onConnected?: () => void }) => ( <button type="button" data-testid="simulate-linear-connected" onClick={onConnected}> @@ -79,35 +43,14 @@ vi.mock('@/components/linear-api-key-dialog', () => ({ ) })) -vi.mock('./AgentSkillSetupPanel', () => ({ - AgentSkillSetupPanel: (props: Record<string, unknown> & { actionHint?: ReactNode }) => { - mocks.panelProps.push(props) - return ( - <section data-testid="linear-skill-panel"> - <h2>{String(props.title)}</h2> - <p>{String(props.description)}</p> - <code>{String(props.command)}</code> - <button type="button" onClick={() => void (props.onBeforeOpenTerminal as () => void)()}> - Open installer - </button> - <button type="button" onClick={() => void (props.onRecheck as () => void)()}> - Panel re-check - </button> - {props.actionHint} - </section> - ) - } -})) - let root: Root | null = null let container: HTMLDivElement | null = null -const defaultUserAgent = navigator.userAgent function installStore( connected: boolean, settings: StoreState['settings'] = { activeRuntimeEnvironmentId: null } -): void { - mocks.store.current = { +): StoreState { + const state: StoreState = { linearStatus: { connected, workspaces: connected @@ -127,8 +70,12 @@ function installStore( disconnectLinearWorkspace: vi.fn(async () => {}), checkLinearConnection: vi.fn(async () => {}), testLinearConnection: vi.fn(async () => ({ ok: true })), - settings + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() } + mocks.store.current = state + return state } async function renderCard(): Promise<HTMLDivElement> { @@ -141,29 +88,7 @@ async function renderCard(): Promise<HTMLDivElement> { return container } -describe('LinearIntegrationCard skill setup', () => { - beforeEach(() => { - mocks.panelProps.length = 0 - mocks.skillRefresh.mockClear() - mocks.ensureCli.mockClear() - mocks.ensureWslCli.mockClear() - mocks.useInstalledAgentSkill.mockReset() - mocks.useInstalledAgentSkill.mockReturnValue({ - installed: false, - loading: false, - error: null, - refresh: mocks.skillRefresh - }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { - cli: { - getWslInstallStatus: vi.fn(async () => undefined) - } - } - }) - }) - +describe('LinearIntegrationCard account scope', () => { afterEach(async () => { if (root) { await act(async () => { @@ -174,100 +99,71 @@ describe('LinearIntegrationCard skill setup', () => { container?.remove() container = null mocks.store.current = null - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: defaultUserAgent + }) + + it('shows local-client account ownership when Linear is disconnected', async () => { + const state = installStore(false) + + const rendered = await renderCard() + + expect(rendered.textContent).toContain('Account scope: Local Mac') + expect(rendered.textContent).toContain( + 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + ) + expect(rendered.textContent).toContain('Open Remote Servers') + expect(rendered.textContent).toContain('Add access with a Personal API key') + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Re-check') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - Reflect.deleteProperty(window, 'api') + + expect(state.checkLinearConnection).toHaveBeenCalledWith(true) }) - it('keeps Linear skill setup out of the disconnected state', async () => { - installStore(false) + it('shows remote-server account ownership and connected workspace rows', async () => { + const state = installStore(true, { activeRuntimeEnvironmentId: 'runtime-1' }) const rendered = await renderCard() - expect(rendered.querySelector('[data-testid="linear-skill-panel"]')).toBeNull() - expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith( - 'linear-tickets', - expect.objectContaining({ enabled: false, sourceKinds: ['home'] }) - ) - }) - - it('renders connected Linear skill setup with installer wiring', async () => { - installStore(true) - - const rendered = await renderCard() - - expect(rendered.textContent).toContain('Linear agent skill') - expect(rendered.textContent).toContain('linear-tickets') - expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith( - 'linear-tickets', - expect.objectContaining({ enabled: true, sourceKinds: ['home'] }) - ) - - const openInstallerButton = Array.from(rendered.querySelectorAll('button')).find( - (button) => button.textContent === 'Open installer' + expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') + expect(rendered.textContent).toContain( + 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' ) await act(async () => { - openInstallerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - - expect(mocks.ensureCli).toHaveBeenCalledTimes(1) - }) - - it('uses the WSL skill location for connected Linear setup when selected', async () => { - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: 'Windows' - }) - installStore(true, { - activeRuntimeEnvironmentId: null, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Fedora', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' }) + expect(rendered.textContent).toContain('Acme') + expect(rendered.textContent).toContain('Acme workspace · linear@example.test') - const rendered = await renderCard() - - expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith( - 'linear-tickets', - expect.objectContaining({ - discoveryTarget: { runtime: 'wsl', wslDistro: 'Fedora' }, - enabled: true, - sourceKinds: ['home'] - }) - ) - expect(rendered.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add") - expect(mocks.panelProps.at(-1)).toEqual( - expect.objectContaining({ - terminalShellOverride: 'powershell.exe', - getPrerequisiteStatus: expect.any(Function) - }) - ) - const getPrerequisiteStatus = mocks.panelProps.at(-1)?.getPrerequisiteStatus - expect(getPrerequisiteStatus).toEqual(expect.any(Function)) - await expect((getPrerequisiteStatus as () => Promise<unknown>)()).resolves.toBeUndefined() - expect(window.api.cli.getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Fedora' }) - - const openInstallerButton = Array.from(rendered.querySelectorAll('button')).find( - (button) => button.textContent === 'Open installer' - ) await act(async () => { - openInstallerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Test') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(mocks.ensureWslCli).toHaveBeenCalledWith( - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Fedora' }) - ) - expect(mocks.ensureCli).not.toHaveBeenCalled() + expect(state.testLinearConnection).toHaveBeenCalledWith('workspace-1') }) - it('shows and dismisses the optional post-connect setup note', async () => { + it('clears verification state after adding another Linear workspace', async () => { installStore(true) const rendered = await renderCard() - expect(rendered.textContent).not.toContain('Optional next step') + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Test') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(rendered.textContent).toContain('Verified') await act(async () => { rendered @@ -275,16 +171,6 @@ describe('LinearIntegrationCard skill setup', () => { ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(rendered.textContent).toContain('Optional next step') - - await act(async () => { - rendered - .querySelector<HTMLButtonElement>( - 'button[aria-label="Dismiss optional Linear agent skill setup note"]' - ) - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(rendered.textContent).not.toContain('Optional next step') + expect(rendered.textContent).not.toContain('Verified') }) }) diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index 187a739fcdb..7c89a30584e 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -1,34 +1,14 @@ -import { useEffect, useMemo, useState } from 'react' -import { AlertCircle, CheckCircle2, LoaderCircle, TicketCheck, Unlink, X } from 'lucide-react' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' -import type { GlobalSettings } from '../../../../shared/types' +import { useState } from 'react' +import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react' import { LinearIcon } from '@/components/icons/LinearIcon' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { Button } from '@/components/ui/button' -import { - GLOBAL_AGENT_SKILL_SOURCE_KINDS, - useInstalledAgentSkill -} from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' -import { - LINEAR_TICKETS_SKILL_NAME, - buildAgentFeatureSkillInstallCommand -} from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal, - isOrcaCliAvailableOnPath -} from '@/lib/agent-skill-cli-prerequisite' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' -import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { - buildSkillInstallCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getWslCliDistroRequest, - type LocalAgentRuntime -} from './CliSkillRuntimeSetup' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -45,7 +25,6 @@ export function LinearIntegrationCard(): React.JSX.Element { const mountedRef = useMountedRef() const [dialogOpen, setDialogOpen] = useState(false) - const [showPostConnectSkillPrompt, setShowPostConnectSkillPrompt] = useState(false) const [testingWorkspaceId, setTestingWorkspaceId] = useState<string | null>(null) const [testResultByWorkspace, setTestResultByWorkspace] = useState< Record<string, VerificationResult> @@ -55,43 +34,12 @@ export function LinearIntegrationCard(): React.JSX.Element { const checking = !contextMatches || !linearStatusChecked const connected = contextMatches && linearStatus.connected const workspaces = linearStatus.workspaces ?? [] - const agentRuntime = useMemo(() => getLinearSettingsAgentRuntime(settings), [settings]) - const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>( - () => - agentRuntime.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: agentRuntime.wslDistro } - : undefined, - [agentRuntime.runtime, agentRuntime.wslDistro] - ) - const linearTicketsSkill = useInstalledAgentSkill(LINEAR_TICKETS_SKILL_NAME, { - enabled: connected, - discoveryTarget: skillDiscoveryTarget, - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) - const linearTicketsInstallCommand = useMemo( - () => - buildSkillInstallCommandForRuntime( - buildAgentFeatureSkillInstallCommand([LINEAR_TICKETS_SKILL_NAME]), - agentRuntime - ), - [agentRuntime] - ) - const linearTicketsTerminalShellOverride = getLinearSettingsTerminalShellOverride( - settings, - agentRuntime - ) - - useEffect(() => { - if (!connected) { - setShowPostConnectSkillPrompt(false) - } - }, [connected]) + const accountScope = getProviderAccountScope(settings) const handleDisconnect = async (workspaceId?: string): Promise<void> => { await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear()) if (mountedRef.current) { setTestResultByWorkspace({}) - setShowPostConnectSkillPrompt(false) } } @@ -159,6 +107,7 @@ export function LinearIntegrationCard(): React.JSX.Element { ) : null } > + <ProviderAccountScopeRow scope={accountScope} /> {connected ? ( <div className="mt-3 space-y-2"> {workspaces.map((workspace) => { @@ -234,80 +183,6 @@ export function LinearIntegrationCard(): React.JSX.Element { 'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.' )} </p> - <AgentSkillSetupPanel - title={translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillTitle', - 'Linear agent skill' - )} - description={ - agentRuntime.runtime === 'wsl' - ? translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillWslDescription', - 'Install the WSL agent skill that agents use for richer linked Linear task handoffs.' - ) - : translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillDescription', - 'Install the host agent skill that agents use for richer linked Linear task handoffs.' - ) - } - command={linearTicketsInstallCommand} - terminalTitle={translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillTerminalTitle', - 'Install Linear agent skill' - )} - terminalAriaLabel={translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillTerminalAria', - 'Linear agent skill installer terminal' - )} - terminalWorktreeId="settings-linear-agent-skill-setup" - terminalShellOverride={linearTicketsTerminalShellOverride} - installed={linearTicketsSkill.installed} - loading={linearTicketsSkill.loading} - error={linearTicketsSkill.error} - icon={<TicketCheck className="size-4" />} - installLabel={translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillInstall', - 'Install CLI & Skill' - )} - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={ - agentRuntime.runtime === 'wsl' - ? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) - : undefined - } - isPrerequisiteAvailable={isOrcaCliAvailableOnPath} - onBeforeOpenTerminal={async () => { - setShowPostConnectSkillPrompt(false) - await (agentRuntime.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) - }} - actionHint={ - showPostConnectSkillPrompt && !linearTicketsSkill.installed ? ( - <div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs text-muted-foreground"> - <span className="min-w-0 flex-1"> - {translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillOptionalHint', - 'Optional next step: install the Linear agent skill for ticket-aware agent handoffs.' - )} - </span> - <Button - type="button" - variant="ghost" - size="icon-xs" - aria-label={translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillDismissHint', - 'Dismiss optional Linear agent skill setup note' - )} - onClick={() => setShowPostConnectSkillPrompt(false)} - > - <X className="size-3.5" /> - </Button> - </div> - ) : null - } - onRecheck={linearTicketsSkill.refresh} - /> </div> ) : !checking ? ( <IntegrationCardDetails> @@ -330,10 +205,7 @@ export function LinearIntegrationCard(): React.JSX.Element { open={dialogOpen} onOpenChange={setDialogOpen} connectLabel="Add Linear access" - onConnected={() => { - setTestResultByWorkspace({}) - setShowPostConnectSkillPrompt(true) - }} + onConnected={() => setTestResultByWorkspace({})} overlayClassName="z-[110]" contentClassName="z-[120]" /> @@ -341,58 +213,17 @@ export function LinearIntegrationCard(): React.JSX.Element { ) } +function ProviderAccountScopeRow({ scope }: { scope: ReturnType<typeof getProviderAccountScope> }) { + return ( + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.task.tracker.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={scope} + className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs" + /> + ) +} + export { JiraIntegrationCard } from './jira-integration-card' - -function getCurrentPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - return navigator.userAgent.includes('Linux') ? 'linux' : 'darwin' -} - -function getLinearSettingsAgentRuntime( - settings: - | Pick< - GlobalSettings, - | 'localAgentRuntime' - | 'localAgentWslDistro' - | 'terminalWindowsShell' - | 'terminalWindowsWslDistro' - > - | null - | undefined -): LocalAgentRuntime { - const selectedRuntime = - settings?.localAgentRuntime ?? (settings?.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host') - if (getCurrentPlatform() === 'win32' && selectedRuntime === 'wsl') { - const selectedDistro = - settings?.localAgentWslDistro?.trim() || settings?.terminalWindowsWslDistro?.trim() || null - return { - runtime: 'wsl', - wslDistro: selectedDistro, - label: selectedDistro - ? `WSL ${selectedDistro}` - : translate( - 'auto.components.settings.task.tracker.integration.cards.linearSkillWslLabel', - 'WSL default' - ) - } - } - return { - runtime: 'host', - label: getCurrentPlatform() === 'win32' ? 'Windows' : 'This device' - } -} - -function getLinearSettingsTerminalShellOverride( - settings: Pick<GlobalSettings, 'terminalWindowsShell'> | null | undefined, - runtime: LocalAgentRuntime -): string | undefined { - if (getCurrentPlatform() !== 'win32') { - return undefined - } - if (runtime.runtime === 'wsl') { - return 'powershell.exe' - } - return settings?.terminalWindowsShell?.toLowerCase() === 'wsl.exe' ? 'powershell.exe' : undefined -} diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index e7cbe7ada49..5b7b95c8468 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -209,7 +209,7 @@ describe('AddProjectFromFolderDialog', () => { closeModal: mocks.state.closeModal, setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace }) - expect(mocks.toastSuccess).toHaveBeenCalledWith('Remote project added', { + expect(mocks.toastSuccess).toHaveBeenCalledWith('Project added on SSH host', { description: repo.displayName }) }) diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index ae308733345..12a6ae4222b 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -91,7 +91,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo toast.success( translate( 'auto.components.sidebar.AddProjectFromFolderDialog.e643b30398', - 'Remote project added' + 'Project added on SSH host' ), { description: repo.displayName } ) diff --git a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx new file mode 100644 index 00000000000..19d78a6f445 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react' +import { Folder } from 'lucide-react' +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { RemoteFileBrowser } from './RemoteFileBrowser' + +type CloneStepProps = { + cloneUrl: string + cloneDestination: string + cloneError: string | null + cloneProgress: { phase: string; percent: number } | null + isCloning: boolean + disableDestinationPicker?: boolean + runtimeEnvironmentId?: string | null + sshTargetId?: string | null + cloneTargetLabel?: string | null + onUrlChange: (value: string) => void + onDestChange: (value: string) => void + onPickDestination: () => void + onClone: () => void +} + +export function CloneStep({ + cloneUrl, + cloneDestination, + cloneError, + cloneProgress, + isCloning, + disableDestinationPicker = false, + runtimeEnvironmentId, + sshTargetId, + cloneTargetLabel, + onUrlChange, + onDestChange, + onPickDestination, + onClone +}: CloneStepProps): React.JSX.Element { + const [browsingDestination, setBrowsingDestination] = useState(false) + const isRemoteClone = Boolean(runtimeEnvironmentId || sshTargetId) + const canBrowseRemoteDestination = isRemoteClone + const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + if (canClone) { + onClone() + } + } + } + + if (browsingDestination && (runtimeEnvironmentId || sshTargetId)) { + return ( + <> + <DialogHeader> + <DialogTitle> + {translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + )} + </DialogTitle> + <DialogDescription> + {translate( + 'auto.components.sidebar.AddRepoSteps.fe8e629fe3', + 'Navigate to a directory and click Select to choose it.' + )} + </DialogDescription> + </DialogHeader> + {sshTargetId ? ( + <RemoteFileBrowser + targetId={sshTargetId} + initialPath={cloneDestination || '~'} + onSelect={(path) => { + onDestChange(path) + setBrowsingDestination(false) + }} + onCancel={() => setBrowsingDestination(false)} + /> + ) : ( + <RemoteFileBrowser + runtimeEnvironmentId={runtimeEnvironmentId as string} + initialPath={cloneDestination || '~'} + onSelect={(path) => { + onDestChange(path) + setBrowsingDestination(false) + }} + onCancel={() => setBrowsingDestination(false)} + /> + )} + </> + ) + } + + return ( + <> + <DialogHeader> + <DialogTitle> + {translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')} + </DialogTitle> + <DialogDescription> + {cloneTargetLabel + ? translate( + 'auto.components.sidebar.AddRepoSteps.cloneOnHostDescription', + 'Enter the Git URL and choose where to clone it on {{value0}}.', + { value0: cloneTargetLabel } + ) + : translate( + 'auto.components.sidebar.AddRepoSteps.5b2ea674b1', + 'Enter the Git URL and choose where to clone it.' + )} + </DialogDescription> + </DialogHeader> + + <div className="space-y-3 pt-1"> + <div className="space-y-1"> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoSteps.3d4acbe693', 'Git URL')} + </label> + <Input + value={cloneUrl} + onChange={(e) => onUrlChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate( + 'auto.components.sidebar.AddRepoSteps.b698a4a29d', + 'https://github.com/user/repo.git' + )} + className="h-8 text-xs" + disabled={isCloning} + autoFocus + /> + </div> + + <div className="space-y-1"> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoSteps.cloneParentFolder', 'Parent folder')} + </label> + <div className="flex gap-2"> + <Input + value={cloneDestination} + onChange={(e) => onDestChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate( + isRemoteClone + ? 'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder' + : 'auto.components.sidebar.AddRepoSteps.2ce3f6edf8', + isRemoteClone ? '/home/user/projects' : '/path/to/destination' + )} + className="h-8 text-xs flex-1" + disabled={isCloning} + /> + <Button + variant="outline" + size="sm" + className="h-8 px-2 shrink-0" + onClick={() => { + if (canBrowseRemoteDestination) { + setBrowsingDestination(true) + return + } + onPickDestination() + }} + disabled={isCloning || (disableDestinationPicker && !canBrowseRemoteDestination)} + title={ + canBrowseRemoteDestination + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } + aria-label={ + canBrowseRemoteDestination + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } + > + <Folder className="size-3.5" /> + </Button> + </div> + </div> + + {cloneError && <p className="text-[11px] text-destructive">{cloneError}</p>} + + <Button + onClick={onClone} + disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning} + className="w-full" + > + {isCloning + ? translate('auto.components.sidebar.AddRepoSteps.69f5b5380d', 'Cloning...') + : translate('auto.components.sidebar.AddRepoSteps.32a7256d85', 'Clone')} + </Button> + + {/* Why: progress bar lives below the button so it doesn't push the + button down when it appears mid-clone. */} + {isCloning && cloneProgress && ( + <div className="space-y-1.5"> + <div className="flex items-center justify-between text-[11px] text-muted-foreground"> + <span>{cloneProgress.phase}</span> + <span>{cloneProgress.percent}%</span> + </div> + <div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden"> + <div + className="h-full rounded-full bg-foreground transition-[width] duration-300 ease-out" + style={{ width: `${cloneProgress.percent}%` }} + /> + </div> + </div> + )} + </div> + </> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx b/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx new file mode 100644 index 00000000000..90cbb7194d0 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx @@ -0,0 +1,72 @@ +import type React from 'react' + +export type AddRepoCreateKind = 'git' | 'folder' + +type AddRepoCreateKindCardProps = { + kind: AddRepoCreateKind + selected: boolean + disabled: boolean + onSelect: () => void + onArrowNav: () => void + icon: React.ReactNode + title: string + caption: string +} + +export function AddRepoCreateKindCard({ + kind, + selected, + disabled, + onSelect, + onArrowNav, + icon, + title, + caption +}: AddRepoCreateKindCardProps): React.JSX.Element { + return ( + <button + type="button" + role="radio" + aria-checked={selected} + tabIndex={selected ? 0 : -1} + onClick={onSelect} + onKeyDown={(e) => { + // Why: WAI-ARIA radiogroup spec expects all four arrow keys to move + // selection, even if this specific layout is horizontal today. + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' + ) { + e.preventDefault() + onArrowNav() + } else if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onSelect() + } + }} + disabled={disabled} + data-kind={kind} + className={`group relative flex cursor-pointer items-center gap-3 rounded-md border px-3.5 py-3.5 text-left text-xs transition-colors outline-none ${ + selected ? 'border-foreground/30 bg-accent' : 'border-border hover:bg-accent/50' + } focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60`} + > + <span + className={`inline-flex size-8 shrink-0 items-center justify-center rounded-md border transition-colors ${ + selected + ? 'border-foreground/20 bg-background/60 text-foreground' + : 'border-border/70 bg-background/30 text-muted-foreground group-hover:text-foreground' + }`} + > + {icon} + </span> + <span className="min-w-0"> + <span className="block text-[13px] font-medium leading-tight">{title}</span> + <span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground"> + {caption} + </span> + </span> + </button> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx index 4b592ad1a46..32fd5c88fba 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx @@ -3,15 +3,17 @@ import { describe, expect, it, vi } from 'vitest' import { Dialog } from '@/components/ui/dialog' import { TooltipProvider } from '@/components/ui/tooltip' import { CreateStep } from './AddRepoCreateStep' -import type { GitAvailability } from './create-project-defaults' +import type { GitAvailability, RepoKind } from './create-project-defaults' function renderCreateStep({ createName = '', + createKind = 'git', gitAvailability = 'available', createParent = '/Users/alice/orca/projects', parentDefaultPending = false }: { createName?: string + createKind?: RepoKind gitAvailability?: GitAvailability createParent?: string parentDefaultPending?: boolean @@ -22,6 +24,7 @@ function renderCreateStep({ <CreateStep createName={createName} createParent={createParent} + createKind={createKind} createError={null} isCreating={false} defaultParent="/Users/alice/orca/projects" @@ -30,6 +33,7 @@ function renderCreateStep({ parentDefaultPending={parentDefaultPending} onNameChange={vi.fn()} onParentChange={vi.fn()} + onKindChange={vi.fn()} onPickParent={vi.fn()} onCreate={vi.fn()} /> @@ -39,31 +43,24 @@ function renderCreateStep({ } describe('CreateStep', () => { - it('renders the conductor-style Git project form without templates or kind selection', () => { + it('renders the name-first create UI with advanced controls collapsed', () => { const html = renderCreateStep() - expect(html).toContain('Create project') - expect(html).toContain('Project name') - expect(html).not.toContain('Git repo:') - expect(html).not.toContain('>project-name</span>') - expect(html).toContain('Parent folder') - expect(html).toContain('Browse') - expect(html).not.toContain('Template') + expect(html).toContain('Create a new project') + expect(html).toContain('Name') + expect(html).toContain('Git repository in ~/orca/projects') + // The summary card itself is the collapsed disclosure for the uncommon settings. + expect(html).toContain('aria-expanded="false"') expect(html).not.toContain('Project kind') + expect(html).not.toContain('Location</span>') + expect(html).not.toContain('aria-label="Browse host filesystem"') }) - it('shows the repo name in the helper only after a project name is entered', () => { - const html = renderCreateStep({ createName: 'demo-project' }) + it('shows the Git fallback explanation in the collapsed summary', () => { + const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' }) - expect(html).toContain('Git repo:') - expect(html).toContain('demo-project') - }) - - it('requires Git instead of falling back to folder creation', () => { - const html = renderCreateStep({ gitAvailability: 'unavailable' }) - - expect(html).toContain('Git is required to create a project.') - expect(html).toContain('disabled=""') + expect(html).toContain('Folder in ~/orca/projects') + expect(html).toContain('Git isn't installed, so a plain folder is the default.') }) it('disables create while an auto-filled parent belongs to a previous target', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 8fd3e75d196..2c75a2222f9 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -1,17 +1,30 @@ // Step for AddRepoDialog (orca#763), split out so create-project state stays scoped. -import React, { useMemo, useState } from 'react' -import { CornerDownLeft, FolderOpen, Loader2 } from 'lucide-react' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react' import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { CreateProjectParentBrowser } from './CreateProjectLocationField' +import { cn } from '@/lib/utils' +import { + CreateProjectLocationField, + CreateProjectParentBrowser +} from './CreateProjectLocationField' import { translate } from '@/i18n/i18n' -import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' -import { formatCreateProjectParentSummary, type GitAvailability } from './create-project-defaults' +import { + formatCreateProjectParentSummary, + joinCreateProjectPath, + type GitAvailability, + type RepoKind +} from './create-project-defaults' + +// ── UI helpers ─────────────────────────────────────────────────────── + +const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name' type CreateStepProps = { createName: string createParent: string + createKind: RepoKind createError: string | null isCreating: boolean defaultParent?: string @@ -20,8 +33,10 @@ type CreateStepProps = { parentDefaultPending?: boolean manualParentEntry?: boolean runtimeEnvironmentId?: string | null + sshTargetId?: string | null onNameChange: (value: string) => void onParentChange: (value: string) => void + onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void } @@ -29,6 +44,7 @@ type CreateStepProps = { export function CreateStep({ createName, createParent, + createKind, createError, isCreating, defaultParent = '', @@ -37,18 +53,57 @@ export function CreateStep({ parentDefaultPending = false, manualParentEntry = false, runtimeEnvironmentId, + sshTargetId, onNameChange, onParentChange, + onKindChange, onPickParent, onCreate }: CreateStepProps): React.JSX.Element { + const radioGroupRef = useRef<HTMLDivElement>(null) + const radioFocusFrameRef = useRef<number | null>(null) const [browsingParent, setBrowsingParent] = useState(false) + // Why: SSH hosts need a typed remote path; hiding that field behind the + // collapsed defaults makes the create flow look impossible. + const [advancedOpen, setAdvancedOpen] = useState(manualParentEntry) + + const cancelRadioFocusFrame = useCallback((): void => { + if (radioFocusFrameRef.current === null) { + return + } + cancelAnimationFrame(radioFocusFrameRef.current) + radioFocusFrameRef.current = null + }, []) + + const setRadioGroupNode = useCallback( + (node: HTMLDivElement | null): void => { + // Why: the queued arrow-key focus is only valid while this radiogroup is mounted. + if (!node) { + cancelRadioFocusFrame() + } + radioGroupRef.current = node + }, + [cancelRadioFocusFrame] + ) + + // Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern). + const cycleKind = useCallback(() => { + const next = createKind === 'git' ? 'folder' : 'git' + onKindChange(next) + cancelRadioFocusFrame() + radioFocusFrameRef.current = requestAnimationFrame(() => { + radioFocusFrameRef.current = null + const nextEl = radioGroupRef.current?.querySelector<HTMLButtonElement>( + `[data-kind="${next}"]` + ) + nextEl?.focus() + }) + }, [cancelRadioFocusFrame, createKind, onKindChange]) const canSubmit = createName.trim().length > 0 && createParent.trim().length > 0 && gitAvailability !== 'checking' && - gitAvailability !== 'unavailable' && !parentDefaultPending && !isCreating const missingLocationLabel = translate( @@ -57,37 +112,47 @@ export function CreateStep({ ) const missingServerLocationLabel = translate( 'auto.components.sidebar.AddRepoCreateStep.6ed14c0281', - 'server folder not selected' + 'host folder not selected' ) + const isRemoteHost = Boolean(runtimeEnvironmentId || sshTargetId) - const parentSummary = useMemo( + const summaryParent = useMemo( () => formatCreateProjectParentSummary({ parent: createParent, defaultParent, runtimeEnvironmentId, + isRemoteHost, missingLocationLabel, missingServerLocationLabel }), [ createParent, defaultParent, + isRemoteHost, missingLocationLabel, missingServerLocationLabel, runtimeEnvironmentId ] ) - const repoNamePreview = createName.trim() - const submitShortcutModifierLabel = getScreenSubmitModifierLabel() + const targetPathPreview = useMemo(() => { + const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER + return createParent.trim() ? joinCreateProjectPath(createParent, name) : '' + }, [createName, createParent]) + const kindLabel = + createKind === 'git' + ? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository') + : translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder') const showGitFallback = gitAvailability === 'unavailable' const showGitChecking = gitAvailability === 'checking' const showRuntimeMissingParent = runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking' - if (browsingParent && runtimeEnvironmentId) { + if (browsingParent && (runtimeEnvironmentId || sshTargetId)) { return ( <CreateProjectParentBrowser runtimeEnvironmentId={runtimeEnvironmentId} + sshTargetId={sshTargetId} createParent={createParent} onParentChange={onParentChange} onClose={() => setBrowsingParent(false)} @@ -100,14 +165,14 @@ export function CreateStep({ <DialogHeader> <DialogTitle> {translate( - 'auto.components.sidebar.AddRepoCreateStep.createProjectTitle', - 'Create project' + 'auto.components.sidebar.AddRepoCreateStep.c7b9f94456', + 'Create a new project' )} </DialogTitle> <DialogDescription> {translate( - 'auto.components.sidebar.AddRepoCreateStep.createProjectDescription', - 'Create a local Git repo and first workspace.' + 'auto.components.sidebar.AddRepoCreateStep.b100311784', + 'Name it and Orca will create a real project with sensible defaults.' )} </DialogDescription> </DialogHeader> @@ -116,16 +181,14 @@ export function CreateStep({ (= content size), so a long path inside the Location row would blow out the dialog width even with flex + truncate on the row itself. min-w-0 here caps the grid track at the dialog's max-width. */} - <div className="min-w-0 space-y-5 pt-1"> - <div className="space-y-2"> + <div className="space-y-3.5 pt-1 min-w-0"> + {/* Name. Monospaced because it ends up as a directory name. */} + <div className="space-y-1"> <label htmlFor="create-project-name" - className="block text-sm font-medium text-foreground" + className="text-[11px] font-medium text-muted-foreground block" > - {translate( - 'auto.components.sidebar.AddRepoCreateStep.projectNameLabel', - 'Project name' - )} + {translate('auto.components.sidebar.AddRepoCreateStep.a8149a3a5a', 'Name')} </label> <Input id="create-project-name" @@ -135,110 +198,195 @@ export function CreateStep({ 'auto.components.sidebar.AddRepoCreateStep.0ae45b8238', 'my-project' )} - className="h-11 text-sm" + className="h-11 text-sm font-mono" disabled={isCreating} autoFocus autoComplete="off" spellCheck={false} /> - {repoNamePreview ? ( - <p className="text-sm text-muted-foreground"> - {translate( - 'auto.components.sidebar.AddRepoCreateStep.createsGitRepoHelp', - 'Git repo:' - )}{' '} - <span className="rounded-md bg-muted px-1.5 py-0.5 font-mono">{repoNamePreview}</span> - </p> - ) : null} </div> - <div className="space-y-2"> - <label - htmlFor="create-project-parent" - className="block text-sm font-medium text-foreground" + {/* Summary card doubles as the disclosure for the uncommon settings, so the + defaults and the controls to change them live in one place. */} + <div className="min-w-0 rounded-md border border-border bg-muted/30"> + <button + type="button" + onClick={() => setAdvancedOpen((open) => !open)} + aria-expanded={advancedOpen} + className="flex w-full min-w-0 items-start gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > - {translate( - 'auto.components.sidebar.AddRepoCreateStep.parentFolderLabel', - 'Parent folder' - )} - </label> - <div className="flex min-w-0 gap-2"> - <Input - id="create-project-parent" - value={createParent} - onChange={(e) => onParentChange(e.target.value)} - placeholder={translate( - 'auto.components.sidebar.CreateProjectLocationField.2a20a603a3', - '/home/user/projects' + <span className="mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background/60 text-muted-foreground"> + {createKind === 'git' ? ( + <GitBranch className="size-3.5" /> + ) : ( + <Folder className="size-3.5" /> + )} + </span> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium"> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.685b5eefe1', + '{{kind}} in {{parent}}', + { + kind: kindLabel, + parent: summaryParent + } + )} + </p> + {showGitChecking ? ( + <p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground"> + <Loader2 className="size-3 animate-spin" /> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.2a762f3b19', + 'Checking Git on this host...' + )} + </p> + ) : showGitFallback ? ( + <p className="mt-0.5 text-[11px] text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b', + "Git isn't installed, so a plain folder is the default." + )} + </p> + ) : showRuntimeMissingParent ? ( + <p className="mt-0.5 text-[11px] text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.c234df77f7', + 'Choose or enter a host parent folder before creating.' + )} + </p> + ) : targetPathPreview ? ( + <p + className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" + title={targetPathPreview} + > + {targetPathPreview} + </p> + ) : null} + </div> + <ChevronDown + className={cn( + 'size-4 shrink-0 self-center text-muted-foreground transition-transform', + advancedOpen && 'rotate-180' )} - className="h-11 min-w-0 flex-1 font-mono text-sm" - disabled={isCreating} - spellCheck={false} /> - <Button - type="button" - variant="outline" - onClick={() => { - if (runtimeEnvironmentId) { - setBrowsingParent(true) - } else { - onPickParent() - } - }} - disabled={isCreating || (manualParentEntry && !runtimeEnvironmentId)} - size="sm" - className="h-11 shrink-0 gap-1.5 px-3" - > - <FolderOpen className="size-3.5" /> - {translate('auto.components.sidebar.AddRepoCreateStep.browseParentFolder', 'Browse')} - </Button> - </div> - {showGitChecking ? ( - <p className="flex items-center gap-2 text-sm text-muted-foreground"> - <Loader2 className="size-4 animate-spin" /> - {translate( - 'auto.components.sidebar.AddRepoCreateStep.2a762f3b19', - 'Checking Git on this host...' + </button> + + {advancedOpen && ( + <div className="space-y-3 border-t border-border px-3 py-3"> + {/* Real radiogroup so screen readers announce the segmented choice. */} + <div className="space-y-1.5"> + <span className="text-[11px] font-medium text-muted-foreground block"> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.180e9b5e48', + 'Project kind' + )} + </span> + <div + ref={setRadioGroupNode} + role="radiogroup" + aria-label={translate( + 'auto.components.sidebar.AddRepoCreateStep.180e9b5e48', + 'Project kind' + )} + className="grid grid-cols-2 rounded-md border border-border bg-muted/30 p-0.5" + > + {(['git', 'folder'] as const).map((kind) => { + const selected = createKind === kind + const label = + kind === 'git' + ? translate( + 'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', + 'Git repository' + ) + : translate( + 'auto.components.sidebar.AddRepoCreateStep.038729c107', + 'Folder' + ) + const Icon = kind === 'git' ? GitBranch : Folder + return ( + <button + key={kind} + type="button" + role="radio" + aria-checked={selected} + tabIndex={selected ? 0 : -1} + onClick={() => onKindChange(kind)} + onKeyDown={(e) => { + // Why: keep keyboard radio navigation intact inside the compact segmented control. + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' + ) { + e.preventDefault() + cycleKind() + } else if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onKindChange(kind) + } + }} + disabled={isCreating} + data-kind={kind} + className={cn( + 'inline-flex min-w-0 items-center justify-center gap-1.5 rounded-sm border px-2.5 py-2 text-xs font-medium outline-none transition-colors', + // Why: the segment sits on a muted card, so bg-background alone + // is too subtle; the border makes the selected state legible. + selected + ? 'border-border bg-background text-foreground shadow-xs' + : 'border-transparent text-muted-foreground hover:text-foreground', + 'focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60' + )} + > + <Icon className="size-3.5 shrink-0" /> + <span className="truncate">{label}</span> + </button> + ) + })} + </div> + {showGitFallback && ( + <p className="text-[11px] text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b', + "Git isn't installed, so a plain folder is the default." + )} + </p> + )} + </div> + + {/* The local picker returns client paths; runtime servers browse host paths via RPC. */} + <CreateProjectLocationField + createParent={createParent} + isCreating={isCreating} + manualParentEntry={manualParentEntry} + runtimeEnvironmentId={runtimeEnvironmentId} + sshTargetId={sshTargetId} + onParentChange={onParentChange} + onPickParent={onPickParent} + onBrowseServer={() => setBrowsingParent(true)} + /> + + {targetPathPreview && ( + <p className="min-w-0 break-all rounded-md border border-border bg-background/40 px-2.5 py-2 font-mono text-[11px] text-muted-foreground"> + {targetPathPreview} + </p> )} - </p> - ) : showGitFallback ? ( - <p className="text-sm text-destructive" role="alert"> - {translate( - 'auto.components.sidebar.AddRepoCreateStep.gitRequiredError', - 'Git is required to create a project.' - )} - </p> - ) : showRuntimeMissingParent ? ( - <p className="text-sm text-muted-foreground"> - {translate( - 'auto.components.sidebar.AddRepoCreateStep.c234df77f7', - 'Choose or enter a server parent folder before creating.' - )} - </p> - ) : ( - <p className="truncate text-sm text-muted-foreground" title={parentSummary}> - {parentSummary} - </p> + </div> )} </div> {createError && ( - <p className="mt-6 text-sm text-destructive" role="alert"> + <p className="text-[11px] text-destructive" role="alert"> {createError} </p> )} - <div className="flex justify-end pt-2"> - <Button onClick={onCreate} disabled={!canSubmit} size="lg"> - {isCreating - ? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…') - : translate('auto.components.sidebar.AddRepoCreateStep.createAction', 'Create')} - <span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/25 bg-primary-foreground/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground/75"> - <span>{submitShortcutModifierLabel}</span> - <CornerDownLeft className="size-3" /> - </span> - </Button> - </div> + <Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full"> + {isCreating + ? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…') + : translate('auto.components.sidebar.AddRepoCreateStep.45b7c26034', 'Create project')} + </Button> </div> </> ) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 9d2c17a12b7..58a5d89f36b 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -1,12 +1,7 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useState } from 'react' import { useAppStore } from '@/store' -import { Dialog, DialogContent } from '@/components/ui/dialog' -import { track } from '@/lib/telemetry' import { useRemoteRepo } from './AddRepoSteps' import { useCreateRepo } from './useCreateRepo' -import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry' -import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' -import { AddRepoStepIndicator } from './AddRepoStepIndicator' import { AddRepoDialogStepContent } from './AddRepoDialogStepContent' import type { AddRepoDialogStep } from './add-repo-dialog-types' import { useAddRepoNestedReviewState } from './useAddRepoNestedReviewState' @@ -14,9 +9,13 @@ import { useAddRepoCloneFlow } from './useAddRepoCloneFlow' import { useAddRepoLocalFolderFlow } from './useAddRepoLocalFolderFlow' import { useAddRepoServerPathFlow } from './useAddRepoServerPathFlow' import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow' -import { buildAddRepoExistingWorkspacesDetectedEvent } from './add-repo-existing-workspaces-telemetry' -import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' +import { useAddRepoHostSelection } from './use-add-repo-host-selection' +import { useCompleteGitRepoAdd } from './use-complete-git-repo-add' import { useCreateProjectDefaults } from './useCreateProjectDefaults' +import { useAddRepoHostChangeReset } from './use-add-repo-host-change-reset' +import { AddRepoDialogChrome } from './AddRepoDialogChrome' +import { AddRepoHostSelectorSlot } from './AddRepoHostSelectorSlot' +import { useAddRepoRemoteNestedScan } from './use-add-repo-remote-nested-scan' const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -32,13 +31,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const settings = useAppStore((s) => s.settings) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const completeGitRepoAdd = useCompleteGitRepoAdd({ + closeModal, + setHideDefaultBranchWorkspace + }) const [step, setStep] = useState<AddRepoDialogStep>('add') const [isAdding, setIsAdding] = useState(false) const [addProjectBusyLabel, setAddProjectBusyLabel] = useState<string | null>(null) - const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set()) const { nestedScan, nestedSelectedPaths, @@ -63,27 +63,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setStep }) - const completeGitRepoAdd = useCallback( - async (repoId: string, source: AddRepoExistingWorkspaceSource): Promise<void> => { - const worktrees = useAppStore.getState().worktreesByRepo[repoId] ?? [] - const existingWorkspaceTelemetry = buildAddRepoExistingWorkspacesDetectedEvent( - source, - worktrees - ) - if (existingWorkspaceTelemetry && !detectedTelemetryTrackedRef.current.has(repoId)) { - detectedTelemetryTrackedRef.current.add(repoId) - track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) - } - await finishProjectAddWithDefaultCheckout({ - repoId, - source, - closeModal, - setHideDefaultBranchWorkspace - }) - }, - [closeModal, setHideDefaultBranchWorkspace] - ) - + const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep }) + const selectedRuntimeEnvironmentId = + hostSelection.selectedParsedHost?.kind === 'runtime' + ? hostSelection.selectedParsedHost.environmentId + : null + const { showRemoteNestedRepoReview, trackRemoteNestedScanResult } = useAddRepoRemoteNestedScan({ + setActiveNestedScanId, + showNestedRepoReview + }) const { sshTargets, selectedTargetId, @@ -105,44 +93,32 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { closeModal, (repoId) => completeGitRepoAdd(repoId, 'ssh_remote_path'), scanNestedRepos, - (scan, selectedPath, connectionId, attemptId, inProgress, scanId) => { - setActiveNestedScanId(inProgress ? scanId : null) - showNestedRepoReview({ - scan, - selectedPath, - connectionId, - attemptId, - runtimeKind: 'ssh', - inProgress, - scanId - }) - }, - (scan, attemptId) => { - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'sidebar', - runtimeKind: 'ssh', - scan - }) - ) - } + showRemoteNestedRepoReview, + trackRemoteNestedScanResult ) const { createName, createParent, + createKind, createError, isCreating, setCreateName, setCreateParent, + setCreateKind, setCreateError, resetCreateState, handlePickParent, handleCreate - } = useCreateRepo(fetchWorktrees, closeModal, (repoId) => - completeGitRepoAdd(repoId, 'create_project') + } = useCreateRepo( + fetchWorktrees, + closeModal, + (repoId) => completeGitRepoAdd(repoId, 'create_project'), + { + hostId: hostSelection.selectedHostId, + runtimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId + } ) const { @@ -151,12 +127,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createRuntimeParentStatus, createParentDefaultPending, resetCreateDefaultState, - markCreateParentTouched + markCreateParentTouched, + markCreateKindTouched } = useCreateProjectDefaults({ step, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId, createParent, - setCreateParent + setCreateParent, + setCreateKind }) const { @@ -173,7 +152,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleClone } = useAddRepoCloneFlow({ step, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId, workspaceDir: settings?.workspaceDir, fetchWorktrees, onGitRepoReady: completeGitRepoAdd @@ -182,18 +162,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const isOpen = activeModal === 'add-repo' const droppedLocalPath = typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' - const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) - // Why: repo_added telemetry cannot reliably separate SSH from local folder adds, - // so promote remote projects from durable local SSH state instead. - const isSshLikely = - repos.some((repo) => Boolean(repo.connectionId)) || - sshTargetLabels.size > 0 || - Array.from(sshConnectionStates.values()).some((state) => state.status === 'connected') - + const isRuntimeEnvironmentActive = Boolean(selectedRuntimeEnvironmentId) + const selectedHostKind = hostSelection.selectedParsedHost?.kind const { handleBrowse, resetLocalFolderFlow } = useAddRepoLocalFolderFlow({ isOpen, droppedLocalPath, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, addRepoPath, closeModal, fetchWorktrees, @@ -232,7 +206,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedConnectionId, nestedGroupName, nestedImportScanId, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, fetchWorktrees, importNestedRepos, getNestedRepoRuntimeKind, @@ -266,14 +240,29 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetCreateState ]) - // Why: reset state on close so reopening doesn't show stale step/repo. - useEffect(() => { - if (!isOpen) { - resetState() - } - }, [isOpen, resetState]) + const resetHostScopedState = useCallback(() => { + setIsAdding(false) + setAddProjectBusyLabel(null) + resetServerPathFlow() + resetCloneFlow() + resetCreateDefaultState() + resetCreateState() + resetRemoteState() + }, [ + resetCloneFlow, + resetCreateDefaultState, + resetCreateState, + resetRemoteState, + resetServerPathFlow + ]) + + useAddRepoHostChangeReset({ + isOpen, + selectedHostId: hostSelection.selectedHostId, + onResetClosed: resetState, + onResetHostScopedState: resetHostScopedState + }) - // Why: handleBack reuses resetState which already aborts clones and resets all fields. const handleBack = useCallback(() => { if (step === 'nested') { trackNestedBackAction() @@ -281,121 +270,146 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetState() }, [resetState, step, trackNestedBackAction]) - return ( - <Dialog - open={isOpen} - onOpenChange={(open) => { - if (!open) { - if (step === 'nested' && !isAdding) { - trackNestedBackAction() - } - closeModal() - resetState() + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + if (step === 'nested' && !isAdding) { + trackNestedBackAction() } - }} + closeModal() + resetState() + } + }, + [closeModal, isAdding, resetState, step, trackNestedBackAction] + ) + + return ( + <AddRepoDialogChrome + isOpen={isOpen} + step={step} + isAdding={isAdding} + onBack={handleBack} + onOpenChange={handleOpenChange} > - <DialogContent - className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${ - step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : '' - }`} - > - <AddRepoStepIndicator step={step} isAdding={isAdding} onBack={handleBack} /> - <AddRepoDialogStepContent - step={step} - isRuntimeEnvironmentActive={isRuntimeEnvironmentActive} - activeRuntimeEnvironmentId={settings?.activeRuntimeEnvironmentId} - isSshLikely={isSshLikely} - repoCount={repos.length} - isAdding={isAdding} - addProjectBusyLabel={addProjectBusyLabel} - nestedScanInProgress={nestedScanInProgress} - nestedScanId={nestedScanId} - serverPath={serverPath} - isAddingServerPath={isAddingServerPath} - cloneUrl={cloneUrl} - cloneDestination={cloneDestination} - cloneError={cloneError} - cloneProgress={cloneProgress} - isCloning={isCloning} - sshTargets={sshTargets} - selectedTargetId={selectedTargetId} - remotePath={remotePath} - remoteError={remoteError} - isAddingRemote={isAddingRemote} - isScanningRemoteNested={isScanningRemoteNested} - nestedScan={nestedScan} - nestedSelectedPaths={nestedSelectedPaths} - nestedGroupName={nestedGroupName} - createName={createName} - createParent={createParent} - createError={createError} - isCreating={isCreating} - createDefaultParent={createDefaultParent} - createGitAvailability={createGitAvailability} - createRuntimeParentStatus={createRuntimeParentStatus} - createParentDefaultPending={createParentDefaultPending} - onBrowse={handleBrowse} - onOpenCloneStep={() => { - setCloneError(null) - setStep('clone') - }} - onOpenCreateStep={() => { - setCreateError(null) - setStep('create') - }} - onOpenRemoteStep={handleOpenRemoteStep} - onStopNestedScan={handleStopNestedScan} - onServerPathChange={setServerPath} - onAddServerPath={(kind) => void handleAddServerPath(kind)} - onSelectTarget={(id) => { - setSelectedTargetId(id) - setRemoteError(null) - }} - onRemotePathChange={(value) => { - setRemotePath(value) - setRemoteError(null) - }} - onAddRemoteRepo={handleAddRemoteRepo} - onOpenSshSettings={() => { - closeModal() - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) - openSettingsPage() - }} - onConnectTarget={handleConnectTarget} - onStopRemoteNestedScan={stopRemoteNestedScan} - onCloneUrlChange={(value) => { - setCloneUrl(value) - setCloneError(null) - }} - onCloneDestinationChange={(value) => { - setCloneDestination(value) - setCloneError(null) - }} - onPickCloneDestination={handlePickDestination} - onClone={handleClone} - onNestedGroupNameChange={setNestedGroupName} - onNestedSelectedPathsChange={setNestedSelectedPaths} - onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} - onCreateNameChange={(value) => { - setCreateName(value) - setCreateError(null) - }} - onCreateParentChange={(value) => { - markCreateParentTouched(value) - setCreateParent(value) - setCreateError(null) - }} - onPickCreateParent={() => { - void handlePickParent().then((dir) => { - if (dir) { - markCreateParentTouched(dir) - } - }) - }} - onCreate={handleCreate} - /> - </DialogContent> - </Dialog> + <AddRepoDialogStepContent + step={step} + isRuntimeEnvironmentActive={isRuntimeEnvironmentActive} + activeRuntimeEnvironmentId={selectedRuntimeEnvironmentId} + isSshLikely={false} + repoCount={repos.length} + isAdding={isAdding} + addProjectBusyLabel={addProjectBusyLabel} + nestedScanInProgress={nestedScanInProgress} + nestedScanId={nestedScanId} + serverPath={serverPath} + isAddingServerPath={isAddingServerPath} + cloneUrl={cloneUrl} + cloneDestination={cloneDestination} + cloneError={cloneError} + cloneProgress={cloneProgress} + isCloning={isCloning} + sshTargets={sshTargets} + selectedTargetId={selectedTargetId} + selectedSshTargetId={hostSelection.selectedSshTargetId} + selectedHostLabel={ + hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId) + ?.label ?? hostSelection.selectedHostId + } + lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'} + remotePath={remotePath} + remoteError={remoteError} + isAddingRemote={isAddingRemote} + isScanningRemoteNested={isScanningRemoteNested} + nestedScan={nestedScan} + nestedSelectedPaths={nestedSelectedPaths} + nestedGroupName={nestedGroupName} + createName={createName} + createParent={createParent} + createKind={createKind} + createError={createError} + isCreating={isCreating} + hostSelector={<AddRepoHostSelectorSlot hostSelection={hostSelection} />} + showRemoteAction={false} + browseHostKind={ + selectedHostKind === 'ssh' || selectedHostKind === 'runtime' ? selectedHostKind : 'local' + } + createDefaultParent={createDefaultParent} + createGitAvailability={createGitAvailability} + createRuntimeParentStatus={createRuntimeParentStatus} + createParentDefaultPending={createParentDefaultPending} + manualCreateParentEntry={isRuntimeEnvironmentActive || selectedHostKind === 'ssh'} + onBrowse={ + selectedHostKind === 'ssh' + ? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId) + : selectedHostKind === 'runtime' + ? () => setStep('server-path') + : handleBrowse + } + onOpenCloneStep={() => { + setCloneError(null) + setStep('clone') + }} + onOpenCreateStep={() => { + setCreateError(null) + setStep('create') + }} + onOpenRemoteStep={handleOpenRemoteStep} + onStopNestedScan={handleStopNestedScan} + onServerPathChange={setServerPath} + onAddServerPath={(kind) => void handleAddServerPath(kind)} + onSelectTarget={(id) => { + setSelectedTargetId(id) + setRemoteError(null) + }} + onRemotePathChange={(value) => { + setRemotePath(value) + setRemoteError(null) + }} + onAddRemoteRepo={handleAddRemoteRepo} + onOpenSshSettings={() => { + closeModal() + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsPage() + }} + onConnectTarget={handleConnectTarget} + onStopRemoteNestedScan={stopRemoteNestedScan} + onCloneUrlChange={(value) => { + setCloneUrl(value) + setCloneError(null) + }} + onCloneDestinationChange={(value) => { + setCloneDestination(value) + setCloneError(null) + }} + onPickCloneDestination={handlePickDestination} + onClone={handleClone} + onNestedGroupNameChange={setNestedGroupName} + onNestedSelectedPathsChange={setNestedSelectedPaths} + onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} + onCreateNameChange={(value) => { + setCreateName(value) + setCreateError(null) + }} + onCreateParentChange={(value) => { + markCreateParentTouched(value) + setCreateParent(value) + setCreateError(null) + }} + onCreateKindChange={(kind) => { + markCreateKindTouched() + setCreateKind(kind) + setCreateError(null) + }} + onPickCreateParent={() => { + void handlePickParent().then((dir) => { + if (dir) { + markCreateParentTouched(dir) + } + }) + }} + onCreate={handleCreate} + /> + </AddRepoDialogChrome> ) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx new file mode 100644 index 00000000000..5c27fd8dd22 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { AddRepoStepIndicator } from './AddRepoStepIndicator' + +export function AddRepoDialogChrome({ + children, + isAdding, + isOpen, + onBack, + onOpenChange, + step +}: { + children: ReactNode + isAdding: boolean + isOpen: boolean + onBack: () => void + onOpenChange: (open: boolean) => void + step: AddRepoDialogStep +}) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent + className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${ + step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : '' + }`} + > + <AddRepoStepIndicator step={step} isAdding={isAdding} onBack={onBack} /> + {children} + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index b295293c100..f1e2bd0cfd4 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -53,6 +53,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string { nestedGroupName: 'platform', createName: '', createParent: '', + createKind: 'git', createError: null, isCreating: false, createDefaultParent: '', @@ -81,6 +82,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string { onImportNestedRepos: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), + onCreateKindChange: vi.fn(), onPickCreateParent: vi.fn(), onCreate: vi.fn(), ...overrides @@ -104,8 +106,8 @@ describe('AddRepoDialogStepContent nested imports', () => { const html = renderNestedStep(0) expect(html).toContain('Is this a monorepo?') - expect(html).toContain('aria-label="Group name"') - expect(html).toContain('Import as group') + expect(html).toContain('aria-label="Monorepo name"') + expect(html).toContain('Yes, import as monorepo') expect(html).toContain('No, import separately') expect(html).not.toContain('>Import</button>') }) @@ -114,25 +116,39 @@ describe('AddRepoDialogStepContent nested imports', () => { const html = renderNestedStep(1) expect(html).toContain('Is this a monorepo?') - expect(html).toContain('aria-label="Group name"') - expect(html).toContain('Import as group') + expect(html).toContain('aria-label="Monorepo name"') + expect(html).toContain('Yes, import as monorepo') expect(html).toContain('No, import separately') expect(html).not.toContain('>Import</button>') }) - it('offers server browsing for remote create project locations', () => { + it('offers host browsing for remote create project locations', () => { const html = renderStepContent({ step: 'create', isRuntimeEnvironmentActive: true, activeRuntimeEnvironmentId: 'env-1' }) - expect(html).toContain('Create project') - expect(html).toContain('Choose or enter a server parent folder before creating.') - expect(html).toContain('Browse') + expect(html).toContain('Create a new project') + expect(html).toContain('host folder not selected') }) - it('offers server browsing for remote clone destinations', () => { + it('uses manual path entry for SSH create project locations', () => { + const html = renderStepContent({ + step: 'create', + manualCreateParentEntry: true, + selectedSshTargetId: 'openclaw-2', + activeRuntimeEnvironmentId: null + }) + + expect(html).toContain('Create a new project') + expect(html).toContain('placeholder="/home/user/projects"') + expect(html).toContain('aria-label="Browse host filesystem"') + expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*aria-label="Browse host filesystem"/) + expect(html).not.toContain('Choose parent folder') + }) + + it('offers host browsing for remote clone destinations', () => { const html = renderStepContent({ step: 'clone', isRuntimeEnvironmentActive: true, @@ -140,6 +156,121 @@ describe('AddRepoDialogStepContent nested imports', () => { }) expect(html).toContain('Clone from URL') - expect(html).toContain('aria-label="Browse server filesystem"') + expect(html).toContain('aria-label="Browse host filesystem"') + }) + + it('offers SSH browsing for selected-host clone destinations', () => { + const html = renderStepContent({ + step: 'clone', + selectedSshTargetId: 'openclaw-2', + selectedHostLabel: 'openclaw 2' + }) + + expect(html).toContain('Clone from URL') + expect(html).toContain('choose where to clone it on openclaw 2') + expect(html).toContain('Parent folder') + expect(html).toContain('aria-label="Browse host filesystem"') + expect(html).not.toContain('aria-label="Choose folder"') + }) + + it('hides the SSH target chooser after a host was already selected', () => { + const html = renderStepContent({ + step: 'remote', + lockSshTargetSelection: true, + selectedTargetId: 'openclaw-2', + sshTargets: [ + { + id: 'github', + label: 'github.com', + host: 'github.com', + port: 22, + username: 'git', + state: { + targetId: 'github', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + }, + { + id: 'openclaw-2', + label: 'openclaw 2', + host: 'openclaw.example.com', + port: 22, + username: 'dev', + state: { + targetId: 'openclaw-2', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + } + ] + }) + + expect(html).toContain('Open project on SSH host') + expect(html).toContain('openclaw 2') + expect(html).toContain('Host path') + expect(html).not.toContain('SSH target') + expect(html).not.toContain('github.com') + expect(html).not.toContain('Connect') + }) + + it('shows a connect affordance for a selected disconnected SSH host', () => { + const html = renderStepContent({ + step: 'remote', + lockSshTargetSelection: true, + selectedTargetId: 'openclaw-2', + sshTargets: [ + { + id: 'openclaw-2', + label: 'openclaw 2', + host: 'openclaw.example.com', + port: 22, + username: 'dev', + state: { + targetId: 'openclaw-2', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + } + ] + }) + + expect(html).toContain('openclaw 2') + expect(html).toContain('is disconnected') + expect(html).toContain('Connect') + expect(html).not.toContain('SSH target') + expect(html).toContain('placeholder="/home/user/project"') + expect(html).toContain('disabled=""') + }) + + it('uses SSH-aware copy on the add step when an SSH host is selected', () => { + const html = renderStepContent({ + step: 'add', + browseHostKind: 'ssh' + }) + + expect(html).toContain('Open project on SSH host') + expect(html).toContain('Existing Git repository or folder on this SSH host') + expect(html).not.toContain('Local project, Git repo, or folder with many repos') + }) + + it('uses the standard add step for remote Orca server hosts', () => { + const html = renderStepContent({ + step: 'add', + isRuntimeEnvironmentActive: true, + activeRuntimeEnvironmentId: 'env-1', + browseHostKind: 'runtime' + }) + + expect(html).toContain('Browse folder') + expect(html).toContain('Existing Git repository or folder on this host') + expect(html).toContain('Clone from URL') + expect(html).toContain('Create new project') + expect(html).not.toContain('Browse host') + expect(html).not.toContain('Create on host') + expect(html).not.toContain('Want to import many repos at once?') }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index baa39686ba7..c7d67d97bda 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -1,5 +1,5 @@ -import type { Dispatch, SetStateAction } from 'react' -import { CloneStep } from './AddRepoSteps' +import type { Dispatch, ReactNode, SetStateAction } from 'react' +import { CloneStep } from './AddRepoCloneStep' import { RemoteStep } from './AddRepoRemoteStep' import { CreateStep } from './AddRepoCreateStep' import { AddRepoLocalStartStep } from './AddRepoStartSteps' @@ -29,6 +29,9 @@ type AddRepoDialogStepContentProps = { isCloning: boolean sshTargets: (SshTarget & { state?: SshConnectionState })[] selectedTargetId: string | null + selectedSshTargetId?: string | null + selectedHostLabel?: string | null + lockSshTargetSelection?: boolean remotePath: string remoteError: string | null isAddingRemote: boolean @@ -38,8 +41,14 @@ type AddRepoDialogStepContentProps = { nestedGroupName: string createName: string createParent: string + createKind: 'git' | 'folder' createError: string | null isCreating: boolean + hostSelector?: ReactNode + showRemoteAction?: boolean + canCreateProject?: boolean + manualCreateParentEntry?: boolean + browseHostKind?: 'local' | 'ssh' | 'runtime' createDefaultParent: string createGitAvailability: GitAvailability createRuntimeParentStatus: 'idle' | 'checking' | 'failed' @@ -47,7 +56,7 @@ type AddRepoDialogStepContentProps = { onBrowse: () => void onOpenCloneStep: () => void onOpenCreateStep: () => void - onOpenRemoteStep: () => void + onOpenRemoteStep: (targetId?: string | null) => void onStopNestedScan: () => void onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void @@ -66,6 +75,7 @@ type AddRepoDialogStepContentProps = { onImportNestedRepos: (mode: 'group' | 'separate') => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void + onCreateKindChange: (kind: 'git' | 'folder') => void onPickCreateParent: () => void onCreate: () => void } @@ -89,6 +99,9 @@ export function AddRepoDialogStepContent({ isCloning, sshTargets, selectedTargetId, + selectedSshTargetId, + selectedHostLabel, + lockSshTargetSelection = false, remotePath, remoteError, isAddingRemote, @@ -98,8 +111,14 @@ export function AddRepoDialogStepContent({ nestedGroupName, createName, createParent, + createKind, createError, isCreating, + hostSelector, + showRemoteAction = true, + canCreateProject = true, + manualCreateParentEntry = isRuntimeEnvironmentActive, + browseHostKind = 'local', createDefaultParent, createGitAvailability, createRuntimeParentStatus, @@ -126,24 +145,10 @@ export function AddRepoDialogStepContent({ onImportNestedRepos, onCreateNameChange, onCreateParentChange, + onCreateKindChange, onPickCreateParent, onCreate }: AddRepoDialogStepContentProps): React.JSX.Element | null { - if (step === 'add' && isRuntimeEnvironmentActive) { - return ( - <AddRepoServerPathStartStep - serverPath={serverPath} - runtimeEnvironmentId={activeRuntimeEnvironmentId} - isAddingServerPath={isAddingServerPath} - addProjectBusyLabel={addProjectBusyLabel} - onServerPathChange={onServerPathChange} - onAddServerPath={onAddServerPath} - onOpenCloneStep={onOpenCloneStep} - onOpenCreateStep={onOpenCreateStep} - /> - ) - } - if (step === 'add') { return ( <AddRepoLocalStartStep @@ -153,6 +158,10 @@ export function AddRepoDialogStepContent({ addProjectBusyLabel={addProjectBusyLabel} nestedScanInProgress={nestedScanInProgress} nestedScanId={nestedScanId} + hostSelector={hostSelector} + showRemoteAction={showRemoteAction} + canCreateProject={canCreateProject} + browseHostKind={browseHostKind} onBrowse={onBrowse} onOpenCloneStep={onOpenCloneStep} onOpenRemoteStep={onOpenRemoteStep} @@ -162,11 +171,29 @@ export function AddRepoDialogStepContent({ ) } + if (step === 'server-path') { + return ( + <AddRepoServerPathStartStep + serverPath={serverPath} + runtimeEnvironmentId={activeRuntimeEnvironmentId} + isAddingServerPath={isAddingServerPath} + addProjectBusyLabel={addProjectBusyLabel} + hostSelector={hostSelector} + initialBrowsing + onServerPathChange={onServerPathChange} + onAddServerPath={onAddServerPath} + onOpenCloneStep={onOpenCloneStep} + onOpenCreateStep={onOpenCreateStep} + /> + ) + } + if (step === 'remote') { return ( <RemoteStep sshTargets={sshTargets} selectedTargetId={selectedTargetId} + lockSshTargetSelection={lockSshTargetSelection} remotePath={remotePath} remoteError={remoteError} isAddingRemote={isAddingRemote} @@ -191,6 +218,10 @@ export function AddRepoDialogStepContent({ isCloning={isCloning} disableDestinationPicker={isRuntimeEnvironmentActive} runtimeEnvironmentId={activeRuntimeEnvironmentId} + sshTargetId={selectedSshTargetId} + cloneTargetLabel={ + isRuntimeEnvironmentActive || selectedSshTargetId ? selectedHostLabel : null + } onUrlChange={onCloneUrlChange} onDestChange={onCloneDestinationChange} onPickDestination={onPickCloneDestination} @@ -220,16 +251,19 @@ export function AddRepoDialogStepContent({ <CreateStep createName={createName} createParent={createParent} + createKind={createKind} createError={createError} isCreating={isCreating} defaultParent={createDefaultParent} gitAvailability={createGitAvailability} runtimeParentStatus={createRuntimeParentStatus} parentDefaultPending={createParentDefaultPending} - manualParentEntry={isRuntimeEnvironmentActive} + manualParentEntry={manualCreateParentEntry} runtimeEnvironmentId={activeRuntimeEnvironmentId} + sshTargetId={selectedSshTargetId} onNameChange={onCreateNameChange} onParentChange={onCreateParentChange} + onKindChange={onCreateKindChange} onPickParent={onPickCreateParent} onCreate={onCreate} /> diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx new file mode 100644 index 00000000000..470e81b0512 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx @@ -0,0 +1,105 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AddRepoHostSelector } from './AddRepoHostSelector' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div> +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + disabled, + className + }: { + children: React.ReactNode + disabled?: boolean + className?: string + }) => ( + <div aria-disabled={disabled} className={className}> + {children} + </div> + ) +})) + +describe('AddRepoHostSelector', () => { + it('shows disconnected SSH hosts as disabled choices in Add Project', () => { + const html = renderToStaticMarkup( + <AddRepoHostSelector + hosts={[ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'ssh:ssh-1', + label: 'Builder', + detail: 'SSH', + kind: 'ssh', + health: 'disconnected', + presence: 'configured' + } + ]} + selectedHostId="ssh:ssh-1" + open={false} + onOpenChange={vi.fn()} + onSelectHost={vi.fn()} + /> + ) + + expect(html).toContain('Builder') + expect(html).toContain('Disconnected') + expect(html).toContain('aria-disabled="true"') + expect(html).toContain('cursor-not-allowed') + expect(html).toContain('opacity-55') + }) + + it('shows exact update guidance for incompatible runtime hosts', () => { + const html = renderToStaticMarkup( + <AddRepoHostSelector + hosts={[ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'runtime:old-server', + label: 'Old server', + detail: 'Orca server', + kind: 'runtime', + health: 'blocked', + presence: 'active', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + } + ]} + selectedHostId="runtime:old-server" + open + onOpenChange={vi.fn()} + onSelectHost={vi.fn()} + /> + ) + + expect(html).toContain('Update needed') + expect(html).toContain('The selected Orca server is too old for this client.') + expect(html).toContain('Update Orca on the server.') + expect(html).toContain('aria-disabled="true"') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx new file mode 100644 index 00000000000..3bf51c33bf7 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -0,0 +1,118 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { SidebarHostOption } from './sidebar-host-options' +import { getSidebarHostHealthLabel, shouldShowHostScopeControls } from './sidebar-host-options' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' +import { translate } from '@/i18n/i18n' +import { canSelectAddRepoHost } from './add-repo-host-availability' + +type AddRepoHostSelectorProps = { + hosts: SidebarHostOption[] + selectedHostId: ExecutionHostId + open: boolean + onOpenChange: (open: boolean) => void + onSelectHost: (hostId: ExecutionHostId) => void +} + +function getHostStatusDetail(host: SidebarHostOption): string { + if (host.compatibility?.kind === 'blocked') { + return describeRuntimeCompatBlock(host.compatibility) + } + return `${getSidebarHostHealthLabel(host.health)}${host.detail ? ` - ${host.detail}` : ''}` +} + +export function AddRepoHostSelector({ + hosts, + selectedHostId, + open, + onOpenChange, + onSelectHost +}: AddRepoHostSelectorProps): React.JSX.Element | null { + if (!shouldShowHostScopeControls(hosts)) { + return null + } + + const selectedHost = hosts.find((host) => host.id === selectedHostId) ?? hosts[0] + if (!selectedHost) { + return null + } + return ( + <div className="flex items-center gap-2 text-xs"> + <span className="font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoHostSelector.host', 'Host')} + </span> + <Popover open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="ghost" + role="combobox" + aria-expanded={open} + className="h-7 min-w-0 max-w-[18rem] gap-1.5 rounded-md border border-border bg-muted/30 px-2 text-xs font-medium text-foreground hover:bg-accent hover:text-accent-foreground" + > + <span className="min-w-0 truncate">{selectedHost.label}</span> + {selectedHost.health !== 'local' ? ( + <span + title={getHostStatusDetail(selectedHost)} + className="shrink-0 text-[11px] font-normal text-muted-foreground" + > + {getSidebarHostHealthLabel(selectedHost.health)} + </span> + ) : null} + <ChevronsUpDown className="size-3.5 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(340px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <CommandList> + {hosts.map((host) => { + const selected = host.id === selectedHostId + const disabled = !canSelectAddRepoHost(host) + return ( + <CommandItem + key={host.id} + value={`${host.label} ${host.detail}`} + disabled={disabled} + onSelect={() => { + if (disabled) { + return + } + onSelectHost(host.id) + onOpenChange(false) + }} + className={cn( + 'items-start gap-2 px-3 py-2 text-xs', + disabled && 'cursor-not-allowed opacity-55' + )} + > + <Check + className={cn( + 'mt-0.5 size-3 text-muted-foreground', + selected ? 'opacity-70' : 'opacity-0' + )} + /> + <span className="min-w-0 flex-1"> + <span className="flex min-w-0 items-center gap-2"> + <span className="truncate font-medium">{host.label}</span> + </span> + <span className="mt-0.5 block truncate text-[11px] text-muted-foreground"> + {getHostStatusDetail(host)} + </span> + </span> + </CommandItem> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx new file mode 100644 index 00000000000..0fe1236f5a5 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx @@ -0,0 +1,18 @@ +import { AddRepoHostSelector } from './AddRepoHostSelector' +import type { useAddRepoHostSelection } from './use-add-repo-host-selection' + +export function AddRepoHostSelectorSlot({ + hostSelection +}: { + hostSelection: ReturnType<typeof useAddRepoHostSelection> +}) { + return ( + <AddRepoHostSelector + hosts={hostSelection.hostOptions} + selectedHostId={hostSelection.selectedHostId} + open={hostSelection.hostSelectorOpen} + onOpenChange={hostSelection.setHostSelectorOpen} + onSelectHost={(hostId) => void hostSelection.handleSelectAddProjectHost(hostId)} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index 47d8ecd691a..13be2021c7d 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -78,13 +78,13 @@ describe('AddRepoNestedImportStep', () => { expect(html).toContain('Import repositories from folder') expect(html).toContain('Found 3 repositories in') expect(html).toContain('/workspace/platform') - expect(html).toContain('aria-label="Group name"') + expect(html).toContain('aria-label="Monorepo name"') expect(html).not.toContain('What is a') expect(html).toContain('Is this a monorepo?') - expect(html).toContain('Import them as a group if they're a monorepo') + expect(html).toContain('Choose this if these projects belong together') expect(html).toContain('Orca will group them and let you work from the parent folder') expect(html).toContain('No, import separately') - expect(html).toContain('Import as group') + expect(html).toContain('Yes, import as monorepo') expect(html).toContain('payments/api') expect(html).toContain('billing/api') expect(html).not.toContain('disabled=""') @@ -97,9 +97,9 @@ describe('AddRepoNestedImportStep', () => { expect(html).toContain('Is this a monorepo?') expect(html).toContain('No, import separately') - expect(html).toContain('Import as group') + expect(html).toContain('Yes, import as monorepo') expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/) - expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Import as group<\/button>/) + expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/) }) it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => { @@ -130,7 +130,7 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Import as group').click() + findButton(host, 'Yes, import as monorepo').click() findButton(host, 'No, import separately').click() }) @@ -174,11 +174,13 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Import as group').click() + findButton(host, 'Yes, import as monorepo').click() }) expect(onImport).toHaveBeenCalledWith('group') - expect(findButton(host, 'Import as group').querySelector('.animate-spin')).not.toBeNull() + expect( + findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin') + ).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index 8332415fa2a..e8e5f816769 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n' type RemoteStepProps = { sshTargets: (SshTarget & { state?: SshConnectionState })[] selectedTargetId: string | null + lockSshTargetSelection?: boolean remotePath: string remoteError: string | null isAddingRemote: boolean @@ -26,6 +27,7 @@ type RemoteStepProps = { export function RemoteStep({ sshTargets, selectedTargetId, + lockSshTargetSelection = false, remotePath, remoteError, isAddingRemote, @@ -38,6 +40,14 @@ export function RemoteStep({ onStopNestedScan }: RemoteStepProps): React.JSX.Element { const [browsing, setBrowsing] = useState(false) + const selectedTarget = selectedTargetId + ? sshTargets.find((target) => target.id === selectedTargetId) + : null + const selectedTargetLabel = + selectedTarget?.label || + (selectedTarget ? `${selectedTarget.username}@${selectedTarget.host}` : selectedTargetId) + const selectedTargetStatus = selectedTarget?.state?.status ?? 'disconnected' + const selectedTargetConnected = selectedTargetStatus === 'connected' if (browsing && selectedTargetId) { return ( @@ -73,60 +83,89 @@ export function RemoteStep({ <> <DialogHeader> <DialogTitle> - {translate('auto.components.sidebar.AddRepoRemoteStep.91b93a90a4', 'Open remote project')} + {translate( + 'auto.components.sidebar.AddRepoRemoteStep.91b93a90a4', + 'Open project on SSH host' + )} </DialogTitle> <DialogDescription> - {translate( - 'auto.components.sidebar.AddRepoRemoteStep.80557be85a', - 'Choose a connected SSH target and enter the path to a Git repository.' - )} + {lockSshTargetSelection + ? translate( + 'auto.components.sidebar.AddRepoRemoteStep.lockedDescription', + 'Enter the path to a Git repository on {{value0}}.', + { value0: selectedTargetLabel ?? 'this SSH target' } + ) + : translate( + 'auto.components.sidebar.AddRepoRemoteStep.80557be85a', + 'Choose a connected SSH target and enter the path to a Git repository.' + )} </DialogDescription> </DialogHeader> <div className="space-y-3 pt-1"> - <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground"> - {translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')} - </label> - {sshTargets.length === 0 ? ( - <div className="space-y-1.5 py-1"> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.sidebar.AddRepoRemoteStep.df6fbcf880', - 'No SSH targets configured.' - )} - </p> - <Button - variant="outline" - size="sm" - className="h-7 text-xs" - onClick={onOpenSshSettings} - > - <Settings className="size-3.5" /> - {translate( - 'auto.components.sidebar.AddRepoRemoteStep.0416bde073', - 'Add in Settings' - )} - </Button> - </div> - ) : ( - <div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek"> - {sshTargets.map((target) => ( - <SshTargetRow - key={target.id} - target={target} - isSelected={selectedTargetId === target.id} - onSelect={onSelectTarget} - onConnect={onConnectTarget} - /> - ))} - </div> - )} - </div> + {!lockSshTargetSelection ? ( + <div className="space-y-1"> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')} + </label> + {sshTargets.length === 0 ? ( + <div className="space-y-1.5 py-1"> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoRemoteStep.df6fbcf880', + 'No SSH targets configured.' + )} + </p> + <Button + variant="outline" + size="sm" + className="h-7 text-xs" + onClick={onOpenSshSettings} + > + <Settings className="size-3.5" /> + {translate( + 'auto.components.sidebar.AddRepoRemoteStep.0416bde073', + 'Add in Settings' + )} + </Button> + </div> + ) : ( + <div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek"> + {sshTargets.map((target) => ( + <SshTargetRow + key={target.id} + target={target} + isSelected={selectedTargetId === target.id} + onSelect={onSelectTarget} + onConnect={onConnectTarget} + /> + ))} + </div> + )} + </div> + ) : selectedTarget && !selectedTargetConnected ? ( + <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> + <p className="min-w-0 text-xs text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoRemoteStep.lockedDisconnected', + '{{value0}} is disconnected.', + { value0: selectedTargetLabel ?? 'This SSH host' } + )} + </p> + <Button + variant="outline" + size="xs" + className="shrink-0" + onClick={() => onConnectTarget(selectedTarget.id)} + > + {translate('auto.components.sidebar.AddRepoRemoteStep.93e0221434', 'Connect')} + </Button> + </div> + ) : null} <div className="space-y-1"> <label className="text-[11px] font-medium text-muted-foreground"> - {translate('auto.components.sidebar.AddRepoRemoteStep.ef410aa881', 'Remote path')} + {translate('auto.components.sidebar.AddRepoRemoteStep.ef410aa881', 'Host path')} </label> <div className="flex gap-2"> <Input @@ -145,14 +184,14 @@ export function RemoteStep({ '/home/user/project' )} className="h-8 text-xs flex-1" - disabled={isAddingRemote || !selectedTargetId} + disabled={isAddingRemote || !selectedTargetId || !selectedTargetConnected} /> <Button variant="outline" size="sm" className="h-8 px-2 shrink-0" onClick={() => setBrowsing(true)} - disabled={!selectedTargetId || isAddingRemote} + disabled={!selectedTargetId || !selectedTargetConnected || isAddingRemote} > <FolderOpen className="size-3.5" /> </Button> @@ -163,14 +202,16 @@ export function RemoteStep({ <Button onClick={onAdd} - disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote} + disabled={ + !selectedTargetId || !selectedTargetConnected || !remotePath.trim() || isAddingRemote + } className="w-full" > {isAddingRemote ? translate('auto.components.sidebar.AddRepoRemoteStep.35831a7312', 'Adding...') : translate( 'auto.components.sidebar.AddRepoRemoteStep.36d427bb66', - 'Add remote project' + 'Add project on SSH host' )} </Button> {isScanningNested ? ( diff --git a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx index 98b2f01fee9..14f539e2b02 100644 --- a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx @@ -1,4 +1,4 @@ -import { useState, type ComponentType } from 'react' +import { useState, type ComponentType, type ReactNode } from 'react' import { FolderOpen, Globe, Lightbulb, Loader2, Server } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -12,6 +12,8 @@ type AddRepoServerPathStartStepProps = { runtimeEnvironmentId: string | null | undefined isAddingServerPath: boolean addProjectBusyLabel: string | null + hostSelector?: ReactNode + initialBrowsing?: boolean onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void onOpenCloneStep: () => void @@ -23,13 +25,15 @@ export function AddRepoServerPathStartStep({ runtimeEnvironmentId, isAddingServerPath, addProjectBusyLabel, + hostSelector, + initialBrowsing = false, onServerPathChange, onAddServerPath, onOpenCloneStep, onOpenCreateStep }: AddRepoServerPathStartStepProps): React.JSX.Element { - const [browsing, setBrowsing] = useState(false) - const [pathEntryOpen, setPathEntryOpen] = useState(false) + const [browsing, setBrowsing] = useState(initialBrowsing) + const [pathEntryOpen, setPathEntryOpen] = useState(initialBrowsing) if (browsing && runtimeEnvironmentId) { return ( @@ -38,7 +42,7 @@ export function AddRepoServerPathStartStep({ <DialogTitle> {translate( 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', - 'Browse server filesystem' + 'Browse host filesystem' )} </DialogTitle> <DialogDescription> @@ -77,18 +81,19 @@ export function AddRepoServerPathStartStep({ <DialogDescription> {translate( 'auto.components.sidebar.AddRepoServerStartStep.8efa930eb5', - 'Add another project from the selected runtime server.' + 'Add another project from the selected host.' )} </DialogDescription> </DialogHeader> <div className="space-y-3 pt-2"> + {hostSelector} <div className="grid grid-cols-3 gap-2"> <AddRepoServerStartAction icon={FolderOpen} title={translate( 'auto.components.sidebar.AddRepoServerStartStep.0adf083af7', - 'Browse server' + 'Browse host' )} description={translate( 'auto.components.sidebar.AddRepoServerStartStep.516187414c', @@ -114,7 +119,7 @@ export function AddRepoServerPathStartStep({ icon={Server} title={translate( 'auto.components.sidebar.AddRepoServerStartStep.a81ffa0a99', - 'Create on server' + 'Create on host' )} description={translate( 'auto.components.sidebar.AddRepoServerStartStep.d40d751517', @@ -145,7 +150,7 @@ export function AddRepoServerPathStartStep({ > {translate( 'auto.components.sidebar.AddRepoServerStartStep.438493f214', - 'Or enter a server path manually' + 'Or enter a host path manually' )} </button> </div> @@ -159,24 +164,25 @@ export function AddRepoServerPathStartStep({ <DialogTitle> {translate( 'auto.components.sidebar.AddRepoServerStartStep.3d0c035483', - 'Open server project' + 'Open host project' )} </DialogTitle> <DialogDescription> {translate( 'auto.components.sidebar.AddRepoServerStartStep.423b5d3d31', - 'Add a Git repository or folder that already exists on the selected runtime server.' + 'Add a Git repository or folder that already exists on the selected host.' )} </DialogDescription> </DialogHeader> <div className="space-y-3 pt-2"> + {hostSelector} <div className="space-y-1"> <label htmlFor="server-project-path" className="block text-[11px] font-medium text-muted-foreground" > - {translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Server path')} + {translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Host path')} </label> <div className="flex gap-2"> <Input @@ -203,7 +209,7 @@ export function AddRepoServerPathStartStep({ disabled={isAddingServerPath || !runtimeEnvironmentId} aria-label={translate( 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', - 'Browse server filesystem' + 'Browse host filesystem' )} > <FolderOpen className="size-4" /> @@ -212,7 +218,7 @@ export function AddRepoServerPathStartStep({ <TooltipContent side="top" sideOffset={4}> {translate( 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', - 'Browse server filesystem' + 'Browse host filesystem' )} </TooltipContent> </Tooltip> diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index 62cfae41daa..40fc712810a 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -120,6 +120,46 @@ function getActionTitles(isSshLikely: boolean): { } } +function getHostAwareActionModel(): { + secondary: string[] + createDisabled: boolean | undefined +} { + const { secondaryActions } = getAddRepoLocalStartActions({ + isSshLikely: true, + showRemoteAction: false, + onBrowse: vi.fn(), + onOpenCloneStep: vi.fn(), + onOpenRemoteStep: vi.fn(), + onOpenCreateStep: vi.fn() + }) + const createAction = secondaryActions.find((action) => action.kind === 'create') + + return { + secondary: secondaryActions.map((action) => action.title), + createDisabled: createAction?.disabled + } +} + +function getRuntimeHostActionModel(): { + primary: string + description: string +} { + const { primaryAction } = getAddRepoLocalStartActions({ + isSshLikely: false, + showRemoteAction: false, + browseHostKind: 'runtime', + onBrowse: vi.fn(), + onOpenCloneStep: vi.fn(), + onOpenRemoteStep: vi.fn(), + onOpenCreateStep: vi.fn() + }) + + return { + primary: primaryAction.title, + description: primaryAction.description + } +} + describe('AddRepoLocalStartStep', () => { afterEach(() => { document.body.innerHTML = '' @@ -130,8 +170,8 @@ describe('AddRepoLocalStartStep', () => { expect(markup).toContain('Browse folder') expect(markup).toContain('Clone from URL') - expect(markup).toContain('Remote project') - expect(markup).toContain('Create project') + expect(markup).toContain('Project on SSH host') + expect(markup).toContain('Create new project') expect(markup).toContain('Other ways to add') expect(markup).not.toContain('More options') }) @@ -140,23 +180,45 @@ describe('AddRepoLocalStartStep', () => { const titles = getActionTitles(false) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create project']) + expect(titles.secondary).toEqual([ + 'Clone from URL', + 'Project on SSH host', + 'Create new project' + ]) }) it('keeps Browse folder primary for SSH-likely users', () => { const markup = renderLocalStartStep(true) expect(markup).toContain('Browse folder') - expect(markup).toContain('Remote project') + expect(markup).toContain('Project on SSH host') expect(markup).toContain('Clone from URL') - expect(markup).toContain('Create project') + expect(markup).toContain('Create new project') }) it('orders secondary actions remote-first for SSH-likely users', () => { const titles = getActionTitles(true) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create project']) + expect(titles.secondary).toEqual([ + 'Project on SSH host', + 'Clone from URL', + 'Create new project' + ]) + }) + + it('lets host-aware Add Project replace the separate remote row', () => { + const model = getHostAwareActionModel() + + expect(model.secondary).toEqual(['Clone from URL', 'Create new project']) + expect(model.createDisabled).toBe(false) + }) + + it('uses host-neutral browse copy for runtime hosts', () => { + const model = getRuntimeHostActionModel() + + expect(model.primary).toBe('Browse folder') + expect(model.description).toBe('Existing Git repository or folder on this host') }) it('focuses Browse folder when the default Add Project step opens', async () => { @@ -173,7 +235,7 @@ describe('AddRepoLocalStartStep', () => { it('focuses Browse folder for SSH-likely users too', async () => { const { container, root } = await renderLocalStartStepDom(true) const browseButton = findButton(container, 'Browse folder') - const remoteButton = findButton(container, 'Remote project') + const remoteButton = findButton(container, 'Project on SSH host') expect(document.activeElement).toBe(browseButton) expect(document.activeElement).not.toBe(remoteButton) @@ -187,8 +249,8 @@ describe('AddRepoLocalStartStep', () => { const { container, root } = await renderLocalStartStepDom(false) expect(findButton(container, 'Clone from URL').disabled).toBe(false) - expect(findButton(container, 'Remote project').disabled).toBe(false) - expect(findButton(container, 'Create project').disabled).toBe(false) + expect(findButton(container, 'Project on SSH host').disabled).toBe(false) + expect(findButton(container, 'Create new project').disabled).toBe(false) await act(async () => { root.unmount() @@ -303,12 +365,12 @@ describe('AddRepoServerPathStartStep', () => { const markup = renderServerPathStartStep('env-1') expect(markup).toContain('Add a project') - expect(markup).toContain('Add another project from the selected runtime server.') - expect(markup).toContain('Browse server') + expect(markup).toContain('Add another project from the selected host.') + expect(markup).toContain('Browse host') expect(markup).toContain('Clone from URL') - expect(markup).toContain('Create on server') + expect(markup).toContain('Create on host') expect(markup).toContain('Want to import many repos at once?') - expect(markup).toContain('Or enter a server path manually') + expect(markup).toContain('Or enter a host path manually') }) it('disables server entry cards without an active runtime environment', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index 20b259eaa88..a6715bdfee6 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ComponentType, type Ref } from 'react' +import { useEffect, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react' import { CircleStop, Loader2 } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -66,6 +66,10 @@ type AddRepoLocalStartStepProps = { addProjectBusyLabel: string | null nestedScanInProgress: boolean nestedScanId: string | null + hostSelector?: ReactNode + showRemoteAction?: boolean + canCreateProject?: boolean + browseHostKind?: 'local' | 'ssh' | 'runtime' onBrowse: () => void onOpenCloneStep: () => void onOpenRemoteStep: () => void @@ -80,6 +84,10 @@ export function AddRepoLocalStartStep({ addProjectBusyLabel, nestedScanInProgress, nestedScanId, + hostSelector, + showRemoteAction = true, + canCreateProject = true, + browseHostKind = 'local', onBrowse, onOpenCloneStep, onOpenRemoteStep, @@ -93,7 +101,10 @@ export function AddRepoLocalStartStep({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction, + canCreateProject, + browseHostKind }) // The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge: @@ -161,6 +172,7 @@ export function AddRepoLocalStartStep({ onBlur={handleActionsBlur} onKeyDown={handleArrowNavigation} > + {hostSelector} <AddRepoPrimaryStartAction icon={primaryAction.icon} title={primaryAction.title} @@ -187,7 +199,7 @@ export function AddRepoLocalStartStep({ icon={action.icon} title={action.title} description={action.description} - disabled={isAdding} + disabled={isAdding || Boolean(action.disabled)} selected={selectedKind === action.kind} onClick={action.onClick} onFocus={() => setSelectedKind(action.kind)} diff --git a/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx b/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx index 514d4f6af1a..7cb9148fb33 100644 --- a/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx @@ -13,7 +13,12 @@ export function AddRepoStepIndicator({ isAdding, onBack }: AddRepoStepIndicatorProps): React.JSX.Element | null { - const showBack = step === 'clone' || step === 'remote' || step === 'create' || step === 'nested' + const showBack = + step === 'clone' || + step === 'remote' || + step === 'server-path' || + step === 'create' || + step === 'nested' if (!showBack) { return null diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index c1c9cd15935..db874556e9d 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -8,11 +8,15 @@ const mocks = vi.hoisted(() => ({ stateIndex: 0, storeState: { repos: [] as Repo[], + projects: [], + projectHostSetups: [], clearOrcaHookTrustForRepo: vi.fn(), openModal: vi.fn(), cancelNestedRepoScan: vi.fn() }, addRemote: vi.fn(), + listTargets: vi.fn(), + getState: vi.fn(), onStateChanged: vi.fn(() => vi.fn()), fetchWorktrees: vi.fn(), onGitRepoReady: vi.fn() @@ -90,9 +94,18 @@ describe('useRemoteRepo default-checkout handoff', () => { mocks.stateSetters = [] mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false, null] mocks.storeState.repos = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] + mocks.listTargets.mockResolvedValue([ + { id: 'ssh-1', label: 'Builder 1' }, + { id: 'ssh-2', label: 'Builder 2' } + ]) + mocks.getState.mockResolvedValue({ status: 'connected' }) vi.stubGlobal('window', { api: { ssh: { + listTargets: mocks.listTargets, + getState: mocks.getState, onStateChanged: mocks.onStateChanged }, repos: { @@ -124,6 +137,12 @@ describe('useRemoteRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) }) @@ -150,4 +169,23 @@ describe('useRemoteRepo default-checkout handoff', () => { 'Could not refresh project worktrees. Try again.' ) }) + + it('preselects the preferred SSH target when opening Browse for a selected host', async () => { + mocks.stateValues = [[], null, '~/', null, false, null] + const { useRemoteRepo } = await import('./AddRepoSteps') + + const result = useRemoteRepo( + mocks.fetchWorktrees, + vi.fn(), + vi.fn(), + mocks.onGitRepoReady, + vi.fn().mockResolvedValue(null) + ) + await result.handleOpenRemoteStep('ssh-2') + + expect(mocks.listTargets).toHaveBeenCalled() + expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-1' }) + expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-2' }) + expect(mocks.stateSetters[1]).toHaveBeenCalledWith('ssh-2') + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index af55e2448ea..5ab96be88ca 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -1,18 +1,15 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { Folder } from 'lucide-react' import { useAppStore } from '@/store' -import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { useMountedRef } from '@/hooks/useMountedRef' -import { RemoteFileBrowser } from './RemoteFileBrowser' import type { NestedRepoScanResult } from '../../../../shared/types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' -// ── Remote project hook ───────────────────────────────────────────── +// ── SSH host project hook ─────────────────────────────────────────── export function useRemoteRepo( fetchWorktrees: ( @@ -67,37 +64,47 @@ export function useRemoteRepo( void cancelNestedRepoScan(remoteNestedScanId) }, [cancelNestedRepoScan, remoteNestedScanId]) - const handleOpenRemoteStep = useCallback(async () => { - const gen = ++remoteGenRef.current - setStep('remote') - try { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - if (gen !== remoteGenRef.current) { - return + const handleOpenRemoteStep = useCallback( + async (preferredTargetId?: string | null) => { + const gen = ++remoteGenRef.current + setStep('remote') + try { + const targets = (await window.api.ssh.listTargets()) as SshTarget[] + if (gen !== remoteGenRef.current) { + return + } + const withState = await Promise.all( + targets.map(async (t) => { + const state = (await window.api.ssh.getState({ + targetId: t.id + })) as SshConnectionState | null + return { ...t, state: state ?? undefined } + }) + ) + if (gen !== remoteGenRef.current) { + return + } + setSshTargets(withState) + const preferred = preferredTargetId + ? withState.find((t) => t.id === preferredTargetId) + : undefined + const connected = withState.find((t) => t.state?.status === 'connected') + if (preferred) { + setSelectedTargetId(preferred.id) + return + } + if (connected) { + setSelectedTargetId(connected.id) + } + } catch { + if (gen !== remoteGenRef.current) { + return + } + setSshTargets([]) } - const withState = await Promise.all( - targets.map(async (t) => { - const state = (await window.api.ssh.getState({ - targetId: t.id - })) as SshConnectionState | null - return { ...t, state: state ?? undefined } - }) - ) - if (gen !== remoteGenRef.current) { - return - } - setSshTargets(withState) - const connected = withState.find((t) => t.state?.status === 'connected') - if (connected) { - setSelectedTargetId(connected.id) - } - } catch { - if (gen !== remoteGenRef.current) { - return - } - setSshTargets([]) - } - }, [setStep]) + }, + [setStep] + ) // Why: keep the target list's connection state in sync while the dialog is // open, so clicking the inline Connect button below updates the dot/label @@ -116,7 +123,11 @@ export function useRemoteRepo( try { await window.api.ssh.connect({ targetId }) } catch (err) { - toast.error(err instanceof Error ? err.message : translate("auto.components.sidebar.AddRepoSteps.3e64e8a70d", "Connection failed")) + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.sidebar.AddRepoSteps.3e64e8a70d', 'Connection failed') + ) } }, []) @@ -178,18 +189,15 @@ export function useRemoteRepo( if (existingIdx !== -1) { state.clearOrcaHookTrustForRepo(repo.id) } - if (existingIdx === -1) { - useAppStore.setState({ repos: [...state.repos, repo] }) - } else { - const updated = [...state.repos] - updated[existingIdx] = repo - useAppStore.setState({ repos: updated }) - } + upsertAddedRepoWithProjectHostSetup(repo) if (!mountedRef.current || gen !== remoteGenRef.current) { return } - toast.success(translate("auto.components.sidebar.AddRepoSteps.df8b0e6c22", "Remote project added"), { description: repo.displayName }) + toast.success( + translate('auto.components.sidebar.AddRepoSteps.df8b0e6c22', 'Project added on SSH host'), + { description: repo.displayName } + ) // Why: the repo is already persisted here; if SSH refresh is temporarily // non-authoritative, finish onto the project row instead of stranding the dialog. await fetchWorktrees(repo.id, { requireAuthoritative: true }) @@ -198,7 +206,7 @@ export function useRemoteRepo( } await onGitRepoReady?.(repo.id) } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = extractIpcErrorMessage(err, String(err)) if (message.includes('Not a valid git repository')) { // Why: match the local add-project flow — show confirmation dialog so // users understand git features will be unavailable, rather than @@ -248,147 +256,3 @@ export function useRemoteRepo( stopRemoteNestedScan } } - -// ── Clone step ─────────────────────────────────────────────────────── - -type CloneStepProps = { - cloneUrl: string - cloneDestination: string - cloneError: string | null - cloneProgress: { phase: string; percent: number } | null - isCloning: boolean - disableDestinationPicker?: boolean - runtimeEnvironmentId?: string | null - onUrlChange: (value: string) => void - onDestChange: (value: string) => void - onPickDestination: () => void - onClone: () => void -} - -export function CloneStep({ - cloneUrl, - cloneDestination, - cloneError, - cloneProgress, - isCloning, - disableDestinationPicker = false, - runtimeEnvironmentId, - onUrlChange, - onDestChange, - onPickDestination, - onClone -}: CloneStepProps): React.JSX.Element { - const [browsingDestination, setBrowsingDestination] = useState(false) - const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning - const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault() - if (canClone) { - onClone() - } - } - } - - if (browsingDestination && runtimeEnvironmentId) { - return ( - <> - <DialogHeader> - <DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem")}</DialogTitle> - <DialogDescription> - {translate("auto.components.sidebar.AddRepoSteps.fe8e629fe3", "Navigate to a directory and click Select to choose it.")}</DialogDescription> - </DialogHeader> - <RemoteFileBrowser - runtimeEnvironmentId={runtimeEnvironmentId} - initialPath={cloneDestination || '~'} - onSelect={(path) => { - onDestChange(path) - setBrowsingDestination(false) - }} - onCancel={() => setBrowsingDestination(false)} - /> - </> - ) - } - - return ( - <> - <DialogHeader> - <DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.c05f88a31f", "Clone from URL")}</DialogTitle> - <DialogDescription>{translate("auto.components.sidebar.AddRepoSteps.5b2ea674b1", "Enter the Git URL and choose where to clone it.")}</DialogDescription> - </DialogHeader> - - <div className="space-y-3 pt-1"> - <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoSteps.3d4acbe693", "Git URL")}</label> - <Input - value={cloneUrl} - onChange={(e) => onUrlChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.b698a4a29d", "https://github.com/user/repo.git")} - className="h-8 text-xs" - disabled={isCloning} - autoFocus - /> - </div> - - <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoSteps.04a4c4e84a", "Clone location")}</label> - <div className="flex gap-2"> - <Input - value={cloneDestination} - onChange={(e) => onDestChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.2ce3f6edf8", "/path/to/destination")} - className="h-8 text-xs flex-1" - disabled={isCloning} - /> - <Button - variant="outline" - size="sm" - className="h-8 px-2 shrink-0" - onClick={() => { - if (runtimeEnvironmentId) { - setBrowsingDestination(true) - return - } - onPickDestination() - }} - disabled={isCloning || (disableDestinationPicker && !runtimeEnvironmentId)} - title={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} - aria-label={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} - > - <Folder className="size-3.5" /> - </Button> - </div> - </div> - - {cloneError && <p className="text-[11px] text-destructive">{cloneError}</p>} - - <Button - onClick={onClone} - disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning} - className="w-full" - > - {isCloning ? translate("auto.components.sidebar.AddRepoSteps.69f5b5380d", "Cloning...") : translate("auto.components.sidebar.AddRepoSteps.32a7256d85", "Clone")} - </Button> - - {/* Why: progress bar lives below the button so it doesn't push the - button down when it appears mid-clone. */} - {isCloning && cloneProgress && ( - <div className="space-y-1.5"> - <div className="flex items-center justify-between text-[11px] text-muted-foreground"> - <span>{cloneProgress.phase}</span> - <span>{cloneProgress.percent}%</span> - </div> - <div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden"> - <div - className="h-full rounded-full bg-foreground transition-[width] duration-300 ease-out" - style={{ width: `${cloneProgress.percent}%` }} - /> - </div> - </div> - )} - </div> - </> - ) -} diff --git a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx index 9b0ae00c3f9..adca84a5230 100644 --- a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx +++ b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx @@ -7,7 +7,8 @@ import { RemoteFileBrowser } from './RemoteFileBrowser' import { translate } from '@/i18n/i18n' type CreateProjectParentBrowserProps = { - runtimeEnvironmentId: string + runtimeEnvironmentId?: string | null + sshTargetId?: string | null createParent: string onParentChange: (value: string) => void onClose: () => void @@ -15,6 +16,7 @@ type CreateProjectParentBrowserProps = { export function CreateProjectParentBrowser({ runtimeEnvironmentId, + sshTargetId, createParent, onParentChange, onClose @@ -25,7 +27,7 @@ export function CreateProjectParentBrowser({ <DialogTitle> {translate( 'auto.components.sidebar.CreateProjectLocationField.f520f83a97', - 'Browse server filesystem' + 'Browse host filesystem' )} </DialogTitle> <DialogDescription> @@ -35,15 +37,27 @@ export function CreateProjectParentBrowser({ )} </DialogDescription> </DialogHeader> - <RemoteFileBrowser - runtimeEnvironmentId={runtimeEnvironmentId} - initialPath={createParent || '~'} - onSelect={(path) => { - onParentChange(path) - onClose() - }} - onCancel={onClose} - /> + {sshTargetId ? ( + <RemoteFileBrowser + targetId={sshTargetId} + initialPath={createParent || '~'} + onSelect={(path) => { + onParentChange(path) + onClose() + }} + onCancel={onClose} + /> + ) : ( + <RemoteFileBrowser + runtimeEnvironmentId={runtimeEnvironmentId as string} + initialPath={createParent || '~'} + onSelect={(path) => { + onParentChange(path) + onClose() + }} + onCancel={onClose} + /> + )} </> ) } @@ -53,6 +67,7 @@ type CreateProjectLocationFieldProps = { isCreating: boolean manualParentEntry: boolean runtimeEnvironmentId?: string | null + sshTargetId?: string | null onParentChange: (value: string) => void onPickParent: () => void onBrowseServer: () => void @@ -63,6 +78,7 @@ export function CreateProjectLocationField({ isCreating, manualParentEntry, runtimeEnvironmentId, + sshTargetId, onParentChange, onPickParent, onBrowseServer @@ -94,10 +110,10 @@ export function CreateProjectLocationField({ size="icon" className="h-11 w-11 shrink-0" onClick={onBrowseServer} - disabled={isCreating || !runtimeEnvironmentId} + disabled={isCreating || (!runtimeEnvironmentId && !sshTargetId)} aria-label={translate( 'auto.components.sidebar.CreateProjectLocationField.f520f83a97', - 'Browse server filesystem' + 'Browse host filesystem' )} > <FolderOpen className="size-4" /> @@ -106,7 +122,7 @@ export function CreateProjectLocationField({ <TooltipContent side="top" sideOffset={4}> {translate( 'auto.components.sidebar.CreateProjectLocationField.f520f83a97', - 'Browse server filesystem' + 'Browse host filesystem' )} </TooltipContent> </Tooltip> diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts new file mode 100644 index 00000000000..30066815370 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE = readFileSync(join(__dirname, 'DeleteWorktreeDialog.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('DeleteWorktreeDialog host-context boundaries', () => { + it('preloads git status from the selected worktree owner instead of the focused host', () => { + const effect = sourceBetween( + SOURCE, + 'const statusTargets = deleteTargets.filter(', + 'return () => {' + ) + + expect(effect).toContain('getSettingsForWorktreeRuntimeOwner') + expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo') + expect(effect).toContain('item.id') + expect(effect).not.toContain('settings,\n worktreeId: item.id') + }) +}) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 64ca7e83b9b..f24c0a5ac17 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle @@ -11,13 +10,16 @@ import { useAppStore } from '@/store' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice' import { DeleteWorktreeSkipConfirmOption } from './DeleteWorktreeSkipConfirmOption' import { DeleteWorktreeDialogFooter } from './DeleteWorktreeDialogFooter' +import { DeleteWorktreeDialogDescription } from './DeleteWorktreeDialogDescription' import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview' import { DeleteWorktreeWarningPanels } from './DeleteWorktreeWarningPanels' +import { persistDeleteWorktreeConfirmSkipPreference } from './delete-worktree-preference-toast' import { countFolderWorkspaceDeletes, getDeleteWorktreeDialogCopy, @@ -188,7 +190,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { let cancelled = false for (const item of statusTargets) { void getRuntimeGitStatus({ - settings, + // Why: delete warnings inspect git state for the selected workspace; + // a later focused-host switch must not make this preload query another host. + settings: getSettingsForWorktreeRuntimeOwner( + { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo }, + item.id + ), worktreeId: item.id, worktreePath: item.path, connectionId: getConnectionId(item.id) ?? undefined @@ -206,7 +213,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { return () => { cancelled = true } - }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, setGitStatus, settings]) + }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, repos, setGitStatus, settings]) const handleOpenChange = useCallback( (open: boolean) => { @@ -232,24 +239,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { ) const persistDontAskAgainPreference = useCallback((): void => { - void updateSettings({ skipDeleteWorktreeConfirm: true }) - // Why: the toast confirms the preference was saved and points the user at - // where to undo it. The "Open Settings" action deep-links to the General - // pane so they never have to hunt for the toggle if they change their mind. - toast.success(translate("auto.components.sidebar.DeleteWorktreeDialog.dd3a45bbbd", "We'll skip this confirmation next time."), { - description: translate("auto.components.sidebar.DeleteWorktreeDialog.2b56b35f53", "You can change this in Settings."), - duration: 8000, - action: { - label: translate("auto.components.sidebar.DeleteWorktreeDialog.5cc1a6701c", "Open Settings"), - onClick: () => { - openSettingsPage() - openSettingsTarget({ - pane: 'general', - repoId: null, - sectionId: 'general-skip-delete-worktree-confirm' - }) - } - } + persistDeleteWorktreeConfirmSkipPreference({ + updateSettings, + openSettingsPage, + openSettingsTarget }) }, [openSettingsPage, openSettingsTarget, updateSettings]) @@ -282,17 +275,29 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { deletePromise .then((result) => { if (!result.ok) { - toast.error(translate("auto.components.sidebar.DeleteWorktreeDialog.42e610d6cf", "Force delete failed"), { - description: result.error - }) + toast.error( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.42e610d6cf', + 'Force delete failed' + ), + { + description: result.error + } + ) return } onDeleted?.([worktreeId]) }) .catch((err: unknown) => { - toast.error(translate("auto.components.sidebar.DeleteWorktreeDialog.4f6750ca7b", "Failed to delete workspace"), { - description: err instanceof Error ? err.message : String(err) - }) + toast.error( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.4f6750ca7b', + 'Failed to delete workspace' + ), + { + description: err instanceof Error ? err.message : String(err) + } + ) }) } else { // Why: this modal is the destructive confirmation for the workspace @@ -365,23 +370,27 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { > <DialogHeader> <DialogTitle className="text-sm"> - {isBatchDelete ? translate("auto.components.sidebar.DeleteWorktreeDialog.86f0ae1257", "Delete Workspaces") : translate("auto.components.sidebar.DeleteWorktreeDialog.fc23c4cbdf", "Delete Workspace")} + {isBatchDelete + ? translate( + 'auto.components.sidebar.DeleteWorktreeDialog.86f0ae1257', + 'Delete Workspaces' + ) + : translate( + 'auto.components.sidebar.DeleteWorktreeDialog.fc23c4cbdf', + 'Delete Workspace' + )} </DialogTitle> - <DialogDescription className="text-xs"> - {translate("auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6", "Remove")}<span className={deleteCopy.targetClassName}>{deleteCopy.targetLabel}</span> - {canDeleteAllLineage ? ( - <> - {' '} - {translate("auto.components.sidebar.DeleteWorktreeDialog.ff2a74ac0e", "and")}{' '} - <span className="font-medium text-foreground"> - {lineageDeleteCopy.childTargetLabel} - </span>{' '} - {lineageDeleteCopy.descriptionSuffix} - </> - ) : ( - <> {deleteCopy.descriptionSuffix}</> - )} - </DialogDescription> + <DeleteWorktreeDialogDescription + targetClassName={deleteCopy.targetClassName} + targetLabel={deleteCopy.targetLabel} + canDeleteAllLineage={canDeleteAllLineage} + childTargetLabel={lineageDeleteCopy.childTargetLabel} + descriptionSuffix={ + canDeleteAllLineage + ? lineageDeleteCopy.descriptionSuffix + : deleteCopy.descriptionSuffix + } + /> </DialogHeader> <DeleteWorktreeTargetPreview diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx new file mode 100644 index 00000000000..0d960e16844 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx @@ -0,0 +1,33 @@ +import { DialogDescription } from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +export function DeleteWorktreeDialogDescription({ + targetClassName, + targetLabel, + canDeleteAllLineage, + childTargetLabel, + descriptionSuffix +}: { + targetClassName: string + targetLabel: string | undefined + canDeleteAllLineage: boolean + childTargetLabel: string + descriptionSuffix: string +}): React.JSX.Element { + return ( + <DialogDescription className="text-xs"> + {translate('auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6', 'Remove')} + <span className={targetClassName}>{targetLabel}</span> + {canDeleteAllLineage ? ( + <> + {' '} + {translate('auto.components.sidebar.DeleteWorktreeDialog.ff2a74ac0e', 'and')}{' '} + <span className="font-medium text-foreground">{childTargetLabel}</span>{' '} + {descriptionSuffix} + </> + ) : ( + <> {descriptionSuffix}</> + )} + </DialogDescription> + ) +} diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx index 7d92e5057cc..eb513c698ee 100644 --- a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -13,14 +13,13 @@ import { import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace' -import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { pickQuickWorkspaceAgent, resolveQuickWorkspaceAgentSelection } from '@/lib/quick-workspace-agent-selection' import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { buildNewWorkspaceProjectOptions } from '@/lib/new-workspace-project-options' import type { GitHubWorkItem, GitLabWorkItem, @@ -32,6 +31,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { getFolderSourceRepos, + getFolderWorkspacePrimaryActionLabel, getLinkedItemDisplayName, getSmartNameSelection, toGitHubLinkedWorkItem, @@ -40,6 +40,8 @@ import { } from './folder-workspace-composer-helpers' import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { useFolderWorkspaceComposerKeyboard } from './folder-workspace-composer-keyboard' type FolderWorkspaceComposerDialogProps = { projectGroup: ProjectGroup | null @@ -71,7 +73,24 @@ export function FolderWorkspaceComposerDialog({ [projectGroup, projectGroups, repos] ) const [repoId, setRepoId] = useState('') + const projectSetupProjection = useMemo( + () => projectHostSetupProjectionFromRepos(sourceRepos), + [sourceRepos] + ) + const projectOptions = useMemo( + () => + buildNewWorkspaceProjectOptions({ + projects: projectSetupProjection.projects, + projectHostSetups: projectSetupProjection.setups, + eligibleRepos: sourceRepos + }), + [projectSetupProjection, sourceRepos] + ) const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null + const selectedProjectHostSetup = projectSetupProjection.setups.find( + (setup) => setup.repoId === repoId + ) + const selectedProjectId = selectedProjectHostSetup?.projectId ?? null const selectedRepoConnectionId = selectedRepo?.connectionId ?? (sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null) @@ -83,7 +102,7 @@ export function FolderWorkspaceComposerDialog({ connectionId: selectedRepoConnectionId, status: selectedRepoSshState?.status ?? null }) - const { detectedIds } = useDetectedAgents(null) + const { detectedIds } = useDetectedAgents(selectedRepoConnectionId) const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds]) const [name, setName] = useState('') const [note, setNote] = useState('') @@ -154,6 +173,17 @@ export function FolderWorkspaceComposerDialog({ return provider === 'github' || provider === 'gitlab' ? null : current }) }, []) + const handleProjectChange = useCallback( + (projectId: string): void => { + const setup = projectSetupProjection.setups.find( + (candidate) => candidate.projectId === projectId + ) + if (setup) { + handleRepoChange(setup.repoId) + } + }, + [handleRepoChange, projectSetupProjection] + ) const handleSmartGitHubItemSelect = useCallback( (item: GitHubWorkItem): void => { @@ -232,7 +262,6 @@ export function FolderWorkspaceComposerDialog({ quickAgent, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, agentCmdOverrides: settings?.agentCmdOverrides, - isRemote: selectedRepoConnectionId !== null, createFolderWorkspace, onOpenChange }) @@ -247,7 +276,6 @@ export function FolderWorkspaceComposerDialog({ onOpenChange, projectGroup, quickAgent, - selectedRepoConnectionId, settings?.agentCmdOverrides, settings?.autoRenameBranchFromWork, submitting, @@ -255,45 +283,13 @@ export function FolderWorkspaceComposerDialog({ selectedRepoRequiresConnection ]) - useEffect(() => { - if (!open) { - return - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' && event.key !== 'Escape') { - return - } - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - if (event.key === 'Escape') { - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - return - } - event.preventDefault() - onOpenChange(false) - return - } - if (!isScreenSubmitShortcut(event)) { - return - } - if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) { - return - } - event.preventDefault() - void handleCreate() - } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [handleCreate, onOpenChange, open, submitting]) + useFolderWorkspaceComposerKeyboard({ + open, + submitting, + composerRef, + onOpenChange, + onCreate: () => void handleCreate() + }) const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem]) const emptySourceProjectMessage = @@ -313,7 +309,7 @@ export function FolderWorkspaceComposerDialog({ event.preventDefault() const content = event.currentTarget as HTMLElement const trigger = content.querySelector<HTMLElement>( - '[data-repo-combobox-root="true"][role="combobox"]' + '[data-project-combobox-root="true"][role="combobox"]' ) trigger?.focus({ preventScroll: true }) }} @@ -335,12 +331,12 @@ export function FolderWorkspaceComposerDialog({ onQuickAgentChange={handleQuickAgentChange} eligibleRepos={sourceRepos} repoId={repoId} + projectOptions={projectOptions} + selectedProjectId={selectedProjectId} selectedRepoIsGit={true} onRepoChange={handleRepoChange} - primaryActionLabel={translate( - 'auto.components.sidebar.FolderWorkspaceComposerDialog.create', - 'Create workspace' - )} + onProjectChange={handleProjectChange} + primaryActionLabel={getFolderWorkspacePrimaryActionLabel(quickAgent)} projectLabel={translate( 'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject', 'Task Source' diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx new file mode 100644 index 00000000000..97592d73fe0 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { removeSshTargetWithBestEffortCleanup } from '../settings/ssh-target-remove' +import { clearHostRename } from './host-rename-remove' +import type { HostRemovalTarget } from './host-rename-remove' + +type HostRemoveDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + hostId: ExecutionHostId + label: string + target: NonNullable<HostRemovalTarget> +} + +export function HostRemoveDialog({ + open, + onOpenChange, + hostId, + label, + target +}: HostRemoveDialogProps): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + + // Why: dropping a host should also drop its now-orphaned label override so a + // future host reusing the same id doesn't inherit a stale rename. + const dropOverridesForHost = (): void => { + const state = useAppStore.getState() + void state.updateSettings({ + hostSettingOverrides: clearHostRename(state.settings, hostId) + }) + } + + const handleRemoveSsh = async (targetId: string): Promise<void> => { + await removeSshTargetWithBestEffortCleanup(window.api.ssh, targetId) + // Why: clear deferred reconnect metadata so focused SSH tabs stop retrying + // the deleted target — mirrors the SSH settings pane removal flow. + useAppStore.getState().clearRemovedSshTargetState(targetId) + dropOverridesForHost() + } + + // Why: runtime-environment removal needs active-environment switching and + // error context owned by the Orca servers settings pane, so we deep-link + // there with the host pre-selected instead of duplicating that flow. + const handleRemoveRuntime = (environmentId: string): void => { + const state = useAppStore.getState() + state.openSettingsTarget({ pane: 'servers', repoId: null, sectionId: environmentId }) + state.openSettingsPage() + onOpenChange(false) + } + + const confirm = async (): Promise<void> => { + if (target.kind === 'runtime') { + handleRemoveRuntime(target.environmentId) + return + } + setBusy(true) + try { + await handleRemoveSsh(target.targetId) + if (mountedRef.current) { + onOpenChange(false) + } + toast.success( + translate('auto.components.sidebar.HostRemoveDialog.1a2b3c4d5e', 'Removed {{value0}}', { + value0: label + }) + ) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.HostRemoveDialog.2b3c4d5e6f', + 'Failed to remove host' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + } + + const isRuntime = parseExecutionHostId(hostId)?.kind === 'runtime' + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {translate( + 'auto.components.sidebar.HostRemoveDialog.3c4d5e6f7a', + 'Remove {{value0}}?', + { + value0: label + } + )} + </DialogTitle> + <DialogDescription> + {isRuntime + ? translate( + 'auto.components.sidebar.HostRemoveDialog.4d5e6f7a8b', + 'This opens the Orca servers settings where you can remove this server.' + ) + : translate( + 'auto.components.sidebar.HostRemoveDialog.5e6f7a8b9c', + 'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.' + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + {translate('auto.components.sidebar.HostRemoveDialog.6f7a8b9c0d', 'Cancel')} + </Button> + <Button + type="button" + variant="destructive" + disabled={busy} + onClick={() => void confirm()} + > + {busy ? <Loader2 className="size-3.5 animate-spin" /> : null} + {isRuntime + ? translate('auto.components.sidebar.HostRemoveDialog.7a8b9c0d1e', 'Open settings') + : translate('auto.components.sidebar.HostRemoveDialog.8b9c0d1e2f', 'Remove host')} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/HostRenameDialog.tsx b/src/renderer/src/components/sidebar/HostRenameDialog.tsx new file mode 100644 index 00000000000..d9089665f43 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostRenameDialog.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { applyHostRename, getHostDisplayLabelOverride } from './host-rename-remove' + +type HostRenameDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + hostId: ExecutionHostId + /** The label the host shows by default, used as the placeholder and reset target. */ + derivedLabel: string +} + +export function HostRenameDialog({ + open, + onOpenChange, + hostId, + derivedLabel +}: HostRenameDialogProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const currentOverride = getHostDisplayLabelOverride(settings, hostId) + const [value, setValue] = useState(currentOverride ?? '') + + // Why: reseed the field from the persisted override each time the dialog opens + // so a prior cancelled edit doesn't leak into the next open. + useEffect(() => { + if (open) { + setValue(currentOverride ?? '') + } + }, [open, currentOverride]) + + const submit = (): void => { + void updateSettings({ hostSettingOverrides: applyHostRename(settings, hostId, value) }) + onOpenChange(false) + } + + const reset = (): void => { + setValue('') + void updateSettings({ hostSettingOverrides: applyHostRename(settings, hostId, '') }) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {translate('auto.components.sidebar.HostRenameDialog.1a2b3c4d5e', 'Rename host')} + </DialogTitle> + <DialogDescription> + {translate( + 'auto.components.sidebar.HostRenameDialog.2b3c4d5e6f', + 'This label is shown only on this computer. Leave it blank to use the default name.' + )} + </DialogDescription> + </DialogHeader> + <div className="space-y-2"> + <Label htmlFor="host-rename-input"> + {translate('auto.components.sidebar.HostRenameDialog.3c4d5e6f7a', 'Display name')} + </Label> + <Input + id="host-rename-input" + autoFocus + value={value} + placeholder={derivedLabel} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } + }} + /> + </div> + <DialogFooter className="sm:justify-between"> + <Button type="button" variant="ghost" disabled={!currentOverride} onClick={reset}> + {translate('auto.components.sidebar.HostRenameDialog.4d5e6f7a8b', 'Reset to default')} + </Button> + <div className="flex gap-2"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + {translate('auto.components.sidebar.HostRenameDialog.5e6f7a8b9c', 'Cancel')} + </Button> + <Button type="button" onClick={submit}> + {translate('auto.components.sidebar.HostRenameDialog.6f7a8b9c0d', 'Save')} + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx new file mode 100644 index 00000000000..fddb48137b5 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -0,0 +1,301 @@ +import React, { useCallback, useState } from 'react' +import { + AlertTriangle, + Ellipsis, + Loader2, + Pencil, + Plug, + PlugZap, + RefreshCw, + Settings2, + Trash2 +} from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' +import { + clearRuntimeCompatibilityCache, + unwrapRuntimeRpcResult +} from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { HostHeaderRow } from './host-section-rows' +import { buildHostHeaderMenuModel } from './host-header-menu-items' +import { HostRenameDialog } from './HostRenameDialog' +import { HostRemoveDialog } from './HostRemoveDialog' +import { resolveHostRemoval } from './host-rename-remove' + +function blockedTitle(reason: 'client-too-old' | 'server-too-old'): string { + return reason === 'server-too-old' + ? translate( + 'auto.components.sidebar.HostSectionHeaderMenu.5b8b4b6a01', + 'Update server required' + ) + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.9b3c1d2e44', + 'Update client required' + ) +} + +// Why: SSH and paired runtime hosts share the sidebar model, but Settings keeps +// their management pages separate so each connection type can explain itself. +function openManageHost(row: HostHeaderRow): void { + const state = useAppStore.getState() + if (row.kind === 'runtime') { + const parsed = parseExecutionHostId(row.hostId) + state.openSettingsTarget({ + pane: 'servers', + repoId: null, + sectionId: parsed?.kind === 'runtime' ? parsed.environmentId : undefined + }) + } else if (row.kind === 'ssh') { + state.openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + } else { + state.openSettingsTarget({ pane: 'general', repoId: null }) + } + state.openSettingsPage() +} + +export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JSX.Element { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [removeOpen, setRemoveOpen] = useState(false) + const mountedRef = useMountedRef() + const sshConnected = useAppStore((s) => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'ssh') { + return false + } + return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected' + }) + + const model = buildHostHeaderMenuModel({ + kind: row.kind, + health: row.health, + sshConnected, + compatibility: row.compatibility + }) + const removalTarget = resolveHostRemoval(row.hostId) + + const handleManage = useCallback(() => { + openManageHost(row) + }, [row]) + + const runSshAction = useCallback( + async (action: 'connect' | 'disconnect') => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'ssh') { + return + } + setBusy(true) + try { + await window.api.ssh[action]({ targetId: parsed.targetId }) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : action === 'connect' + ? translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2c29e2de68', + 'Connection failed' + ) + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.bf07aee59e', + 'Disconnect failed' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, + [mountedRef, row.hostId] + ) + + const handleCheckConnection = useCallback(async () => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'runtime') { + return + } + setBusy(true) + // Why: drop any cached "compatible" verdict so the re-probe re-evaluates + // version skew instead of trusting the prior pass. + clearRuntimeCompatibilityCache(parsed.environmentId) + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: parsed.environmentId, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response) + // Why: feed the probe result into the shared store so the host header and + // other host pickers reflect this check without a separate fetch. + useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { + status: runtimeStatus, + checkedAt: Date.now() + }) + toast.success( + translate( + 'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d', + '{{value0}} is reachable', + { + value0: row.label + } + ) + ) + } catch (err) { + // Why: record the failed probe so the host registry can drop a previously + // healthy verdict instead of showing stale "compatible" state. + useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { + status: null, + checkedAt: Date.now() + }) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2c29e2de68', + 'Connection failed' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, row.hostId, row.label]) + + return ( + <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-xs" + type="button" + className="size-5 shrink-0 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100" + aria-label={translate( + 'auto.components.sidebar.HostSectionHeaderMenu.4f2c8a9b10', + 'Host actions for {{value0}}', + { value0: row.label } + )} + // Why: the host header row itself toggles collapse on click; + // opening the menu must not also fold the section. + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {busy ? ( + <Loader2 className="size-3.5 animate-spin" /> + ) : ( + <Ellipsis className="size-3.5" /> + )} + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={6}> + {translate('auto.components.sidebar.HostSectionHeaderMenu.6b7c8d9e10', 'Host actions')} + </TooltipContent> + </Tooltip> + <DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56"> + {model.blocked && ( + <> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuItem + className="text-destructive focus:text-destructive" + onSelect={() => openManageHost(row)} + > + <AlertTriangle className="size-3.5" /> + {blockedTitle(model.blocked.reason)} + </DropdownMenuItem> + </TooltipTrigger> + <TooltipContent side="right" sideOffset={6} className="max-w-72"> + {row.compatibility ? describeRuntimeCompatBlock(row.compatibility) : null} + </TooltipContent> + </Tooltip> + <DropdownMenuSeparator /> + </> + )} + <DropdownMenuLabel className="truncate text-[11px] font-medium text-muted-foreground"> + {row.label} + </DropdownMenuLabel> + {model.actions.includes('rename') && ( + <DropdownMenuItem onSelect={() => setRenameOpen(true)}> + <Pencil className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.8d1e2f3a4b', 'Rename…')} + </DropdownMenuItem> + )} + {model.actions.includes('ssh-reconnect') && ( + <DropdownMenuItem onSelect={() => void runSshAction('connect')}> + <Plug className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')} + </DropdownMenuItem> + )} + {model.actions.includes('ssh-disconnect') && ( + <DropdownMenuItem onSelect={() => void runSshAction('disconnect')}> + <PlugZap className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.59b553e2aa', 'Disconnect')} + </DropdownMenuItem> + )} + {model.actions.includes('runtime-check-connection') && ( + <DropdownMenuItem onSelect={() => void handleCheckConnection()}> + <RefreshCw className="size-3.5" /> + {translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2d3e4f5a6b', + 'Check connection' + )} + </DropdownMenuItem> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleManage}> + <Settings2 className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.3c4d5e6f7a', 'Manage host…')} + </DropdownMenuItem> + {model.actions.includes('remove') && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive focus:text-destructive" + onSelect={() => setRemoveOpen(true)} + > + <Trash2 className="size-3.5" /> + {translate( + 'auto.components.sidebar.HostSectionHeaderMenu.6e7f8a9b0c', + 'Remove host…' + )} + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + <HostRenameDialog + open={renameOpen} + onOpenChange={setRenameOpen} + hostId={row.hostId} + derivedLabel={row.label} + /> + {removalTarget && ( + <HostRemoveDialog + open={removeOpen} + onOpenChange={setRemoveOpen} + hostId={row.hostId} + label={row.label} + target={removalTarget} + /> + )} + </DropdownMenu> + ) +} diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 621f89c0d67..6eeb461be7a 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -81,6 +81,19 @@ vi.mock('../settings/AgentSkillSetupPanel', () => ({ let root: Root | null = null let container: HTMLDivElement | null = null +function installLocalStorageShim(): void { + const values = new Map<string, string>() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + function cliStatus(overrides: Partial<CliInstallStatus>): CliInstallStatus { return { platform: 'darwin', @@ -177,6 +190,7 @@ describe('LinearAgentSkillSetupPrompt', () => { mocks.ensureCli.mockClear() mocks.ensureWslCli.mockClear() mocks.panelProps.length = 0 + installLocalStorageShim() window.localStorage.clear() _linearAgentSkillSetupPromptInternalsForTests.resetSessionReminders() Object.defineProperty(window, 'api', { diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 1e9ef329da3..d65cd747604 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -73,7 +73,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { ? err.message : translate( 'auto.components.sidebar.NonGitFolderDialog.c49fb13492', - 'Failed to add remote folder' + 'Failed to add folder on this host' ) ) } diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index 2abaebe4365..f05809ce11a 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -748,7 +748,7 @@ export function RemoteFileBrowser({ ? FILE_HINT_TEXT : translate( 'auto.components.sidebar.RemoteFileBrowser.971d85cc84', - 'Opens as a remote project · {{value0}}', + 'Opens as a project on this host · {{value0}}', { value0: resolvedPath } )} </p> diff --git a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx index 0ac1b3241b4..bddeeb43843 100644 --- a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx +++ b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx @@ -20,7 +20,7 @@ export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element { onClick={requestScrollToCurrentWorkspaceReveal} className="text-muted-foreground" > - <Crosshair className="size-3.5" /> + <Crosshair className="size-4" /> </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> diff --git a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx index 0a9c63dfa73..9ccce4fbe65 100644 --- a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx +++ b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx @@ -1,6 +1,10 @@ +// @vitest-environment happy-dom + import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureWallSetupProgress } from '../feature-wall/feature-wall-setup-progress' import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' @@ -92,7 +96,35 @@ function makeOnlyBrowserIncompleteProgress(): FeatureWallSetupProgress { }) } +const mountedRoots: Root[] = [] + +async function renderSetupGuideSidebarEntry(): Promise<{ + container: HTMLDivElement + rerender: () => Promise<void> +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const rerender = async (): Promise<void> => { + await act(async () => { + root.render(<SetupGuideSidebarEntry />) + }) + } + await rerender() + return { container, rerender } +} + describe('SetupGuideSidebarEntry', () => { + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + beforeEach(() => { persistedUIReady = true activeModal = 'none' @@ -151,4 +183,20 @@ describe('SetupGuideSidebarEntry', () => { it('renders after persisted UI and setup progress are ready when setup is incomplete', () => { expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).toContain('Onboarding checklist') }) + + it('keeps the visible entry mounted during transient setup progress refreshes', async () => { + const { container, rerender } = await renderSetupGuideSidebarEntry() + + expect(container.textContent).toContain('Onboarding checklist') + + mocks.useSetupGuideProgress.mockReturnValue(makeProgress({ ready: false })) + await rerender() + + expect(container.textContent).toContain('Onboarding checklist') + + mocks.useSetupGuideProgress.mockReturnValue(makeAllDoneProgress()) + await rerender() + + expect(container.textContent).not.toContain('Onboarding checklist') + }) }) diff --git a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx index 7cbaf215812..08e1d2ae95c 100644 --- a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx +++ b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx @@ -49,22 +49,31 @@ export function SetupGuideSidebarEntry(): React.JSX.Element | null { const setupProgress = useSetupGuideProgress(true, false, false) const setupComplete = isSetupGuideSidebarComplete(setupProgress) const setupActive = activeModal === 'setup-guide' - const firstUnfinishedSetupStepId = React.useMemo<FeatureWallSetupStepId>( - () => getFirstIncompleteFeatureWallSetupStepId(setupProgress.stepDone), - [setupProgress.stepDone] - ) const showSetupGuideEntry = shouldShowSetupGuideEntry({ ready: getSetupGuideSidebarEntryReady(persistedUIReady, setupProgress.ready), setupComplete, dismissed: setupGuideSidebarDismissed }) + const lastVisibleProgressRef = React.useRef<FeatureWallSetupProgress | null>(null) + if (showSetupGuideEntry) { + lastVisibleProgressRef.current = setupProgress + } + // Why: host/workspace switches can briefly refresh setup probes. Once the + // checklist is visibly available, keep that stable row through the refresh. + const renderedProgress = showSetupGuideEntry + ? setupProgress + : !setupProgress.ready && !setupGuideSidebarDismissed + ? lastVisibleProgressRef.current + : null const handleHideSetupGuide = React.useCallback(() => { setSetupGuideSidebarDismissed(true) }, [setSetupGuideSidebarDismissed]) - if (!showSetupGuideEntry) { + if (!renderedProgress) { return null } + const firstUnfinishedSetupStepId: FeatureWallSetupStepId = + getFirstIncompleteFeatureWallSetupStepId(renderedProgress.stepDone) return ( <ContextMenu> @@ -87,8 +96,8 @@ export function SetupGuideSidebarEntry(): React.JSX.Element | null { )} > <SetupGuideProgressRing - done={setupProgress.coreDoneCount} - total={setupProgress.coreTotal} + done={renderedProgress.coreDoneCount} + total={renderedProgress.coreTotal} sizeClassName="size-4" /> <span className="flex min-w-0 flex-1 flex-col"> diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts new file mode 100644 index 00000000000..35d8380ac45 --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getRenderedSetupScriptPromptState } from './setup-script-prompt-render-state' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' + +function prompt(repoId: string): SetupScriptPromptInspection { + return { + status: 'ok', + repoId, + hasEffectiveSetup: false, + hasSharedHooks: false, + candidate: null + } +} + +describe('getRenderedSetupScriptPromptState', () => { + it('uses the current inspection when it belongs to the active repo', () => { + const current = prompt('repo-local') + + expect( + getRenderedSetupScriptPromptState({ + promptState: current, + activeRepoId: 'repo-local', + activeProjectId: 'github:stablyai/orca', + lastVisiblePrompt: { state: prompt('repo-ssh'), projectId: 'github:stablyai/orca' } + }) + ).toBe(current) + }) + + it('keeps the previous visible prompt during same-project host inspection refresh', () => { + const previous = prompt('repo-local') + + expect( + getRenderedSetupScriptPromptState({ + promptState: null, + activeRepoId: 'repo-ssh', + activeProjectId: 'github:stablyai/orca', + lastVisiblePrompt: { state: previous, projectId: 'github:stablyai/orca' } + }) + ).toBe(previous) + }) + + it('does not keep a stale prompt when switching to a different project', () => { + expect( + getRenderedSetupScriptPromptState({ + promptState: null, + activeRepoId: 'repo-other', + activeProjectId: 'github:stablyai/other', + lastVisiblePrompt: { state: prompt('repo-local'), projectId: 'github:stablyai/orca' } + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index f1a4604d130..cfec68165f5 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { track } from '@/lib/telemetry' -import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets' -import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { useMountedRef } from '@/hooks/useMountedRef' import { buildImportedHookSettings, @@ -17,57 +15,25 @@ import { import { checkRuntimeHooks, inspectRuntimeSetupScriptImports } from '@/runtime/runtime-hooks-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { SetupScriptImportCandidate } from '../../../../shared/setup-script-imports' +import { buildSetupScriptPromptActionTelemetry } from '../../../../shared/setup-script-telemetry' +import { SetupScriptPromptCardShell } from './SetupScriptPromptCardShell' +import { showSavedInProjectSettingsToast } from './SetupScriptPromptToast' +import { openSetupScriptSettings } from './open-setup-script-settings' +import { trackSetupScriptPromptExposure } from './setup-script-prompt-exposure-telemetry' import { - buildSetupScriptPromptActionTelemetry, - buildSetupScriptPromptTelemetry -} from '../../../../shared/setup-script-telemetry' -import { - ConfigureOnlyAction, - DetectedSetupPreview, - DismissButton, - InspectionErrorActions, - PackageManagerActions, - SaveLocalSetupAction, - SetupScriptPromptBody -} from './SetupScriptPromptCardViews' + getRenderedSetupScriptPromptState, + getRepoProjectId, + type LastVisibleSetupScriptPrompt, + useSetupScriptPromptProjectContext +} from './setup-script-prompt-render-state' import { translate } from '@/i18n/i18n' type PromptState = SetupScriptPromptInspection -type SavedInProjectSettingsToastProps = { - onOpenSettings: () => void -} - -function SavedInProjectSettingsToast({ - onOpenSettings -}: SavedInProjectSettingsToastProps): React.JSX.Element { - return ( - <span> - {translate("auto.components.sidebar.SetupScriptPromptCard.a5bb8c5135", "Saved in this")}{' '} - <button - type="button" - className="rounded-sm font-medium underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - onClick={onOpenSettings} - > - {translate("auto.components.sidebar.SetupScriptPromptCard.d9f2db2738", "project's settings")}</button> - </span> - ) -} - -function showSavedInProjectSettingsToast(input: { - onOpenSettings: () => void - description?: React.ReactNode -}): void { - // Why: the save confirmation is also the fastest path back to the exact - // local setup editor the user just changed. - toast.success(<SavedInProjectSettingsToast onOpenSettings={input.onOpenSettings} />, { - description: input.description - }) -} - function SetupScriptPromptCard(): React.JSX.Element | null { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const repos = useAppStore((s) => s.repos) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const updateRepo = useAppStore((s) => s.updateRepo) @@ -87,9 +53,15 @@ function SetupScriptPromptCard(): React.JSX.Element | null { () => repos.find((repo) => repo.id === activeRepoId) ?? null, [activeRepoId, repos] ) + const { activeProjectId, setupByRepoId } = useSetupScriptPromptProjectContext( + activeRepo, + repos, + projectHostSetups + ) const isDismissed = activeRepo ? isSetupScriptPromptDismissed(activeRepo.id, dismissedRepoIds) : false + const lastVisiblePromptRef = useRef<LastVisibleSetupScriptPrompt | null>(null) useEffect(() => { if (!sidebarOpen || !activeRepo || !isGitRepoKind(activeRepo) || isDismissed) { @@ -127,15 +99,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const openLocalCommandSettings = useCallback( (repoId: string) => { - // Why: imported setup commands are local repo settings; a stale Settings - // search should not hide the exact editor this action opens. - setSettingsSearchQuery('') - openSettingsTarget({ - pane: 'repo', + openSetupScriptSettings({ repoId, - sectionId: getRepositoryLocalCommandsSectionId(repoId) + setSettingsSearchQuery, + openSettingsTarget, + openSettingsPage }) - openSettingsPage() }, [openSettingsPage, openSettingsTarget, setSettingsSearchQuery] ) @@ -157,26 +126,11 @@ function SetupScriptPromptCard(): React.JSX.Element | null { return } - const telemetry = buildSetupScriptPromptTelemetry({ - candidate: promptState.candidate, - hasSharedHooks: promptState.hasSharedHooks + trackSetupScriptPromptExposure({ + repoId: activeRepo.id, + promptState, + trackedPromptKeys: trackedPromptKeysRef.current }) - // Why: React may re-render the sidebar often; this event should represent - // a distinct prompt exposure for this repo/source, not render churn. - const promptKey = [ - activeRepo.id, - telemetry.mode, - telemetry.provider ?? 'none', - telemetry.file_count_bucket, - telemetry.unsupported_field_count_bucket, - String(telemetry.has_shared_hooks) - ].join(':') - if (trackedPromptKeysRef.current.has(promptKey)) { - return - } - - trackedPromptKeysRef.current.add(promptKey) - track('setup_script_prompt_shown', telemetry) }, [activeRepo, isDismissed, promptState, sidebarOpen]) const handleConfigure = useCallback(() => { @@ -250,7 +204,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) if (mountedRef.current) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.888b83bf78", "Failed to save setup script")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.888b83bf78', + 'Failed to save setup script' + ) + ) } return } @@ -267,9 +226,6 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) if (actionPrefix === 'save_detected_setup') { - // Why: the user has already reviewed the detected script in the - // card; after saving, close the prompt instead of showing a second - // confirmation panel. if (mountedRef.current) { setPromptState((current) => current?.repoId === activeRepo.id && current.status === 'ok' @@ -278,7 +234,10 @@ function SetupScriptPromptCard(): React.JSX.Element | null { ) showSavedInProjectSettingsToast({ onOpenSettings: () => openLocalCommandSettings(importedRepoId), - description: translate("auto.components.sidebar.SetupScriptPromptCard.a49196d538", "Runs when Orca creates a new worktree.") + description: translate( + 'auto.components.sidebar.SetupScriptPromptCard.a49196d538', + 'Runs when Orca creates a new worktree.' + ) }) } return @@ -313,7 +272,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { ) console.warn('[setup-script-prompt] Failed to save setup script:', error) if (mountedRef.current) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.888b83bf78", "Failed to save setup script")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.888b83bf78', + 'Failed to save setup script' + ) + ) } } finally { if (mountedRef.current) { @@ -339,7 +303,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { } : promptState.candidate if (!candidate.setup) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.70715947fb", "Setup script cannot be empty")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.70715947fb', + 'Setup script cannot be empty' + ) + ) return } if (actionPrefix === 'save_detected_setup') { @@ -362,75 +331,72 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }, [activeRepo, detectedSetupDraft, promptState, saveSetupCandidate]) if (!sidebarOpen || !activeRepo || !isGitRepoKind(activeRepo) || isDismissed) { + lastVisiblePromptRef.current = null + return null + } + + const promptProjectId = promptState?.repoId + ? getRepoProjectId(promptState.repoId, repos, projectHostSetups, setupByRepoId) + : null + const renderedPromptState = + activeRepo && + getRenderedSetupScriptPromptState({ + promptState, + activeRepoId: activeRepo.id, + activeProjectId, + lastVisiblePrompt: lastVisiblePromptRef.current + }) + + if ( + !renderedPromptState || + (renderedPromptState.status === 'ok' && renderedPromptState.hasEffectiveSetup) + ) { + if (renderedPromptState?.status === 'ok' && renderedPromptState.hasEffectiveSetup) { + lastVisiblePromptRef.current = null + } return null } if ( - promptState?.repoId !== activeRepo.id || - (promptState.status === 'ok' && promptState.hasEffectiveSetup) + renderedPromptState.status === 'ok' && + !renderedPromptState.hasEffectiveSetup && + (renderedPromptState.repoId === activeRepo.id || promptProjectId === activeProjectId) ) { - return null + lastVisiblePromptRef.current = { + state: renderedPromptState, + projectId: activeProjectId + } } - const isInspectionError = promptState.status === 'error' - const candidate = promptState.status === 'ok' ? promptState.candidate : null + const isInspectionError = renderedPromptState.status === 'error' + const candidate = renderedPromptState.status === 'ok' ? renderedPromptState.candidate : null const isPackageManagerSuggestion = candidate?.provider === 'package-manager' const sharedSetupIgnored = - promptState.status === 'ok' && candidate === null && ignoresSharedSetupScripts(activeRepo) + renderedPromptState.status === 'ok' && + candidate === null && + ignoresSharedSetupScripts(activeRepo) const candidateSource = candidate ? formatCandidateSource(candidate) : null const candidateProvenance = candidate ? formatCandidateProvenance(candidate) : null return ( - // Why: shrink-0 keeps the card from being squeezed by a long worktree list - // in the overflow-hidden sidebar column, which clipped its top edge. - <div className="shrink-0 px-3 pb-2"> - <div className="setup-script-prompt-card rounded-lg border border-worktree-sidebar-border p-3 text-worktree-sidebar-accent-foreground shadow-xs"> - <div className="flex items-center justify-between gap-2"> - <p className="text-sm font-semibold leading-snug">{translate("auto.components.sidebar.SetupScriptPromptCard.ff1e819a11", "Add a setup script")}</p> - <DismissButton onDismiss={handleDismiss} /> - </div> - - {/* Why: name the repo on its own line so the prompt's project is clear in - every body variant, not just the default one. */} - <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> - <RepoBadgeMark color={activeRepo.badgeColor} /> - <span className="truncate font-medium text-foreground">{activeRepo.displayName}</span> - </p> - - <p className="mt-1 text-xs leading-snug text-muted-foreground"> - <SetupScriptPromptBody - isInspectionError={isInspectionError} - sharedSetupIgnored={sharedSetupIgnored} - isPackageManagerSuggestion={Boolean(isPackageManagerSuggestion && candidate)} - candidateSource={candidateSource} - /> - </p> - - {!isInspectionError && !sharedSetupIgnored && candidate && isPackageManagerSuggestion ? ( - <DetectedSetupPreview - setup={detectedSetupDraft} - onSetupChange={setDetectedSetupDraft} - provenance={candidateProvenance} - /> - ) : null} - - {isInspectionError ? ( - <InspectionErrorActions onRetry={handleRetryInspection} onConfigure={handleConfigure} /> - ) : sharedSetupIgnored ? ( - <ConfigureOnlyAction onConfigure={handleConfigure} /> - ) : candidate && isPackageManagerSuggestion ? ( - <PackageManagerActions - isSaving={isImporting} - onSave={() => void handleImport()} - onConfigure={handleConfigure} - /> - ) : candidate ? ( - <SaveLocalSetupAction isSaving={isImporting} onSave={() => void handleImport()} /> - ) : promptState.status === "ok" ? ( - <ConfigureOnlyAction onConfigure={handleConfigure} /> - ) : null} - </div> - </div> + <SetupScriptPromptCardShell + repoBadgeColor={activeRepo.badgeColor} + repoDisplayName={activeRepo.displayName} + isInspectionError={isInspectionError} + sharedSetupIgnored={sharedSetupIgnored} + isPackageManagerSuggestion={Boolean(isPackageManagerSuggestion && candidate)} + hasCandidate={Boolean(candidate)} + candidateSource={candidateSource} + candidateProvenance={candidateProvenance} + detectedSetupDraft={detectedSetupDraft} + isImporting={isImporting} + renderedStateOk={renderedPromptState.status === 'ok'} + onDismiss={handleDismiss} + onRetryInspection={handleRetryInspection} + onConfigure={handleConfigure} + onImport={() => void handleImport()} + onSetupDraftChange={setDetectedSetupDraft} + /> ) } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx new file mode 100644 index 00000000000..062730e7f4e --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx @@ -0,0 +1,103 @@ +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { + ConfigureOnlyAction, + DetectedSetupPreview, + DismissButton, + InspectionErrorActions, + PackageManagerActions, + SaveLocalSetupAction, + SetupScriptPromptBody +} from './SetupScriptPromptCardViews' +import { translate } from '@/i18n/i18n' + +type SetupScriptPromptCardShellProps = { + repoBadgeColor: string + repoDisplayName: string + isInspectionError: boolean + sharedSetupIgnored: boolean + isPackageManagerSuggestion: boolean + hasCandidate: boolean + candidateSource: string | null + candidateProvenance: string | null + detectedSetupDraft: string + isImporting: boolean + renderedStateOk: boolean + onDismiss: () => void + onRetryInspection: () => void + onConfigure: () => void + onImport: () => void + onSetupDraftChange: (value: string) => void +} + +export function SetupScriptPromptCardShell({ + repoBadgeColor, + repoDisplayName, + isInspectionError, + sharedSetupIgnored, + isPackageManagerSuggestion, + hasCandidate, + candidateSource, + candidateProvenance, + detectedSetupDraft, + isImporting, + renderedStateOk, + onDismiss, + onRetryInspection, + onConfigure, + onImport, + onSetupDraftChange +}: SetupScriptPromptCardShellProps): React.JSX.Element { + return ( + <div className="shrink-0 px-3 pb-2"> + <div className="setup-script-prompt-card rounded-lg border border-worktree-sidebar-border p-3 text-worktree-sidebar-accent-foreground shadow-xs"> + <div className="flex items-center justify-between gap-2"> + <p className="text-sm font-semibold leading-snug"> + {translate( + 'auto.components.sidebar.SetupScriptPromptCard.ff1e819a11', + 'Add a setup script' + )} + </p> + <DismissButton onDismiss={onDismiss} /> + </div> + + <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> + <RepoBadgeMark color={repoBadgeColor} /> + <span className="truncate font-medium text-foreground">{repoDisplayName}</span> + </p> + + <p className="mt-1 text-xs leading-snug text-muted-foreground"> + <SetupScriptPromptBody + isInspectionError={isInspectionError} + sharedSetupIgnored={sharedSetupIgnored} + isPackageManagerSuggestion={isPackageManagerSuggestion} + candidateSource={candidateSource} + /> + </p> + + {!isInspectionError && !sharedSetupIgnored && hasCandidate && isPackageManagerSuggestion ? ( + <DetectedSetupPreview + setup={detectedSetupDraft} + onSetupChange={onSetupDraftChange} + provenance={candidateProvenance} + /> + ) : null} + + {isInspectionError ? ( + <InspectionErrorActions onRetry={onRetryInspection} onConfigure={onConfigure} /> + ) : sharedSetupIgnored ? ( + <ConfigureOnlyAction onConfigure={onConfigure} /> + ) : hasCandidate && isPackageManagerSuggestion ? ( + <PackageManagerActions + isSaving={isImporting} + onSave={onImport} + onConfigure={onConfigure} + /> + ) : hasCandidate ? ( + <SaveLocalSetupAction isSaving={isImporting} onSave={onImport} /> + ) : renderedStateOk ? ( + <ConfigureOnlyAction onConfigure={onConfigure} /> + ) : null} + </div> + </div> + ) +} diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx new file mode 100644 index 00000000000..2cbf7bd5e12 --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +type SavedInProjectSettingsToastProps = { + onOpenSettings: () => void +} + +function SavedInProjectSettingsToast({ + onOpenSettings +}: SavedInProjectSettingsToastProps): React.JSX.Element { + return ( + <span> + {translate('auto.components.sidebar.SetupScriptPromptCard.a5bb8c5135', 'Saved in this')}{' '} + <button + type="button" + className="rounded-sm font-medium underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + onClick={onOpenSettings} + > + {translate( + 'auto.components.sidebar.SetupScriptPromptCard.d9f2db2738', + "project's settings" + )} + </button> + </span> + ) +} + +export function showSavedInProjectSettingsToast(input: { + onOpenSettings: () => void + description?: React.ReactNode +}): void { + // Why: the save confirmation is also the fastest path back to the exact + // local setup editor the user just changed. + toast.success(<SavedInProjectSettingsToast onOpenSettings={input.onOpenSettings} />, { + description: input.description + }) +} diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx new file mode 100644 index 00000000000..657a2c91df0 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx @@ -0,0 +1,155 @@ +import type React from 'react' +import { + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { ALL_EXECUTION_HOSTS_SCOPE, type ExecutionHostId } from '../../../../shared/execution-host' +import type { VisibleWorkspaceHostIds, WorkspaceHostScope } from '../../../../shared/types' +import { getSidebarHostHealthLabel, type SidebarHostOption } from './sidebar-host-options' +import { translate } from '@/i18n/i18n' + +type SidebarHostScopeMenuSectionProps = { + hostOptionsCount: number + hostVisibilityLabel: string + hostOptions: readonly SidebarHostOption[] + preserveWorkspaceBoardOpen: boolean + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void +} + +function getHostMetadata(host: SidebarHostOption): string { + const healthLabel = getSidebarHostHealthLabel(host.health) + if (host.kind === 'local') { + return host.detail + } + if (host.kind === 'ssh') { + const presenceLabel = + host.presence === 'configured' + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.configuredSshHost', + 'Configured SSH' + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.projectSshHost', + 'Project SSH' + ) + return `${presenceLabel} · ${healthLabel}` + } + const presenceLabel = + host.presence === 'active' + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.activeRuntimeHost', + 'Active server' + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.projectRuntimeHost', + 'Project server' + ) + return `${presenceLabel} · ${healthLabel}` +} + +export function SidebarHostScopeMenuSection({ + hostOptionsCount, + hostVisibilityLabel, + hostOptions, + preserveWorkspaceBoardOpen, + setWorkspaceHostScope, + visibleWorkspaceHostIds, + setVisibleWorkspaceHostIds +}: SidebarHostScopeMenuSectionProps): React.JSX.Element { + const allVisible = !visibleWorkspaceHostIds + const visibleHostIdSet = new Set(visibleWorkspaceHostIds ?? []) + + const toggleAllHosts = (): void => { + if (!allVisible) { + setWorkspaceHostScope(ALL_EXECUTION_HOSTS_SCOPE) + return + } + const firstHost = hostOptions[0] + if (firstHost) { + setVisibleWorkspaceHostIds([firstHost.id]) + } + } + + const toggleHost = (hostId: ExecutionHostId): void => { + if (allVisible) { + setVisibleWorkspaceHostIds([hostId]) + return + } + const next = new Set(visibleHostIdSet) + if (next.has(hostId)) { + if (next.size <= 1) { + return + } + next.delete(hostId) + } else { + next.add(hostId) + } + setVisibleWorkspaceHostIds(next.size === hostOptions.length ? null : [...next]) + } + + return ( + <> + <DropdownMenuLabel> + {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.hosts', 'Hosts')} + </DropdownMenuLabel> + <DropdownMenuSub> + <DropdownMenuSubTrigger> + <span className="flex flex-1 items-center justify-between gap-3"> + <span className="min-w-0 truncate">{hostVisibilityLabel}</span> + <span className="text-[11px] font-medium text-muted-foreground"> + {hostOptionsCount} + </span> + </span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent + className="w-56" + data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} + > + <DropdownMenuCheckboxItem + checked={allVisible} + onCheckedChange={toggleAllHosts} + onSelect={(e) => e.preventDefault()} + className="min-h-11 items-start py-1.5" + > + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate"> + {translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts')} + </span> + <span className="truncate text-[11px] font-normal text-muted-foreground"> + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.allHostsDetail', + 'Show every host' + )} + </span> + </span> + </DropdownMenuCheckboxItem> + {hostOptions.map((host) => ( + <DropdownMenuCheckboxItem + key={host.id} + checked={visibleHostIdSet.has(host.id)} + disabled={!allVisible && visibleHostIdSet.has(host.id) && visibleHostIdSet.size <= 1} + onCheckedChange={() => toggleHost(host.id)} + onSelect={(e) => e.preventDefault()} + className="min-h-11 items-start py-1.5" + > + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate">{host.label}</span> + <span className="text-[11px] font-normal text-muted-foreground"> + {getHostMetadata(host)} + </span> + </span> + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + + <DropdownMenuSeparator /> + </> + ) +} diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx new file mode 100644 index 00000000000..82e81f48c65 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { AlertTriangle, Loader2, X } from 'lucide-react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { + getSidebarHostVisibilityLabel, + shouldShowHostScopeControls, + type SidebarHostScopeOption +} from './sidebar-host-options' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { translate } from '@/i18n/i18n' + +function HostScopeWarningIcon({ health }: { health: SidebarHostScopeOption['health'] }) { + // Why: the banner stays quiet unless the scoped host needs attention. + if (health === 'connecting') { + return <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + } + if (health === 'blocked' || health === 'error') { + return <AlertTriangle className="size-3 shrink-0 text-destructive" /> + } + return null +} + +/** Shown only when the sidebar is scoped to a single host: names the scope and + * offers the way back. In All-hosts view the host section headers tell the + * story, so no persistent strip renders; scope switching lives in the + * workspace options menu. */ +const SidebarHostScopeStrip = React.memo(function SidebarHostScopeStrip() { + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) + const { hostOptions, hostScopeOptions } = useSidebarHostScopeOptions() + + if (!visibleWorkspaceHostIds) { + return null + } + if (!shouldShowHostScopeControls(hostOptions)) { + return null + } + + const label = getSidebarHostVisibilityLabel(visibleWorkspaceHostIds, hostOptions) + const selectedScope = + visibleWorkspaceHostIds.length === 1 + ? hostScopeOptions.find((option) => option.id === visibleWorkspaceHostIds[0]) + : undefined + + return ( + <div className="px-2 pb-1"> + <div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-sidebar-border/70 bg-sidebar-accent/35 pl-2 pr-1"> + <span className="flex min-w-0 items-center gap-1.5"> + <HostScopeWarningIcon health={selectedScope?.health ?? 'available'} /> + <span className="truncate text-xs font-medium text-sidebar-foreground"> + {translate( + 'auto.components.sidebar.SidebarHostScopeStrip.scopedTo', + '{{value0}} visible', + { + value0: label + } + )} + </span> + </span> + <Button + variant="ghost" + size="sm" + className="h-5 shrink-0 gap-1 rounded px-1.5 text-[11px] font-normal text-muted-foreground hover:text-foreground" + onClick={() => setVisibleWorkspaceHostIds(null)} + > + <X className="size-3" /> + {translate('auto.components.sidebar.SidebarHostScopeStrip.backToAll', 'All hosts')} + </Button> + </div> + </div> + ) +}) + +export default SidebarHostScopeStrip diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 208a747ebe2..18027cbbcae 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -21,13 +21,9 @@ import type { AgentActivityDisplayMode } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection' import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' -import { translate } from '@/i18n/i18n' - -type SidebarWorkspaceOptionsMenuProps = { - preserveWorkspaceBoardOpen?: boolean - onMenuOpenChange?: (open: boolean) => void -} - +import { getSidebarHostVisibilityLabel, shouldShowHostScopeControls } from './sidebar-host-options' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { SidebarHostScopeMenuSection } from './SidebarHostScopeMenuSection' import { AGENT_ACTIVITY_DISPLAY_OPTIONS, CARD_LAYOUT_OPTIONS, @@ -35,7 +31,13 @@ import { PROJECT_ORDER_OPTIONS, PROPERTY_OPTIONS, SORT_OPTIONS -} from './sidebar-workspace-options-menu-options' +} from './sidebar-workspace-option-items' +import { translate } from '@/i18n/i18n' + +type SidebarWorkspaceOptionsMenuProps = { + preserveWorkspaceBoardOpen?: boolean + onMenuOpenChange?: (open: boolean) => void +} const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({ preserveWorkspaceBoardOpen = false, @@ -49,6 +51,9 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty) const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode) const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode) const sortBy = useAppStore((s) => s.sortBy) @@ -59,6 +64,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const setProjectOrderBy = useAppStore((s) => s.setProjectOrderBy) const [open, setOpen] = useState(false) + const { hostOptions } = useSidebarHostScopeOptions() + const showHostScopeControls = shouldShowHostScopeControls(hostOptions) const handleOpenChange = useCallback( (next: boolean) => { @@ -81,13 +88,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM }, [repos, filterRepoIds]) const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES - const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter + const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null + const hasAnyFilter = + hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = - (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + + (hideDefaultBranchWorkspace ? 1 : 0) + + (hasHostVisibilityFilter ? 1 : 0) + + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort' const projectOrderLabel = PROJECT_ORDER_OPTIONS.find((opt) => opt.id === projectOrderBy)?.label ?? 'Manual' + const hostVisibilityLabel = getSidebarHostVisibilityLabel(visibleWorkspaceHostIds, hostOptions) const cardLayout = settings?.compactWorktreeCards ? 'compact' : 'detailed' const cardLayoutLabel = CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed' @@ -153,6 +166,18 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM className="w-72 pb-2" data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} > + {showHostScopeControls && ( + <SidebarHostScopeMenuSection + hostOptionsCount={hostOptions.length} + hostVisibilityLabel={hostVisibilityLabel} + hostOptions={hostOptions} + preserveWorkspaceBoardOpen={preserveWorkspaceBoardOpen} + setWorkspaceHostScope={setWorkspaceHostScope} + visibleWorkspaceHostIds={visibleWorkspaceHostIds} + setVisibleWorkspaceHostIds={setVisibleWorkspaceHostIds} + /> + )} + <DropdownMenuLabel> {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc', 'Group by')} </DropdownMenuLabel> diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index 21fa58d026d..fd4a460e29a 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -27,7 +27,7 @@ const STATUS_MESSAGES: Partial<Record<SshConnectionStatus, string>> = { get disconnected() { return translate( 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This remote repository is not connected.' + 'This SSH host is not connected.' ) }, get reconnecting() { @@ -108,7 +108,7 @@ export function SshDisconnectedDialog({ STATUS_MESSAGES.disconnected ?? translate( 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This remote repository is not connected.' + 'This SSH host is not connected.' ) const message = isConnecting ? reconnectingMessage diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 77a3bf955dc..3ce6db39e76 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -1,5 +1,5 @@ /** - * Row used in the "Open remote project" step to pick an SSH target. + * Row used in the "Open project on SSH host" step to pick an SSH target. * * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit * while isolating the inline-connect interaction logic. diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index f2a37dd13d0..d3568375d4e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -10,7 +10,6 @@ const fetchIssue = vi.fn() const fetchLinearIssue = vi.fn() const openModal = vi.fn() const updateWorktreeMeta = vi.fn() -const linearPromptProps: { remote?: boolean; settings?: unknown }[] = [] let worktreeCardProperties: WorktreeCardProperty[] = ['pr'] let hostedReviewCache: Record<string, unknown> = {} @@ -66,13 +65,6 @@ vi.mock('./SshDisconnectedDialog', () => ({ SshDisconnectedDialog: () => null })) -vi.mock('./LinearAgentSkillSetupPrompt', () => ({ - LinearAgentSkillSetupPrompt: (props: { remote?: boolean; settings?: unknown }) => { - linearPromptProps.push(props) - return null - } -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</>, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', @@ -136,7 +128,6 @@ describe('WorktreeCard linked PR display', () => { vi.clearAllMocks() worktreeCardProperties = ['pr'] hostedReviewCache = {} - linearPromptProps.length = 0 workspacePortScan = null settings = null }) @@ -150,7 +141,7 @@ describe('WorktreeCard linked PR display', () => { expect(markup).toContain('Linked PR #456') expect(markup).not.toContain('Loading PR') - }) + }, 10_000) it('does not show cached branch PR details when the worktree has no linked PR', async () => { hostedReviewCache = { @@ -226,27 +217,6 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('Reviewer handoff note') }) - it('treats active runtime environment Linear prompts as remote setup', async () => { - settings = { activeRuntimeEnvironmentId: 'env-1' } - worktreeCardProperties = ['linear-issue'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - renderWorktreeCardMarkup( - <WorktreeCard - worktree={makeWorktree({ linkedLinearIssue: 'ENG-123' })} - repo={makeRepo()} - isActive - /> - ) - - expect(linearPromptProps.at(-1)).toEqual( - expect.objectContaining({ - remote: true, - settings - }) - ) - }) - it('keeps issue, Linear issue, PR, and notes metadata out of compact cards', async () => { settings = { compactWorktreeCards: true } worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 3c30d49972b..f5cd15563ec 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useState } from 'react' import { useAppStore } from '@/store' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -22,7 +23,6 @@ import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer' import WorktreeContextMenu from './WorktreeContextMenu' import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' -import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' @@ -77,6 +77,7 @@ type WorktreeCardProps = { revealHighlightTone?: 'default' | 'ai' selectedWorktrees?: readonly Worktree[] hideRepoBadge?: boolean + hostContextLabel?: string inPinnedSection?: boolean contentIndent?: number flushSurface?: boolean @@ -164,6 +165,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ onCardDragEnd, nativeDragEnabled = true, hideRepoBadge, + hostContextLabel, inPinnedSection = false, contentIndent = 0, flushSurface = false, @@ -265,9 +267,26 @@ const WorktreeCard = React.memo(function WorktreeCard({ const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null const hostedReviewCacheKey = repo && branch - ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId) + ? getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) + : '' + const issueCacheKey = + repo && worktree.linkedIssue + ? getIssueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + settings, + repo.connectionId, + repo.executionHostId + ) : '' - const issueCacheKey = repo && worktree.linkedIssue ? `${repo.id}::${worktree.linkedIssue}` : '' // Why: use 'all' to fetch from all Linear workspaces. The issue might belong // to a different workspace than the currently selected one. const linearIssueCacheKey = worktree.linkedLinearIssue ? `all::${worktree.linkedLinearIssue}` : '' @@ -780,6 +799,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showInlineRepoBadge = compactCards && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge && !showPinnedRepoIcon + const showHostContextBadge = !compactCards && !!hostContextLabel const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null const showBranch = !isFolder && branch.length > 0 && (!compactCards || branch !== worktree.displayName) @@ -801,6 +821,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ // metadata lane unless branch or detached-head identity has content. const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || + showHostContextBadge || isFolder || showBranch || showDetachedHeadInMetaRow || @@ -991,7 +1012,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) : translate( 'auto.components.sidebar.WorktreeCard.ca74db7550', - 'Remote project via SSH' + 'Project on SSH host' )} </TooltipContent> </Tooltip> @@ -1219,6 +1240,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ </div> )} + {showHostContextBadge && ( + <Badge + variant="secondary" + className="h-[16px] max-w-[7rem] shrink-0 rounded border border-border bg-accent px-1.5 text-[10px] font-medium leading-none text-muted-foreground dark:bg-accent/80 dark:border-border/50" + > + <span className="truncate">{hostContextLabel}</span> + </Badge> + )} + {isFolder ? ( <span className="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground" @@ -1278,15 +1308,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ </div> )} - {isActive && worktree.linkedLinearIssue ? ( - <LinearAgentSkillSetupPrompt - linked - remote={Boolean(repo?.connectionId || settings?.activeRuntimeEnvironmentId?.trim())} - surface="modal" - settings={settings} - /> - ) : null} - {/* Why: inline agent list. Gated on the 'inline-agents' card property so users can hide it. Layout coupling: this block grows the card height dynamically — WorktreeList uses diff --git a/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx index 3545454a918..ba30c1f2791 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { canStopWorkspacePort, goToWorkspacePortOwner, @@ -98,12 +99,19 @@ function PortAction({ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, port.kind === 'workspace' ? port.owner.worktreeId : null) + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId, settings] + ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const address = addressForPort(port) const canStop = canStopWorkspacePort(port) @@ -182,6 +190,8 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -203,6 +213,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { recordFeatureInteraction, runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 10669bf79f1..8d544d5daa8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -201,6 +201,8 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ React.createElement(React.Fragment, null, children), DropdownMenuItem: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), DropdownMenuSeparator: () => React.createElement('hr'), DropdownMenuSub: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -376,6 +378,9 @@ function setLineageFixtureState( toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + // Why: multi-host added a host scope filter; 'all' (the store default) + // bypasses it so the fixture's worktrees aren't dropped before rendering. + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: { @@ -452,6 +457,7 @@ function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []): toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: {}, @@ -513,6 +519,7 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void { toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: {}, diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 26acdb6fd7f..9100b85e459 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -311,6 +311,7 @@ function setLineageState(options: { deletingChild?: boolean } = {}): void { updateRepo: vi.fn(), updateWorktreeMeta: mockStore.updateWorktreeMeta, updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspacePortScan: null, workspaceStatuses: [], worktreeCardProperties: [ diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index beac56c2d08..ddcee020067 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -6,6 +6,7 @@ import { } from '@tanstack/react-virtual' import type { Range } from '@tanstack/react-virtual' import { + AlertTriangle, ChevronDown, CircleX, Ellipsis, @@ -13,7 +14,10 @@ import { FolderInput, FolderPlus, FolderX, + Loader2, Plus, + Server, + ServerOff, Shapes, SlidersHorizontal, Trash2 @@ -69,6 +73,7 @@ import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { type Row, + type ProjectGroupingModel, type WorktreeGroupBy, ALL_GROUP_KEY, PINNED_GROUP_KEY, @@ -79,7 +84,7 @@ import { import { estimateRenderRowSize, extractWorktreeVirtualRowIndexes, - getActiveStickyHeaderIndexForScroll, + getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, shouldUseHeaderTopSpacing, @@ -124,7 +129,6 @@ import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' -import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { buildManualOrderUpdatesForGroupDrop, @@ -177,7 +181,15 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId, + parseExecutionHostId +} from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' @@ -205,9 +217,15 @@ import { getWorktreeCardContentIndent, getWorktreeCardSurfaceInset } from './worktree-list-indentation' +import { addHostSectionRows, type HostHeaderRow, type HostSectionRow } from './host-section-rows' +import { orderHostSectionOptions } from './host-section-order' +import { useHostHeaderDrag } from './host-header-drag' +import { buildSidebarHostOptions } from './sidebar-host-options' +import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, type FolderWorkspacePathStatus @@ -377,7 +395,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string { const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 type VirtualizedWorktreeViewportProps = { - rows: Row[] + rows: HostSectionRow[] activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy @@ -420,8 +438,11 @@ type VirtualizedWorktreeViewportProps = { // (filtered out / collapsed-only). Visible-only ids would silently drop the // hidden repos on reorder. allRepoIds: string[] + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void prCache: Record<string, unknown> | null workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void @@ -456,8 +477,8 @@ type VirtualizedWorktreeViewportProps = { scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor> } -type WorktreeItemRow = Extract<Row, { type: 'item' }> -type FolderWorkspaceItemRow = Extract<Row, { type: 'folder-workspace' }> +type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }> +type FolderWorkspaceItemRow = Extract<HostSectionRow, { type: 'folder-workspace' }> function formatSectionActivityLabel(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}` @@ -485,6 +506,141 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { ) } +function HostHeaderHealthIcon({ + health +}: { + health: HostHeaderRow['health'] +}): React.JSX.Element | null { + // Why: healthy is the default state — indicating it adds noise. Only states + // needing active attention get a separate mark. + if (health === 'connecting') { + return <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + } + if (health === 'blocked' || health === 'error') { + return <AlertTriangle className="size-3 shrink-0 text-destructive" /> + } + return null +} + +function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { + // Why: a blocked compatibility verdict gets a compact warning treatment so one + // skewed host stands out without altering how its siblings render. + if (row.health === 'blocked') { + return { + text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), + isWarning: true + } + } + // Why: auth-expired SSH hosts must say so in words — the plan requires a clear + // auth-needed status, and the health icon alone doesn't explain the fix. + if (row.connectionStatus === 'auth-failed') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostAuthNeeded', + 'Authentication needed' + ), + isWarning: true + } + } + if (row.health === 'disconnected') { + return { + text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), + isWarning: false + } + } + // Why: the transport suffix only earns space on remote hosts; "This + // computer" on Local Mac is noise. + if (row.kind !== 'local') { + return { text: row.detail, isWarning: false } + } + return null +} + +function HostSectionHeader({ + row, + onToggle, + onDragPointerDown, + dragging +}: { + row: HostHeaderRow + onToggle: () => void + onDragPointerDown?: (event: React.PointerEvent<HTMLElement>) => void + dragging?: boolean +}): React.JSX.Element { + const isBlocked = row.health === 'blocked' + const isDisconnected = row.health === 'disconnected' + const detail = getHostHeaderDetail(row) + return ( + <div className="px-2 pt-1"> + {/* Why: hosts are machines, not just groups — the outlined card with a + server glyph keeps that distinction visible. Status stays quiet: a + mark renders only when the host needs attention. */} + <div + role="button" + tabIndex={0} + data-host-header-drag-id={row.hostId} + aria-expanded={!row.collapsed} + className={cn( + 'group/host-header flex h-8 w-full cursor-pointer items-center gap-2 rounded-md border px-2 text-left transition-all', + onDragPointerDown && 'cursor-grab active:cursor-grabbing', + isBlocked + ? 'border-destructive/40 bg-destructive/10' + : isDisconnected + ? 'border-worktree-sidebar-border/70 bg-worktree-sidebar-accent/35 text-muted-foreground' + : 'border-worktree-sidebar-border bg-worktree-sidebar-accent/70', + dragging && 'pointer-events-none opacity-0' + )} + onPointerDown={onDragPointerDown} + onClick={onToggle} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + > + {isDisconnected ? ( + <ServerOff className="size-3.5 shrink-0 text-muted-foreground/80" /> + ) : ( + <Server className="size-3.5 shrink-0 text-muted-foreground" /> + )} + <HostHeaderHealthIcon health={row.health} /> + {/* Why: the badge hugs the label like repo headers do — anchoring it + right would leave it floating beside the hover-only controls. */} + <div className="flex min-w-0 flex-1 items-baseline gap-1.5"> + <span + className={cn( + 'min-w-0 truncate text-[12px] font-semibold leading-none', + isDisconnected ? 'text-muted-foreground' : 'text-foreground' + )} + > + {row.label} + </span> + {detail ? ( + <span + className={cn( + 'shrink-0 truncate text-[10px] leading-none', + detail.isWarning ? 'text-destructive' : 'text-muted-foreground/70' + )} + > + {detail.text} + </span> + ) : null} + <SectionMetricsBadge count={row.count} /> + </div> + <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/60 opacity-0 transition-opacity group-hover/host-header:opacity-100"> + <ChevronDown + className={cn('size-3.5 transition-transform', row.collapsed && '-rotate-90')} + /> + </div> + <span data-host-header-action=""> + <HostSectionHeaderMenu row={row} /> + </span> + </div> + </div> + ) +} + function FolderPathStatusIndicator({ status }: { @@ -641,7 +797,7 @@ function shouldPreferSidebarStatusDropTarget(args: { return sourceStatus !== null && args.target.status !== sourceStatus } -function isWorktreeItemRow(row: Row): row is WorktreeItemRow { +function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { return row.type === 'item' } @@ -658,7 +814,7 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n return row.type === 'item' && row.worktree.id === worktreeId } -function buildRenderableRows(rows: Row[]): RenderRow[] { +function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { const renderRows: RenderRow[] = [] for (let index = 0; index < rows.length; index++) { const row = rows[index] @@ -694,6 +850,9 @@ function buildRenderableRows(rows: Row[]): RenderRow[] { } export function getRenderRowKey(row: RenderRow): string { + if (row.type === 'host-header') { + return `host:${row.hostId}` + } if (row.type === 'header') { return `hdr:${row.key}` } @@ -712,7 +871,7 @@ export function getRenderRowKey(row: RenderRow): string { return `wt:${row.worktree.id}` } -export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { +export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null @@ -723,6 +882,7 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { continue } if ( + row.type === 'host-header' || row.type === 'imported-worktrees-card' || row.type === 'pending-creation' || row.type === 'folder-workspace' @@ -807,8 +967,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, repoOrder, allRepoIds, + onReorderHostSections, + onHostDragActiveChange, prCache, workspaceStatuses, + projectGrouping, projectGroups = EMPTY_PROJECT_GROUPS, onMoveWorktreeToStatus, onMoveWorktreesToStatus, @@ -891,8 +1054,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 - const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const canReorderRepoHeaders = + groupBy === 'repo' && projectOrderBy === 'manual' && !hasProjectGroups const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -935,6 +1098,29 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [reorderRepos] ) + // Drag is only meaningful when repo headers are using manual order. The + // controller is still constructed for hook order stability when inert. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + onCommit: commitRepoReorder, + getScrollContainer: () => scrollRef.current + }) + const orderedHostIds = useMemo( + () => + rows + .filter((row): row is HostHeaderRow => row.type === 'host-header') + .map((row) => row.hostId), + [rows] + ) + const hostDrag = useHostHeaderDrag({ + orderedHostIds, + onCommit: onReorderHostSections, + getScrollContainer: () => scrollRef.current + }) + useEffect(() => { + onHostDragActiveChange(hostDrag.state.draggingHostId !== null) + }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) + useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) const worktreeLineageDragRows = useMemo( @@ -1044,47 +1230,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [computeWorktreeDropForGroup] ) const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) - const sidebarRepoHeaderIdsByBucket = useMemo( - () => getSidebarOrderedRepoHeaderIdsByBucket(rows), - [rows] - ) - const repoHeaderIndexByRepoId = useMemo(() => { - const map = new Map<string, number>() - for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { - repoIds.forEach((repoId, index) => { - map.set(repoId, index) - }) - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const repoHeaderBucketByRepoId = useMemo(() => { - const map = new Map<string, string>() - for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) { - for (const repoId of repoIds) { - map.set(repoId, bucketKey) - } - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number) => { - void moveProjectToGroup(repoId, projectGroupId, order) - }, - [moveProjectToGroup] - ) - // Drag is only meaningful when repo headers are using manual order. The - // controller is still constructed for hook order stability when inert. - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: allRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById: repoMap, - usesProjectGroupOrdering: hasProjectGroups, - onCommitRepoOrder: commitRepoReorder, - onCommitProjectGroupOrder: commitProjectGroupOrder, - getScrollContainer: () => scrollRef.current - }) const firstHeaderIndex = useMemo( - () => renderRows.findIndex((row) => row.type === 'header'), + () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), [renderRows] ) const firstHeaderIndexRef = useRef(firstHeaderIndex) @@ -1093,6 +1240,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) stickyHeaderIndexesRef.current = stickyHeaderIndexes const activeStickyHeaderIndexRef = useRef<number | null>(null) + const activeStickyHostIndexRef = useRef<number | null>(null) const stickyRangeStartIndexRef = useRef(0) const activeWorktreeRowIndex = useMemo( () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), @@ -1227,7 +1375,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) } const index = getVirtualRowIndex(element) - if (index !== null && renderRowsRef.current[index]?.type === 'header') { + if ( + index !== null && + (renderRowsRef.current[index]?.type === 'header' || + renderRowsRef.current[index]?.type === 'host-header') + ) { return estimateRenderRowSize( renderRowsRef.current, index, @@ -1276,7 +1428,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rangeExtractor: useCallback( (range: Range) => { stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ range, stickyHeaderIndexes }) + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) }, [stickyHeaderIndexes] ), @@ -1512,13 +1668,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() - const activeStickyHeaderIndex = getActiveStickyHeaderIndexForScroll({ + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, rangeStartIndex: stickyRangeStartIndexRef.current, scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, stickyHeaderIndexes, virtualItems }) - activeStickyHeaderIndexRef.current = activeStickyHeaderIndex + activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex const measureMountedRows = useCallback(() => { virtualizer.elementsCache.forEach((element) => { @@ -1597,7 +1755,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, true, settings, - projectGroups + projectGroups, + new Set(), + new Map(), + [], + projectGrouping ).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item') if (worktreeRows.length === 0) { return @@ -1644,7 +1806,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, worktreeMap, settings, - projectGroups + projectGroups, + projectGrouping ] ) @@ -2634,7 +2797,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const visibleRows = virtualItems .filter((item) => item.start < viewportBottom && item.end > viewportTop) .map((item) => renderRows[item.index]) - .filter((row): row is Extract<Row, { type: 'item' }> => row?.type === 'item') + .filter((row): row is WorktreeItemRow => row?.type === 'item') .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) if ( @@ -2918,6 +3081,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp style={{ top: `${repoDrag.state.dropIndicatorY}px` }} /> ) : null} + {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( + <div + role="presentation" + className="pointer-events-none absolute left-3 right-2 z-40 flex h-3 -translate-y-1/2 items-center" + style={{ top: `${hostDrag.state.dropIndicatorY}px` }} + > + <span className="size-1.5 shrink-0 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + <span className="h-0.5 flex-1 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + <span className="size-1.5 shrink-0 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + </div> + ) : null} {worktreeDragState.draggingWorktreeId !== null && worktreeDragState.dropIndicatorY !== null ? ( <div @@ -2936,8 +3110,58 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return null } + if (row.type === 'host-header') { + // Why: the host card is the outer hierarchy tier — it pins above + // group headers (z-30 vs z-20) and stays put while they hand off. + const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: renderRows, + index: vItem.index, + firstHeaderIndex + }) + return ( + <div + key={vItem.key} + role="presentation" + data-worktree-virtual-row + data-worktree-virtual-row-key={String(vItem.key)} + data-worktree-sticky-header="" + data-worktree-sticky-header-active={isActiveStickyHost ? '' : undefined} + data-index={vItem.index} + ref={measureVirtualRowElement} + className={cn( + 'left-0 right-0', + hasHeaderTopSpacing && !isActiveStickyHost && 'pt-1', + isActiveStickyHost + ? 'sticky -top-px z-30 bg-worktree-sidebar' + : 'absolute top-0' + )} + style={ + isActiveStickyHost + ? undefined + : { transform: getVirtualRowTransform(vItem.start) } + } + > + <HostSectionHeader + row={row} + onToggle={() => toggleGroupWithScrollAnchor(row.key)} + onDragPointerDown={ + orderedHostIds.length > 1 + ? (e) => hostDrag.onHandlePointerDown(e, row.hostId) + : undefined + } + dragging={hostDrag.state.draggingHostId === row.hostId} + /> + </div> + ) + } + if (row.type === 'header') { const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index + // Why: when a host card is pinned, the group tier pins flush + // beneath it instead of at the viewport top. + const stickyTopClass = + activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ rows: renderRows, index: vItem.index, @@ -2946,21 +3170,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const isRepoHeader = groupBy === 'repo' && row.repo !== undefined const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined - const repoHeaderIndex = - projectIdForHeader !== undefined - ? repoHeaderIndexByRepoId.get(projectIdForHeader) - : undefined - const repoHeaderBucketKey = - projectIdForHeader !== undefined - ? repoHeaderBucketByRepoId.get(projectIdForHeader) - : undefined - const isDraggableRepoHeader = Boolean( - canReorderRepoHeaders && - isRepoHeader && - projectIdForHeader && - repoHeaderBucketKey && - (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 - ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && @@ -3014,7 +3223,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-worktree-virtual-row-key={String(vItem.key)} data-worktree-sticky-header="" data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined} - data-worktree-virtual-row-start={vItem.start} data-index={vItem.index} ref={measureVirtualRowElement} className={cn( @@ -3026,7 +3234,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // so the previous repo no longer stays pinned over it. hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1', isActiveStickyHeader - ? 'sticky -top-px z-20 bg-worktree-sidebar' + ? cn('sticky z-20 bg-worktree-sidebar', stickyTopClass) : 'absolute top-0' )} style={ @@ -3039,8 +3247,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp role="button" tabIndex={0} data-repo-header-id={projectIdForHeader} - data-repo-header-index={repoHeaderIndex} - data-repo-header-bucket={repoHeaderBucketKey} data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined} data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} @@ -3058,6 +3264,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp row.repo && 'overflow-hidden' )} style={{ paddingLeft: headerPaddingLeft }} + // Why: arm project-header drag from anywhere on the row, not + // just the icon — users grab the name to reorder. The hook + // ignores presses on nested buttons (+/chevron) and only + // promotes to a drag past a 4px threshold, so a plain click + // still toggles collapse via onClick. + onPointerDown={ + canReorderRepoHeaders && isRepoHeader && projectIdForHeader + ? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader) + : undefined + } onDragOver={ isPinnedHeader ? handleWorkspacePinDragOver @@ -3115,18 +3331,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp <div className="min-w-0 flex-1"> <div className="flex min-w-0 items-center gap-1.5"> - <div - data-repo-header-drag-handle="" - className={cn( - 'min-w-0 truncate text-[13px] font-semibold leading-none', - isDraggableRepoHeader && 'cursor-grab' - )} - onPointerDown={ - isDraggableRepoHeader && projectIdForHeader - ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined - } - > + <div className="min-w-0 truncate text-[13px] font-semibold leading-none"> {row.label} </div> <RepoForkIndicator upstream={row.repo?.upstream} /> @@ -3587,6 +3792,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} + hostContextLabel={itemRow.hostContextLabel} // Why: pinned worktrees only render in the Pinned group, so // isPinned marks the mixed-repo pinned section that needs icons. inPinnedSection={itemRow.worktree.isPinned} @@ -3635,7 +3841,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) cursor = nextSiblingIndex } - return childNodes.length > 0 ? childNodes : undefined } @@ -3865,6 +4070,10 @@ const WorktreeList = React.memo(function WorktreeList({ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) + const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) const setSortBy = useAppStore((s) => s.setSortBy) @@ -3944,6 +4153,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null ) const settings = useAppStore((s) => s.settings) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const sortEpoch = useAppStore((s) => s.sortEpoch) @@ -4173,15 +4386,24 @@ const WorktreeList = React.memo(function WorktreeList({ if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) { return } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - void (target.kind === 'environment' - ? callRuntimeRpc( - target, - 'worktree.persistSortOrder', - { orderedIds: sortedIds }, - { timeoutMs: 15_000 } - ) - : window.api.worktrees.persistSortOrder({ orderedIds: sortedIds })) + // Why: sortOrder is persisted in each host's worktreeMeta and enriched from + // the owner host, so persist each host's ids on that host. + const state = useAppStore.getState() + for (const group of splitWorktreeSortOrderByHost(state, sortedIds)) { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + void (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'worktree.persistSortOrder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + : window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds })) + } }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so @@ -4195,6 +4417,9 @@ const WorktreeList = React.memo(function WorktreeList({ browserTabsByWorktree, hideDefaultBranchWorkspace, repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), worktreeLineageById }) if ( @@ -4213,6 +4438,9 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, showSleepingWorkspaces, hideDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, repoMap, tabsByWorktree, ptyIdsByTabId, @@ -4230,6 +4458,12 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: manual repo header order is bound to state.repos. Recent/Smart derive // header order from the sorted visible worktree stream instead. const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectGrouping = useMemo( + () => ({ projects, projectHostSetups }), + [projectHostSetups, projects] + ) const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const effectiveCollapsedGroups = useMemo(() => { @@ -4251,7 +4485,8 @@ const WorktreeList = React.memo(function WorktreeList({ prCache, workspaceStatuses, settings, - projectGroups + projectGroups, + projectGrouping )) { next.delete(groupKey) } @@ -4281,12 +4516,57 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, prCache, projectGroups, + projectGrouping, repoMap, settings, workspaceStatuses, worktreeLineageById, worktreeMap ]) + const defaultHostId = getSettingsFocusedExecutionHostId(settings) + const visibleHostIdSet = useMemo(() => { + const visibleHostIds = + visibleWorkspaceHostIds ?? + (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) + return visibleHostIds ? new Set<ExecutionHostId>(visibleHostIds) : null + }, [visibleWorkspaceHostIds, workspaceHostScope]) + const visibleReposForRows = useMemo(() => { + if (!visibleHostIdSet) { + return repos + } + return repos.filter((repo) => { + const hostId = + repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, repos, visibleHostIdSet]) + const visibleProjectGroupsForRows = useMemo(() => { + if (!visibleHostIdSet) { + return projectGroups + } + return projectGroups.filter((group) => { + const hostId = group.connectionId + ? (`ssh:${encodeURIComponent(group.connectionId)}` as ExecutionHostId) + : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, projectGroups, visibleHostIdSet]) + const visibleFolderWorkspacesForRows = useMemo(() => { + if (!visibleHostIdSet) { + return folderWorkspaces + } + const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) + return folderWorkspaces.filter((folderWorkspace) => { + const connectionId = + folderWorkspace.connectionId ?? + projectGroupById.get(folderWorkspace.projectGroupId)?.connectionId ?? + null + const hostId = connectionId + ? (`ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId) + : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { const map = new Map<string, number>() repos.forEach((r, i) => map.set(r.id, i)) @@ -4302,15 +4582,20 @@ const WorktreeList = React.memo(function WorktreeList({ .map(([repoId]) => repoId) ) return buildImportedWorktreesCardCandidates({ - repos, + repos: visibleReposForRows, detectedWorktreesByRepo, filterRepoIds, forceVisibleRepoIds }) - }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, repos]) + }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) const placeholderRepoIds = useMemo(() => { - return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds }) - }, [filterRepoIds, groupBy, repos, worktreesByRepo]) + return getEmptyProjectPlaceholderRepoIds({ + groupBy, + repos: visibleReposForRows, + worktreesByRepo, + filterRepoIds + }) + }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a @@ -4333,6 +4618,32 @@ const WorktreeList = React.memo(function WorktreeList({ }), [pendingCreationKeys] ) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -4350,11 +4661,13 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap, true, settings, - projectGroups, + visibleProjectGroupsForRows, placeholderRepoIds, importedWorktreesByRepo, pendingCreations, - folderWorkspaces + projectGrouping, + visibleFolderWorkspacesForRows, + hostLabelById ), [ groupBy, @@ -4368,16 +4681,69 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById, worktreeMap, settings, - projectGroups, + projectGrouping, + visibleProjectGroupsForRows, + visibleFolderWorkspacesForRows, placeholderRepoIds, importedWorktreesByRepo, pendingCreations, - folderWorkspaces + hostLabelById + ] + ) + const orderedHostOptions = useMemo( + () => orderHostSectionOptions(hostOptions, workspaceHostOrder), + [hostOptions, workspaceHostOrder] + ) + const [hostDragActive, setHostDragActive] = useState(false) + const handleReorderHostSections = useCallback( + (orderedVisibleHostIds: ExecutionHostId[]) => { + const visibleHostIds = new Set(orderedVisibleHostIds) + const hostOptionIds = orderedHostOptions.map((host) => host.id) + const knownHostIds = new Set(hostOptionIds) + const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] + const seen = new Set(nextOrder) + // Why: dragging only covers rendered host sections. Keep non-rendered + // SSH/runtime hosts in the saved preference so they return in the same + // place when their workspaces become visible again. + for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { + if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { + continue + } + nextOrder.push(hostId) + seen.add(hostId) + } + setWorkspaceHostOrder(nextOrder) + }, + [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] + ) + const sectionRows = useMemo( + () => + addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are now the primary sidebar object in every + // grouping mode; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }), + [ + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + orderedHostOptions, + rows, + visibleWorkspaceHostIds, + workspaceHostScope ] ) // Why: status headers change during wake (inactive -> active). Key only on // the grouping mode so row identity survives those ordinary status moves. - const viewportResetKey = `group:${groupBy}:lineage` + const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' + const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` // Why: derive the rendered item order from the post-buildRows() row list, // not the flat `worktrees` array, because grouping (groupBy: 'repo' or @@ -4386,7 +4752,7 @@ const WorktreeList = React.memo(function WorktreeList({ // positions when grouping is active. const renderedWorktrees = useMemo( () => - rows.flatMap((row) => { + sectionRows.flatMap((row) => { if (row.type === 'item') { return [row.worktree] } @@ -4395,7 +4761,7 @@ const WorktreeList = React.memo(function WorktreeList({ } return [] }), - [rows] + [sectionRows] ) const renderedWorktreeIds = useMemo( () => renderedWorktrees.map((worktree) => worktree.id), @@ -4897,13 +5263,19 @@ const WorktreeList = React.memo(function WorktreeList({ // worktree is a default-branch row and who just toggled hide on would see // "No workspaces found" with no way back short of reopening the filter menu. const filterState = useMemo( - () => ({ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace }), - [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace] + () => ({ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + visibleWorkspaceHostIds + }), + [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, visibleWorkspaceHostIds] ) const hasFilters = sidebarHasActiveFilters(filterState) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) const clearFilters = useCallback(() => { const actions = computeClearFilterActions(filterState) @@ -4916,7 +5288,16 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDefaultBranchWorkspace) { setHideDefaultBranchWorkspace(false) } - }, [setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState]) + if (actions.resetVisibleWorkspaceHostIds) { + setVisibleWorkspaceHostIds(null) + } + }, [ + setShowSleepingWorkspaces, + setFilterRepoIds, + setHideDefaultBranchWorkspace, + setVisibleWorkspaceHostIds, + filterState + ]) const handleRevealCurrentWorkspaceRequest = useCallback( (event: Event) => { @@ -5067,7 +5448,7 @@ const WorktreeList = React.memo(function WorktreeList({ /> <VirtualizedWorktreeViewport key={viewportResetKey} - rows={rows} + rows={sectionRows} activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} @@ -5103,8 +5484,11 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById={worktreeLineageById} repoOrder={repoOrder} allRepoIds={allRepoIds} + onReorderHostSections={handleReorderHostSections} + onHostDragActiveChange={setHostDragActive} prCache={prCache} workspaceStatuses={workspaceStatuses} + projectGrouping={projectGrouping} projectGroups={projectGroups} onMoveWorktreeToStatus={moveWorktreeToStatus} onMoveWorktreesToStatus={moveWorktreesToStatus} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 9157f3fec31..217d2fd6735 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -11,28 +11,14 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates' +import { useWorktreeIssueLink } from './use-worktree-issue-link' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { ExternalLink, LoaderCircle } from 'lucide-react' -import type { WorktreeMeta } from '../../../../shared/types' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -type WorktreeMetaSavedPayload = { - worktreeId: string - updates: Partial<WorktreeMeta> -} - -function parseExplicitGitHubIssueUrl(input: string): string | null { - const trimmed = input.trim() - const link = parseGitHubIssueOrPRLink(trimmed) - if (!link || link.type !== 'issue') { - return null - } - - return trimmed -} - function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` @@ -43,7 +29,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const fetchIssue = useAppStore((s) => s.fetchIssue) const submitShortcutLabel = getScreenSubmitShortcutLabel() const isEditMeta = activeModal === 'edit-meta' @@ -68,7 +53,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [prInput, setPrInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) - const [openingIssue, setOpeningIssue] = useState(false) + const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ + worktreeId, + issueInput + }) const issueInputRef = useRef<HTMLInputElement>(null) const prInputRef = useRef<HTMLInputElement>(null) @@ -81,35 +69,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { setIssueInput(currentIssue) setPrInput(currentPR) setCommentInput(currentComment) - setOpeningIssue(false) + resetOpeningIssue() } prevIsOpenRef.current = isOpen - const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput]) - const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput]) - const issueInputLooksLikeUrl = useMemo( - () => /^https?:\/\//i.test(issueInput.trim()), - [issueInput] - ) - const issueRepo = useAppStore((s) => { - const worktree = Object.values(s.worktreesByRepo) - .flat() - .find((item) => item.id === worktreeId) - if (!worktree) { - return undefined - } - return s.repos.find((repo) => repo.id === worktree.repoId) - }) - const cachedIssueUrl = useAppStore((s) => { - if (!issueRepo || issueNumber === null) { - return null - } - return s.issueCache[`${issueRepo.id}::${issueNumber}`]?.data?.url ?? null - }) - const canOpenIssue = issueInputLooksLikeUrl - ? Boolean(issueUrlFromInput) - : Boolean(cachedIssueUrl || (issueRepo && issueNumber)) - const setCommentTextareaRef = useCallback( (textarea: HTMLTextAreaElement | null) => { textareaRef.current = textarea @@ -152,28 +115,13 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } setSaving(true) try { - const trimmedIssue = issueInput.trim() - const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) - const finalLinkedIssue = - trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined - const trimmedPR = prInput.trim() - const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR) - const finalLinkedPR = - trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined - - const trimmedDisplayName = displayNameInput.trim() - const updates: Partial<WorktreeMeta> = { - comment: commentInput.trim(), - ...(trimmedDisplayName !== currentDisplayName && { - displayName: trimmedDisplayName || undefined - }) - } - if (finalLinkedIssue !== undefined) { - updates.linkedIssue = finalLinkedIssue - } - if (finalLinkedPR !== undefined) { - updates.linkedPR = finalLinkedPR - } + const updates = buildWorktreeMetaUpdates({ + displayNameInput, + currentDisplayName, + issueInput, + prInput, + commentInput + }) await updateWorktreeMeta(worktreeId, updates) closeModal() @@ -225,51 +173,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { [handleSave] ) - const handleOpenIssue = useCallback(async () => { - if (openingIssue) { - return - } - - if (issueUrlFromInput) { - void window.api.shell.openUrl(issueUrlFromInput) - return - } - - if (issueInputLooksLikeUrl) { - return - } - - if (cachedIssueUrl) { - void window.api.shell.openUrl(cachedIssueUrl) - return - } - - if (!issueRepo || issueNumber === null) { - return - } - - setOpeningIssue(true) - try { - const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id }) - if (issue?.url) { - void window.api.shell.openUrl(issue.url) - } - } finally { - if (mountedRef.current) { - setOpeningIssue(false) - } - } - }, [ - cachedIssueUrl, - fetchIssue, - issueInputLooksLikeUrl, - issueNumber, - issueRepo, - issueUrlFromInput, - mountedRef, - openingIssue - ]) - return ( <Dialog open={isOpen} onOpenChange={handleOpenChange}> <DialogContent @@ -288,35 +191,58 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }} > <DialogHeader> - <DialogTitle className="text-sm">{translate("auto.components.sidebar.WorktreeMetaDialog.382fd11a3e", "Edit Worktree Details")}</DialogTitle> + <DialogTitle className="text-sm"> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.382fd11a3e', + 'Edit Worktree Details' + )} + </DialogTitle> <DialogDescription className="text-xs"> - {translate("auto.components.sidebar.WorktreeMetaDialog.65770ad0f0", "Edit GitHub links and notes for this workspace.")}</DialogDescription> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0', + 'Edit GitHub links and notes for this workspace.' + )} + </DialogDescription> </DialogHeader> <div className="space-y-4"> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f", "Display Name")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f', 'Display Name')} + </label> <Input ref={displayNameInputRef} value={displayNameInput} onChange={(e) => setDisplayNameInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.7f21e0464f", "Custom display name...")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', + 'Custom display name...' + )} className="h-8 text-xs" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.459ad7f650", "Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', + 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd", "GH Issue")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd', 'GH Issue')} + </label> <div className="relative"> <Input ref={issueInputRef} value={issueInput} onChange={(e) => setIssueInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.741279e7b7", "Issue # or GitHub URL")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7', + 'Issue # or GitHub URL' + )} className="h-8 pr-9 text-xs" /> <Tooltip> @@ -325,7 +251,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { type="button" variant="ghost" size="icon-xs" - aria-label={translate("auto.components.sidebar.WorktreeMetaDialog.029ea5ec57", "Open GitHub issue")} + aria-label={translate( + 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', + 'Open GitHub issue' + )} disabled={!canOpenIssue || openingIssue} onClick={handleOpenIssue} className="absolute right-1 top-1 text-muted-foreground" @@ -338,41 +267,71 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.sidebar.WorktreeMetaDialog.029ea5ec57", "Open GitHub issue")}</TooltipContent> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', + 'Open GitHub issue' + )} + </TooltipContent> </Tooltip> </div> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.7c454be4c5", "Paste an issue URL, or enter a number. Leave blank to remove the link.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5', + 'Paste an issue URL, or enter a number. Leave blank to remove the link.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.1b91db7e14", "GH PR")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.1b91db7e14', 'GH PR')} + </label> <Input ref={prInputRef} value={prInput} onChange={(e) => setPrInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.077a4f7b5c", "PR # or GitHub URL")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.077a4f7b5c', + 'PR # or GitHub URL' + )} className="h-8 text-xs" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.5ae06f40fd", "Paste a pull request URL, or enter a number. Leave blank to remove the link.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.5ae06f40fd', + 'Paste a pull request URL, or enter a number. Leave blank to remove the link.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.9c1d1e9b71", "Comment")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.9c1d1e9b71', 'Comment')} + </label> <textarea ref={setCommentTextareaRef} value={commentInput} onChange={handleCommentChange} onKeyDown={handleCommentKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.030d484fc0", "Notes about this worktree...")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.030d484fc0', + 'Notes about this worktree...' + )} rows={3} className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.7f0be5e9a6", "Supports **markdown** — bold, lists, `code`, links. Press Enter or")}{' '} - {submitShortcutLabel} {translate("auto.components.sidebar.WorktreeMetaDialog.b48c271d39", "to save, Shift+Enter for a new line.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f0be5e9a6', + 'Supports **markdown** — bold, lists, `code`, links. Press Enter or' + )}{' '} + {submitShortcutLabel}{' '} + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.b48c271d39', + 'to save, Shift+Enter for a new line.' + )} + </p> </div> </div> @@ -383,9 +342,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { onClick={() => handleOpenChange(false)} className="text-xs" > - {translate("auto.components.sidebar.WorktreeMetaDialog.3db0a2a593", "Cancel")}</Button> + {translate('auto.components.sidebar.WorktreeMetaDialog.3db0a2a593', 'Cancel')} + </Button> <Button size="sm" onClick={handleSave} disabled={!canSave || saving} className="text-xs"> - {saving ? translate("auto.components.sidebar.WorktreeMetaDialog.61d6f612cf", "Saving...") : translate("auto.components.sidebar.WorktreeMetaDialog.2174f17011", "Save")} + {saving + ? translate('auto.components.sidebar.WorktreeMetaDialog.61d6f612cf', 'Saving...') + : translate('auto.components.sidebar.WorktreeMetaDialog.2174f17011', 'Save')} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/src/components/sidebar/add-repo-dialog-types.ts b/src/renderer/src/components/sidebar/add-repo-dialog-types.ts index 0f4b6e94425..e756aa29da8 100644 --- a/src/renderer/src/components/sidebar/add-repo-dialog-types.ts +++ b/src/renderer/src/components/sidebar/add-repo-dialog-types.ts @@ -1,4 +1,4 @@ -export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' +export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'server-path' | 'create' | 'nested' export function defaultProjectGroupNameForPath(path: string): string { return ( diff --git a/src/renderer/src/components/sidebar/add-repo-host-availability.ts b/src/renderer/src/components/sidebar/add-repo-host-availability.ts new file mode 100644 index 00000000000..63019f298c3 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-host-availability.ts @@ -0,0 +1,5 @@ +import type { SidebarHostOption } from './sidebar-host-options' + +export function canSelectAddRepoHost(host: Pick<SidebarHostOption, 'health' | 'kind'>): boolean { + return host.health === 'local' || host.health === 'available' +} diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index 0572b1dc236..fbe525470da 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -7,6 +7,9 @@ export type AddRepoLocalStartActionHandlers = { onOpenCloneStep: () => void onOpenRemoteStep: () => void onOpenCreateStep: () => void + showRemoteAction?: boolean + canCreateProject?: boolean + browseHostKind?: 'local' | 'ssh' | 'runtime' } export type AddRepoLocalStartAction = { @@ -14,6 +17,7 @@ export type AddRepoLocalStartAction = { icon: ComponentType<{ className?: string }> title: string description: string + disabled?: boolean onClick: () => void } @@ -22,7 +26,10 @@ export function getAddRepoLocalStartActions({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction = true, + canCreateProject = true, + browseHostKind = 'local' }: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): { primaryAction: AddRepoLocalStartAction secondaryActions: AddRepoLocalStartAction[] @@ -30,14 +37,31 @@ export function getAddRepoLocalStartActions({ const primaryAction = { kind: 'browse' as const, icon: FolderOpen, - title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7', - 'Browse folder' - ), - description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', - 'Local project, Git repo, or folder with many repos' - ), + title: + browseHostKind === 'ssh' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle', + 'Open project on SSH host' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7', + 'Browse folder' + ), + description: + browseHostKind === 'ssh' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription', + 'Existing Git repository or folder on this SSH host' + ) + : browseHostKind === 'runtime' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription', + 'Existing Git repository or folder on this host' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', + 'Local project, Git repo, or folder with many repos' + ), onClick: onBrowse } @@ -46,11 +70,11 @@ export function getAddRepoLocalStartActions({ icon: Monitor, title: translate( 'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f', - 'Remote project' + 'Project on SSH host' ), description: translate( 'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96', - 'Open a project from an SSH target' + 'Open a project folder from an SSH host' ), onClick: onOpenRemoteStep } @@ -71,18 +95,27 @@ export function getAddRepoLocalStartActions({ kind: 'create' as const, icon: Plus, title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.createProjectTitle', - 'Create project' - ), - description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.createGitProjectDescription', - 'Create a local Git repository' + 'auto.components.sidebar.add.repo.local.start.actions.c709860596', + 'Create new project' ), + description: canCreateProject + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.d72789705e', + 'Start from an empty folder' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable', + 'Not available for SSH hosts yet' + ), + disabled: !canCreateProject, onClick: onOpenCreateStep } - // SSH-likely users reach for remote targets first, so surface that row ahead of clone. - const secondaryActions = isSshLikely ? [remote, clone, create] : [clone, remote, create] + const secondaryActions = showRemoteAction + ? isSshLikely + ? [remote, clone, create] + : [clone, remote, create] + : [clone, create] return { primaryAction, secondaryActions } } diff --git a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts new file mode 100644 index 00000000000..664d5e3e93e --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts @@ -0,0 +1,19 @@ +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/types' +import { useAppStore } from '@/store' + +export function upsertAddedRepoWithProjectHostSetup(repo: Repo): void { + const state = useAppStore.getState() + const repos = state.repos.some((entry) => entry.id === repo.id) + ? state.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...state.repos, repo] + const projection = projectHostSetupProjectionFromRepos(repos) + + // Why: these Add Project flows call IPC directly, bypassing the repo slice + // action that normally keeps the project-first compatibility model synced. + useAppStore.setState({ + repos, + projects: projection.projects, + projectHostSetups: projection.setups + }) +} diff --git a/src/renderer/src/components/sidebar/clone-defaults.test.ts b/src/renderer/src/components/sidebar/clone-defaults.test.ts index 9ec5f6ea3c4..ec61ffdb566 100644 --- a/src/renderer/src/components/sidebar/clone-defaults.test.ts +++ b/src/renderer/src/components/sidebar/clone-defaults.test.ts @@ -104,4 +104,17 @@ describe('getCloneDestinationAutoFill', () => { }) ).toBeNull() }) + + it('does not fill SSH clone destinations from the local workspace directory', () => { + expect( + getCloneDestinationAutoFill({ + step: 'clone', + cloneDestination: '', + activeRuntimeEnvironmentId: null, + sshTargetId: 'openclaw-2', + workspaceDir: '/Users/mvanhorn/orca/workspaces', + cloneStepAutoFilled: false + }) + ).toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/clone-defaults.ts b/src/renderer/src/components/sidebar/clone-defaults.ts index 6a720a33a6c..8352cb023ef 100644 --- a/src/renderer/src/components/sidebar/clone-defaults.ts +++ b/src/renderer/src/components/sidebar/clone-defaults.ts @@ -30,19 +30,21 @@ export function getCloneDestinationAutoFill({ step, cloneDestination, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, cloneStepAutoFilled }: { step: string cloneDestination: string activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null | undefined workspaceDir: string | null | undefined cloneStepAutoFilled: boolean }): { destination: string } | null { if (step !== 'clone' || cloneStepAutoFilled || cloneDestination) { return null } - if (activeRuntimeEnvironmentId?.trim() || !workspaceDir) { + if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim() || !workspaceDir) { return null } return { destination: getDefaultCloneParent(workspaceDir) } diff --git a/src/renderer/src/components/sidebar/create-project-defaults.test.ts b/src/renderer/src/components/sidebar/create-project-defaults.test.ts index 4691b605081..6b1a7204fb8 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.test.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.test.ts @@ -85,6 +85,20 @@ describe('create project defaults', () => { defaultParent: '', runtimeEnvironmentId: 'env-1' }) - ).toBe('server folder not selected') + ).toBe('host folder not selected') + expect( + formatCreateProjectParentSummary({ + parent: '/Users/alice/orca/projects', + defaultParent: '/Users/alice/orca/projects', + isRemoteHost: true + }) + ).toBe('/Users/alice/orca/projects') + expect( + formatCreateProjectParentSummary({ + parent: '', + defaultParent: '', + isRemoteHost: true + }) + ).toBe('host folder not selected') }) }) diff --git a/src/renderer/src/components/sidebar/create-project-defaults.ts b/src/renderer/src/components/sidebar/create-project-defaults.ts index e5df269a967..fd5175c8c22 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.ts @@ -68,20 +68,22 @@ export function formatCreateProjectParentSummary({ parent, defaultParent, runtimeEnvironmentId, + isRemoteHost, missingLocationLabel = 'location not selected', - missingServerLocationLabel = 'server folder not selected' + missingServerLocationLabel = 'host folder not selected' }: { parent: string defaultParent: string runtimeEnvironmentId?: string | null + isRemoteHost?: boolean missingLocationLabel?: string missingServerLocationLabel?: string }): string { const trimmedParent = parent.trim() if (!trimmedParent) { - return runtimeEnvironmentId ? missingServerLocationLabel : missingLocationLabel + return runtimeEnvironmentId || isRemoteHost ? missingServerLocationLabel : missingLocationLabel } - if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId) { + if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId && !isRemoteHost) { return '~/orca/projects' } return trimmedParent diff --git a/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts new file mode 100644 index 00000000000..188e1bdbbaf --- /dev/null +++ b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts @@ -0,0 +1,50 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { SettingsNavTarget } from '@/lib/settings-navigation-types' +import type { GlobalSettings } from '../../../../shared/types' + +export function persistDeleteWorktreeConfirmSkipPreference({ + updateSettings, + openSettingsPage, + openSettingsTarget +}: { + updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> + openSettingsPage: () => void + openSettingsTarget: (target: { + pane: SettingsNavTarget + repoId: string | null + sectionId?: string + intent?: 'add-quick-command' + }) => void +}): void { + void updateSettings({ skipDeleteWorktreeConfirm: true }) + // Why: the toast confirms the preference was saved and deep-links to the + // exact toggle so users can undo a skipped destructive confirmation quickly. + toast.success( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.dd3a45bbbd', + "We'll skip this confirmation next time." + ), + { + description: translate( + 'auto.components.sidebar.DeleteWorktreeDialog.2b56b35f53', + 'You can change this in Settings.' + ), + duration: 8000, + action: { + label: translate( + 'auto.components.sidebar.DeleteWorktreeDialog.5cc1a6701c', + 'Open Settings' + ), + onClick: () => { + openSettingsPage() + openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-skip-delete-worktree-confirm' + }) + } + } + } + ) +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index e1440c4be5a..fa1e1fab963 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -13,9 +13,11 @@ import type { GitLabWorkItem, LinearIssue, ProjectGroup, - Repo + Repo, + TuiAgent } from '../../../../shared/types' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import { translate } from '@/i18n/i18n' const EMPTY_REPOS: Repo[] = [] @@ -114,3 +116,12 @@ export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSumm export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearIssueLinkedWorkItem(issue) } + +export function getFolderWorkspacePrimaryActionLabel(quickAgent: TuiAgent | null): string { + return quickAgent + ? translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart', + 'Create & Start Agent' + ) + : translate('auto.components.sidebar.FolderWorkspaceComposerDialog.create', 'Create Workspace') +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts new file mode 100644 index 00000000000..b48c5ed7f01 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' +import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' + +type UseFolderWorkspaceComposerKeyboardInput = { + open: boolean + submitting: boolean + composerRef: RefObject<HTMLDivElement | null> + onOpenChange: (open: boolean) => void + onCreate: () => void +} + +export function useFolderWorkspaceComposerKeyboard({ + open, + submitting, + composerRef, + onOpenChange, + onCreate +}: UseFolderWorkspaceComposerKeyboardInput): void { + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== 'Escape') { + return + } + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + if (event.key === 'Escape') { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + event.preventDefault() + target.blur() + return + } + event.preventDefault() + onOpenChange(false) + return + } + if (!isScreenSubmitShortcut(event)) { + return + } + if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) { + return + } + event.preventDefault() + onCreate() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [composerRef, onCreate, onOpenChange, open, submitting]) +} diff --git a/src/renderer/src/components/sidebar/host-header-drag-dom.ts b/src/renderer/src/components/sidebar/host-header-drag-dom.ts new file mode 100644 index 00000000000..050427563b0 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag-dom.ts @@ -0,0 +1,40 @@ +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' + +export type HostHeaderRect = { + hostId: ExecutionHostId + top: number + bottom: number +} + +const HOST_HEADER_ACTION_SELECTOR = + '[data-host-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isHostHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return currentTarget.contains(target) && target.closest(HOST_HEADER_ACTION_SELECTOR) !== null +} + +export function readHostHeaderRects(container: HTMLElement): HostHeaderRect[] { + const containerRect = container.getBoundingClientRect() + const headerRects: HostHeaderRect[] = [] + for (const header of Array.from( + container.querySelectorAll<HTMLElement>('[data-host-header-drag-id]') + )) { + const hostId = normalizeExecutionHostId(header.dataset.hostHeaderDragId) + if (!hostId) { + continue + } + const rect = header.getBoundingClientRect() + headerRects.push({ + hostId, + top: rect.top - containerRect.top + container.scrollTop, + bottom: rect.bottom - containerRect.top + container.scrollTop + }) + } + return headerRects +} diff --git a/src/renderer/src/components/sidebar/host-header-drag.ts b/src/renderer/src/components/sidebar/host-header-drag.ts new file mode 100644 index 00000000000..8b6ea676a46 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent +} from 'react' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + createSidebarDragPreview, + setSidebarPointerDragDocumentStyles, + updateSidebarDragPreviewPosition +} from './worktree-sidebar-pointer-drag-dom' +import { + isHostHeaderActionTarget, + readHostHeaderRects, + type HostHeaderRect +} from './host-header-drag-dom' + +export type HostDragState = { + draggingHostId: ExecutionHostId | null + dropIndex: number | null + dropIndicatorY: number | null +} + +const INITIAL_STATE: HostDragState = { + draggingHostId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseHostHeaderDragArgs = { + orderedHostIds: readonly ExecutionHostId[] + onCommit: (orderedIds: ExecutionHostId[]) => void + getScrollContainer: () => HTMLElement | null +} + +export type HostHeaderDragController = { + state: HostDragState + onHandlePointerDown: (event: ReactPointerEvent<HTMLElement>, hostId: ExecutionHostId) => void +} + +const DRAG_THRESHOLD_PX = 4 + +export function useHostHeaderDrag({ + orderedHostIds, + onCommit, + getScrollContainer +}: UseHostHeaderDragArgs): HostHeaderDragController { + const [state, setState] = useState<HostDragState>(INITIAL_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef<number | null>(null) + latestDropIndexRef.current = state.dropIndex + const orderedIdsRef = useRef(orderedHostIds) + orderedIdsRef.current = orderedHostIds + const onCommitRef = useRef(onCommit) + onCommitRef.current = onCommit + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + + const dragSessionRef = useRef<{ + hostId: ExecutionHostId + pointerId: number + headerRects: HostHeaderRect[] + handleEl: HTMLElement + startX: number + startY: number + promoted: boolean + preview: HTMLElement | null + previewOffsetX: number + previewOffsetY: number + } | null>(null) + const deferredComputeFrameRef = useRef<number | null>(null) + + const clearDeferredComputeFrame = useCallback(() => { + if (deferredComputeFrameRef.current !== null) { + window.cancelAnimationFrame(deferredComputeFrameRef.current) + deferredComputeFrameRef.current = null + } + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + // Why: dragging host headers temporarily collapses their sections, so + // live rects are the source of truth after the first promoted move. + const rects = readHostHeaderRects(container) + if (rects.length === 0 || rects.length < orderedIdsRef.current.length) { + return null + } + session.headerRects = rects + const containerRect = container.getBoundingClientRect() + const localY = pointerY - containerRect.top + container.scrollTop + let insertBefore = rects.length + for (let i = 0; i < rects.length; i++) { + const mid = (rects[i].top + rects[i].bottom) / 2 + if (localY < mid) { + insertBefore = i + break + } + } + const INDICATOR_GAP_PX = 4 + const rawIndicatorY = + insertBefore >= rects.length + ? rects.at(-1)!.bottom + INDICATOR_GAP_PX + : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) + return { + dropIndex: insertBefore, + dropIndicatorY: Math.max(container.scrollTop, rawIndicatorY) + } + }, + [] + ) + + const applyDrop = useCallback((drop: { dropIndex: number; dropIndicatorY: number } | null) => { + if (!drop) { + return + } + latestDropIndexRef.current = drop.dropIndex + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingHostId: dragSessionRef.current?.hostId ?? prev.draggingHostId, ...drop } + ) + }, []) + + const scheduleDeferredDropCompute = useCallback( + (pointerY: number) => { + clearDeferredComputeFrame() + deferredComputeFrameRef.current = window.requestAnimationFrame(() => { + deferredComputeFrameRef.current = null + applyDrop(computeDrop(pointerY)) + }) + }, + [applyDrop, clearDeferredComputeFrame, computeDrop] + ) + + const endDrag = useCallback( + (commit: boolean, pointerY?: number) => { + const session = dragSessionRef.current + if (!session) { + clearDeferredComputeFrame() + setState(INITIAL_STATE) + setSessionArmed(false) + return + } + clearDeferredComputeFrame() + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // Pointer capture may already be gone if the element unmounted. + } + session.preview?.remove() + setSidebarPointerDragDocumentStyles(false) + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (e: MouseEvent): void => { + const target = e.target as Node | null + if (target && handleEl.contains(target)) { + e.stopPropagation() + e.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + setTimeout(() => window.removeEventListener('click', swallow, true), 0) + } + const finalIndex = + commit && session.promoted + ? (latestDropIndexRef.current ?? + (pointerY === undefined ? null : (computeDrop(pointerY)?.dropIndex ?? null))) + : null + dragSessionRef.current = null + setState(INITIAL_STATE) + setSessionArmed(false) + if (finalIndex === null) { + return + } + const ids = orderedIdsRef.current + const fromIndex = ids.indexOf(session.hostId) + if (fromIndex === -1) { + return + } + const next = ids.slice() + next.splice(fromIndex, 1) + const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex + if (insertAt === fromIndex) { + return + } + next.splice(insertAt, 0, session.hostId) + onCommitRef.current(next) + }, + [clearDeferredComputeFrame, computeDrop] + ) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || e.pointerId !== session.pointerId) { + return + } + if (!session.promoted) { + const dx = e.clientX - session.startX + const dy = e.clientY - session.startY + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { + return + } + session.promoted = true + const { preview, offsetX, offsetY } = createSidebarDragPreview({ + sourceRow: session.handleEl, + pointerX: e.clientX, + pointerY: e.clientY, + draggedCount: 1 + }) + session.preview = preview + session.previewOffsetX = offsetX + session.previewOffsetY = offsetY + setSidebarPointerDragDocumentStyles(true) + setState({ draggingHostId: session.hostId, dropIndex: null, dropIndicatorY: null }) + } + if (session.preview) { + updateSidebarDragPreviewPosition({ + preview: session.preview, + pointerX: e.clientX, + pointerY: e.clientY, + offsetX: session.previewOffsetX, + offsetY: session.previewOffsetY + }) + } + const drop = computeDrop(e.clientY) + if (!drop) { + scheduleDeferredDropCompute(e.clientY) + return + } + applyDrop(drop) + } + const onPointerUp = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (session && e.pointerId === session.pointerId) { + endDrag(true, e.clientY) + } + } + const onPointerCancel = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (session && e.pointerId === session.pointerId) { + endDrag(false) + } + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + } + }, [applyDrop, computeDrop, endDrag, scheduleDeferredDropCompute, sessionArmed]) + + const onHandlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLElement>, hostId: ExecutionHostId) => { + if (event.button !== 0 || isHostHeaderActionTarget(event.target, event.currentTarget)) { + return + } + const container = getContainerRef.current() + if (!container || orderedIdsRef.current.length <= 1) { + return + } + const headerRects = readHostHeaderRects(container) + dragSessionRef.current = { + hostId, + pointerId: event.pointerId, + headerRects, + handleEl: event.currentTarget, + startX: event.clientX, + startY: event.clientY, + promoted: false, + preview: null, + previewOffsetX: 0, + previewOffsetY: 0 + } + event.currentTarget.setPointerCapture(event.pointerId) + setSessionArmed(true) + }, + [] + ) + + useEffect(() => { + return () => { + clearDeferredComputeFrame() + dragSessionRef.current?.preview?.remove() + setSidebarPointerDragDocumentStyles(false) + } + }, [clearDeferredComputeFrame]) + + return { state, onHandlePointerDown } +} diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.test.ts b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts new file mode 100644 index 00000000000..b9c45800faa --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { buildHostHeaderMenuModel } from './host-header-menu-items' + +describe('buildHostHeaderMenuModel', () => { + it('offers Focus + Rename + Manage for the local host (no Remove)', () => { + const model = buildHostHeaderMenuModel({ kind: 'local', health: 'local' }) + expect(model.actions).toEqual(['rename', 'manage']) + expect(model.actions).not.toContain('remove') + expect(model.blocked).toBeNull() + }) + + it('offers Reconnect + Remove for a disconnected SSH host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'ssh', + health: 'disconnected', + sshConnected: false + }) + expect(model.actions).toEqual(['rename', 'ssh-reconnect', 'manage', 'remove']) + }) + + it('offers Disconnect + Remove for a connected SSH host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'ssh', + health: 'available', + sshConnected: true + }) + expect(model.actions).toEqual(['rename', 'ssh-disconnect', 'manage', 'remove']) + }) + + it('offers Check connection + Remove for a runtime host', () => { + const model = buildHostHeaderMenuModel({ kind: 'runtime', health: 'available' }) + expect(model.actions).toEqual(['rename', 'runtime-check-connection', 'manage', 'remove']) + }) + + it('offers Rename for every host kind', () => { + for (const kind of ['local', 'ssh', 'runtime'] as const) { + expect(buildHostHeaderMenuModel({ kind, health: 'available' }).actions).toContain('rename') + } + }) + + it('offers Remove only for ssh and runtime hosts', () => { + expect(buildHostHeaderMenuModel({ kind: 'ssh', health: 'available' }).actions).toContain( + 'remove' + ) + expect(buildHostHeaderMenuModel({ kind: 'runtime', health: 'available' }).actions).toContain( + 'remove' + ) + expect(buildHostHeaderMenuModel({ kind: 'local', health: 'local' }).actions).not.toContain( + 'remove' + ) + }) + + it('surfaces a server-too-old block for a blocked runtime host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + }) + expect(model.blocked).toEqual({ reason: 'server-too-old' }) + expect(model.actions).toContain('runtime-check-connection') + }) + + it('surfaces a client-too-old block per verdict reason', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: 1, + serverProtocolVersion: 5, + requiredClientProtocolVersion: 4 + } + }) + expect(model.blocked).toEqual({ reason: 'client-too-old' }) + }) + + it('does not surface a block when health is not blocked', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'available', + compatibility: { kind: 'ok', clientProtocolVersion: 5, serverProtocolVersion: 5 } + }) + expect(model.blocked).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.ts b/src/renderer/src/components/sidebar/host-header-menu-items.ts new file mode 100644 index 00000000000..2117d43fc24 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-menu-items.ts @@ -0,0 +1,73 @@ +import type { ExecutionHostKind } from '../../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' + +// Why: the host-header dropdown shows different lifecycle actions per host kind. +// Keeping the availability rules in a pure function makes them unit-testable +// without rendering the sidebar. +// Why: no 'focus' action here — the host scope strip is the single scoping +// control (the design doc forbids a separate focused-host toggle), and +// decluttering is served by collapsing the section. +export type HostHeaderMenuAction = + | 'rename' + | 'manage' + | 'ssh-reconnect' + | 'ssh-disconnect' + | 'runtime-check-connection' + | 'remove' + +export type HostHeaderMenuModel = { + /** Lifecycle/navigation actions, in display order. */ + actions: HostHeaderMenuAction[] + /** Present only when the host is blocked on a compatibility verdict. */ + blocked: { + reason: 'client-too-old' | 'server-too-old' + } | null +} + +export type HostHeaderMenuInput = { + kind: ExecutionHostKind + health: ExecutionHostHealth + /** SSH connection status drives Reconnect vs Disconnect. */ + sshConnected?: boolean + compatibility?: RuntimeCompatVerdict +} + +function sshActions(connected: boolean): HostHeaderMenuAction[] { + // Why: only offer the action that changes state — Disconnect when up, + // Reconnect otherwise — to avoid a dead menu item. + return connected ? ['ssh-disconnect'] : ['ssh-reconnect'] +} + +export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeaderMenuModel { + // Why: Rename edits only the client-side display label, so it's offered for + // every host kind including local. + const actions: HostHeaderMenuAction[] = ['rename'] + + switch (input.kind) { + case 'ssh': + actions.push(...sshActions(input.sshConnected ?? false)) + break + case 'runtime': + actions.push('runtime-check-connection') + break + case 'local': + break + } + + // Manage host… always closes out the list as the catch-all deep link. + actions.push('manage') + + // Why: removing a host deletes the underlying SSH target / runtime + // environment, which only exists for those kinds — local can't be removed. + if (input.kind === 'ssh' || input.kind === 'runtime') { + actions.push('remove') + } + + const blocked = + input.health === 'blocked' && input.compatibility?.kind === 'blocked' + ? { reason: input.compatibility.reason } + : null + + return { actions, blocked } +} diff --git a/src/renderer/src/components/sidebar/host-rename-remove.test.ts b/src/renderer/src/components/sidebar/host-rename-remove.test.ts new file mode 100644 index 00000000000..96cc59210c6 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-rename-remove.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applyHostRename, + clearHostRename, + getHostDisplayLabelOverride, + resolveHostRemoval +} from './host-rename-remove' + +describe('host rename helpers', () => { + it('reads the current display-label override', () => { + const settings = { hostSettingOverrides: { 'ssh:box': { displayLabel: 'Box' } } } + expect(getHostDisplayLabelOverride(settings, 'ssh:box')).toBe('Box') + expect(getHostDisplayLabelOverride(settings, 'ssh:other')).toBeUndefined() + }) + + it('applies a rename', () => { + expect(applyHostRename({ hostSettingOverrides: {} }, 'ssh:box', 'Renamed')).toEqual({ + 'ssh:box': { displayLabel: 'Renamed' } + }) + }) + + it('clears the override when renamed to blank', () => { + const settings = { hostSettingOverrides: { 'ssh:box': { displayLabel: 'Box' } } } + expect(applyHostRename(settings, 'ssh:box', ' ')).toEqual({}) + }) + + it('resets a rename to the derived label', () => { + const settings = { + hostSettingOverrides: { + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + } + } + expect(clearHostRename(settings, 'ssh:box')).toEqual({ + 'ssh:box': { defaultWorktreeLocation: '/w' } + }) + }) +}) + +describe('resolveHostRemoval', () => { + it('resolves an ssh host to its target id', () => { + expect(resolveHostRemoval('ssh:box')).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('resolves a runtime host to its environment id', () => { + expect(resolveHostRemoval('runtime:env-1')).toEqual({ + kind: 'runtime', + environmentId: 'env-1' + }) + }) + + it('returns null for the local host', () => { + expect(resolveHostRemoval('local')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/host-rename-remove.ts b/src/renderer/src/components/sidebar/host-rename-remove.ts new file mode 100644 index 00000000000..dc1dfec6c84 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-rename-remove.ts @@ -0,0 +1,56 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + clearHostSettingOverride, + getHostSettingOverride, + setHostSettingOverride +} from '../../../../shared/host-setting-overrides' +import type { GlobalSettings, HostSettingOverrides } from '../../../../shared/types' + +type OverridesSlice = Pick<GlobalSettings, 'hostSettingOverrides'> +type OverridesMap = Partial<Record<ExecutionHostId, HostSettingOverrides>> + +/** The current user-chosen display-label override for a host, or undefined when + * the host still uses its derived label. */ +export function getHostDisplayLabelOverride( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId +): string | undefined { + return getHostSettingOverride(settings, hostId, 'displayLabel') +} + +/** Computes the next `hostSettingOverrides` after a rename. A blank label clears + * the override so the host reverts to its derived label. */ +export function applyHostRename( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId, + nextLabel: string +): OverridesMap { + return setHostSettingOverride(settings, hostId, 'displayLabel', nextLabel) +} + +/** Computes the next `hostSettingOverrides` after resetting a host's label. */ +export function clearHostRename( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId +): OverridesMap { + return clearHostSettingOverride(settings, hostId, 'displayLabel') +} + +export type HostRemovalTarget = + | { kind: 'ssh'; targetId: string } + | { kind: 'runtime'; environmentId: string } + | null + +/** Resolves how a host should be removed. SSH targets are removed inline via the + * ssh API; runtime environments deep-link into the Orca servers pane because + * their removal needs active-environment/error context that lives there. */ +export function resolveHostRemoval(hostId: ExecutionHostId): HostRemovalTarget { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'ssh') { + return { kind: 'ssh', targetId: parsed.targetId } + } + if (parsed?.kind === 'runtime') { + return { kind: 'runtime', environmentId: parsed.environmentId } + } + return null +} diff --git a/src/renderer/src/components/sidebar/host-section-order.test.ts b/src/renderer/src/components/sidebar/host-section-order.test.ts new file mode 100644 index 00000000000..9062ab1cca4 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-order.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { orderHostSectionOptions } from './host-section-order' +import type { HostSectionOption } from './host-section-rows' + +const host = (id: HostSectionOption['id'], label = id): HostSectionOption => ({ + id, + kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime', + label, + detail: 'Host', + health: id === 'local' ? 'local' : 'available' +}) + +describe('orderHostSectionOptions', () => { + it('applies persisted host order and appends newly discovered hosts', () => { + expect( + orderHostSectionOptions( + [host('local'), host('ssh:ssh-1'), host('runtime:env-1')], + ['ssh:ssh-1', 'local'] + ).map((option) => option.id) + ).toEqual(['ssh:ssh-1', 'local', 'runtime:env-1']) + }) + + it('ignores stale host ids in the persisted order', () => { + expect( + orderHostSectionOptions( + [host('local'), host('ssh:ssh-1')], + ['runtime:deleted', 'ssh:ssh-1'] + ).map((option) => option.id) + ).toEqual(['ssh:ssh-1', 'local']) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-order.ts b/src/renderer/src/components/sidebar/host-section-order.ts new file mode 100644 index 00000000000..e8cc1ad38a2 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-order.ts @@ -0,0 +1,32 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { HostSectionOption } from './host-section-rows' + +export function orderHostSectionOptions( + hostOptions: readonly HostSectionOption[], + workspaceHostOrder: readonly ExecutionHostId[] = [] +): HostSectionOption[] { + if (workspaceHostOrder.length === 0 || hostOptions.length <= 1) { + return [...hostOptions] + } + const hostById = new Map(hostOptions.map((host) => [host.id, host])) + const ordered: HostSectionOption[] = [] + const seen = new Set<ExecutionHostId>() + for (const hostId of workspaceHostOrder) { + const host = hostById.get(hostId) + if (!host || seen.has(host.id)) { + continue + } + ordered.push(host) + seen.add(host.id) + } + // Why: persisted order is only a preference for hosts the user has seen; + // newly-discovered SSH/runtime hosts should still appear without needing a + // migration or explicit reset. + for (const host of hostOptions) { + if (seen.has(host.id)) { + continue + } + ordered.push(host) + } + return ordered +} diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts new file mode 100644 index 00000000000..cb56dcd59cc --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -0,0 +1,613 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { Row } from './worktree-list-groups' +import { addHostSectionRows, type HostSectionRow } from './host-section-rows' + +function repo(id: string, connectionId?: string | null): Repo { + return { + id, + path: `/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 0, + connectionId + } +} + +function worktree(id: string, repoId: string): Worktree { + return { + id, + repoId, + path: `/${repoId}/${id}`, + branch: `refs/heads/${id}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + comment: '', + isUnread: false, + isPinned: false, + displayName: id, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function header(key: string, label = key): Extract<Row, { type: 'header' }> { + return { + type: 'header', + key, + label, + count: 1, + tone: 'text-foreground' + } +} + +function repoHeader(project: Repo): Extract<Row, { type: 'header' }> { + return { + ...header(`repo:${project.id}`, project.displayName), + repo: project + } +} + +function item(id: string, project: Repo): Extract<Row, { type: 'item' }> { + return { + type: 'item', + worktree: worktree(id, project.id), + repo: project, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 + } +} + +function folderWorkspaceRow( + connectionId: string | null +): Extract<Row, { type: 'folder-workspace' }> { + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Remote folder', + parentPath: '/srv/project', + connectionId, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: projectGroup.id, + name: 'Folder workspace', + folderPath: '/srv/project', + connectionId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + return { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: 0 + } +} + +function rowKey(row: HostSectionRow): string { + return row.type === 'item' ? row.worktree.id : row.key +} + +describe('addHostSectionRows', () => { + it('does not add host headers for a specific host scope', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'local', + defaultHostId: 'local' + }) + + expect(sectioned).toHaveLength(2) + expect(sectioned).toEqual(rows) + }) + + it('does not add host headers when only the local host exists', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + expect( + addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + ).toEqual(rows) + }) + + it('groups rows under host headers in all-host scope', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { label: 'Local Mac', count: 1 }, + { label: 'Builder', count: 1 } + ]) + }) + + it('keeps project grouping outermost in the default Projects view', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + preferProjectGrouping: true + }) + + expect(sectioned).toEqual(rows) + }) + + it('keeps host headers for a custom multi-host visibility filter', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local', 'ssh:ssh-1'], + defaultHostId: 'local', + preferProjectGrouping: true + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + }) + + it('keeps non-repo group headers with the following host-owned rows', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [header('all'), item('local-wt', local), header('done'), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'all', + 'local-wt', + 'host:ssh:ssh-1', + 'done', + 'ssh-wt' + ]) + }) + + it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const runtimeOwned: Repo = { ...repo('remote-project'), executionHostId: 'runtime:env-2' } + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(runtimeOwned), + item('remote-wt', runtimeOwned) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + }, + { + id: 'runtime:env-2', + kind: 'runtime', + label: 'env-2', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local-project', + 'local-wt', + 'host:runtime:env-2', + 'repo:remote-project', + 'remote-wt' + ]) + }) + + it('groups SSH folder workspace rows under their connection host', () => { + const local = repo('local') + const rows: Row[] = [repoHeader(local), item('local-wt', local), folderWorkspaceRow('ssh-1')] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'folder-workspace:folder-1' + ]) + }) + + it('carries the SSH connection status through to the host header row', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'ssh:ssh-1', + kind: 'ssh', + label: 'Builder', + detail: 'SSH', + health: 'error', + connectionStatus: 'auth-failed' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') + ).toMatchObject({ + health: 'error', + connectionStatus: 'auth-failed', + collapsed: false + }) + }) + + it('uses the focused runtime as the owner for non-SSH repos', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const project = repo('runtime-project') + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(project), + item('runtime-wt', project) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') + ).toMatchObject({ + key: 'host:runtime:env-1', + label: 'env-1' + }) + }) + + it('passes host kind and blocked compatibility through to the header row', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const project = repo('runtime-project') + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(project), + item('runtime-wt', project) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') + ).toMatchObject({ + kind: 'runtime', + health: 'blocked', + compatibility: { kind: 'blocked', reason: 'server-too-old' } + }) + }) + + it('suppresses host headers when only one host has visible workspaces', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'disconnected' }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned).toEqual(rows) + }) + + it('counts a collapsed repo group via its header count instead of zero', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + // The ssh repo group is collapsed: its header is present, items are not. + const collapsedSshHeader = { ...repoHeader(ssh), count: 9 } + const rows = [repoHeader(local), item('local-wt', local), collapsedSshHeader] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') + ).toMatchObject({ count: 9 }) + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') + ).toMatchObject({ count: 1 }) + }) + + it('keeps a collapsed host header but hides its rows', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(['host:ssh:ssh-1']) + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: false }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } + ]) + }) + + it('can temporarily collapse every host without mutating persisted collapse keys', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(), + forceCollapseHosts: true + }) + + expect(sectioned.map(rowKey)).toEqual(['host:local', 'host:ssh:ssh-1']) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: true, count: 1 }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts new file mode 100644 index 00000000000..fecd7ea5d86 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -0,0 +1,224 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + type ExecutionHostId, + type ExecutionHostKind, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import type { Row } from './worktree-list-groups' + +export type HostHeaderRow = { + type: 'host-header' + key: string + hostId: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + // Why: blocked-host guidance in the header menu needs the verdict reason so + // it can deep-link an "Update server/client required" row per skew direction. + compatibility?: RuntimeCompatVerdict + connectionStatus?: SshConnectionStatus + collapsed: boolean + count: number +} + +export type HostSectionRow = Row | HostHeaderRow + +export type HostSectionOption = { + id: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + compatibility?: RuntimeCompatVerdict + connectionStatus?: SshConnectionStatus +} + +function getRepoHostId( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + defaultHostId: ExecutionHostId +): ExecutionHostId { + // Why: explicit executionHostId must win over the focused/default host, or + // runtime-owned repos group under whichever host happens to be focused. + if (repo?.connectionId || repo?.executionHostId) { + return getRepoExecutionHostId(repo) + } + return defaultHostId +} + +function getSshHostId(connectionId: string): ExecutionHostId { + return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId +} + +function getFolderWorkspaceHostId( + folderWorkspace: Pick<FolderWorkspace, 'connectionId'>, + projectGroup: Pick<ProjectGroup, 'connectionId'>, + defaultHostId: ExecutionHostId +): ExecutionHostId { + const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId + return connectionId ? getSshHostId(connectionId) : defaultHostId +} + +function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { + switch (row.type) { + case 'item': + return getRepoHostId(row.repo, defaultHostId) + case 'pending-creation': + case 'imported-worktrees-card': + return getRepoHostId(row.repo, defaultHostId) + case 'folder-workspace': + return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId) + case 'header': + return row.repo ? getRepoHostId(row.repo, defaultHostId) : null + } +} + +function getFallbackHost(hostId: ExecutionHostId): HostSectionOption { + const isLocal = hostId === LOCAL_EXECUTION_HOST_ID + return { + id: hostId, + kind: isLocal ? 'local' : hostId.startsWith('ssh:') ? 'ssh' : 'runtime', + label: isLocal ? 'Local Mac' : hostId, + detail: isLocal ? 'This computer' : 'Host', + health: isLocal ? 'local' : 'available' + } +} + +function countWorktreeRows(rows: readonly Row[]): number { + // Why: a collapsed repo group contributes a header row but no item rows; + // fall back to the header's own count so the host badge doesn't read 0 + // while a visibly populated project sits right under it. + let count = 0 + let pendingHeaderCount: number | null = null + let pendingHeaderHadItems = false + const flushHeader = (): void => { + if (pendingHeaderCount !== null && !pendingHeaderHadItems) { + count += pendingHeaderCount + } + pendingHeaderCount = null + pendingHeaderHadItems = false + } + for (const row of rows) { + if (row.type === 'header') { + flushHeader() + pendingHeaderCount = row.count + continue + } + if (row.type === 'item') { + count += 1 + pendingHeaderHadItems = pendingHeaderCount !== null + } + } + flushHeader() + return count +} + +export function addHostSectionRows(args: { + rows: readonly Row[] + hostOptions: readonly HostSectionOption[] + workspaceHostScope: ExecutionHostScope + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + defaultHostId: ExecutionHostId + // Why: host sections reuse the sidebar's persisted collapsed-group keys + // (`host:<hostId>`) so collapse state survives restarts like other groups. + collapsedHostKeys?: ReadonlySet<string> + forceCollapseHosts?: boolean + // Why: in the default Projects view, project is the user's primary object + // and host is context inside it. Explicit host filters still keep host + // headers as an operational/troubleshooting view. + preferProjectGrouping?: boolean +}): HostSectionRow[] { + const visibleHostIds = + args.visibleWorkspaceHostIds ?? + (args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [args.workspaceHostScope]) + if ( + args.preferProjectGrouping && + args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE && + !args.visibleWorkspaceHostIds + ) { + return [...args.rows] + } + if ((visibleHostIds && visibleHostIds.length <= 1) || args.hostOptions.length <= 1) { + return [...args.rows] + } + + const hostOptionsById = new Map(args.hostOptions.map((host) => [host.id, host])) + const rowsByHostId = new Map<ExecutionHostId, Row[]>() + const globalRows: Row[] = [] + let pendingRows: Row[] = [] + + for (const row of args.rows) { + const rowHostId = getRowHostId(row, args.defaultHostId) + if (rowHostId) { + const hostRows = rowsByHostId.get(rowHostId) ?? [] + if (pendingRows.length > 0) { + hostRows.push(...pendingRows) + pendingRows = [] + } + hostRows.push(row) + rowsByHostId.set(rowHostId, hostRows) + continue + } + // Why: status/"All" headers describe the rows that follow. Buffer them + // until the next host-owned row so host remains above the existing grouping. + pendingRows.push(row) + } + + if (pendingRows.length > 0) { + globalRows.push(...pendingRows) + } + + const hostOrder: ExecutionHostId[] = [] + for (const host of args.hostOptions) { + if (rowsByHostId.has(host.id)) { + hostOrder.push(host.id) + } + } + for (const hostId of rowsByHostId.keys()) { + if (!hostOptionsById.has(hostId)) { + hostOrder.push(hostId) + } + } + + // Why: a lone host section is pure noise — the grouping only earns its keep + // when there are at least two host sections to tell apart. Registered-but- + // empty hosts stay visible in the scope picker, not as headers. + if (rowsByHostId.size <= 1) { + return [...args.rows] + } + + const result: HostSectionRow[] = [...globalRows] + for (const hostId of hostOrder) { + const hostRows = rowsByHostId.get(hostId) + if (!hostRows || hostRows.length === 0) { + continue + } + const host = hostOptionsById.get(hostId) ?? getFallbackHost(hostId) + const collapsed = + args.forceCollapseHosts || (args.collapsedHostKeys?.has(`host:${host.id}`) ?? false) + result.push({ + type: 'host-header', + key: `host:${host.id}`, + hostId: host.id, + kind: host.kind, + label: host.label, + detail: host.detail, + health: host.health, + compatibility: host.compatibility, + connectionStatus: host.connectionStatus, + collapsed, + count: countWorktreeRows(hostRows) + }) + if (!collapsed) { + result.push(...hostRows) + } + } + + return result +} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 40ee26fe44b..d51dd2c2924 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -1,9 +1,8 @@ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect } from 'react' import { useAppStore } from '@/store' import { TooltipProvider } from '@/components/ui/tooltip' import { useSidebarResize } from '@/hooks/useSidebarResize' import SidebarHeader from './SidebarHeader' -import ProjectOrderManualDefaultNotice from './ProjectOrderManualDefaultNotice' import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' @@ -14,8 +13,6 @@ import { cn } from '@/lib/utils' import { FolderPlus, Loader2 } from 'lucide-react' import { useSidebarProjectDrop } from './useSidebarProjectDrop' import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' -import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' -import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog')) const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog')) @@ -45,15 +42,9 @@ function Sidebar({ const sidebarWidth = useAppStore((s) => s.sidebarWidth) const setSidebarWidth = useAppStore((s) => s.setSidebarWidth) const repos = useAppStore((s) => s.repos) - const settings = useAppStore((s) => s.settings) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) const activeModal = useAppStore((s) => s.activeModal) const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() - const systemPrefersDark = useSystemPrefersDark() - const leftSidebarStyle = useMemo( - () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), - [settings, systemPrefersDark] - ) as React.CSSProperties | undefined const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false) const unmountAddRepoDialogTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null) const { @@ -124,7 +115,6 @@ function Sidebar({ ref={containerRef} data-native-file-drop-target={sidebarOpen ? nativeDropTarget : undefined} className="relative min-h-0 flex-shrink-0 bg-worktree-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent" - style={leftSidebarStyle} {...dropHandlers} > {sidebarOpen && ( @@ -132,7 +122,6 @@ function Sidebar({ {/* Fixed controls */} <SidebarNav /> <SidebarHeader onWorkspaceBoardMenuOpenChange={setWorkspaceBoardMenuOpen} /> - <ProjectOrderManualDefaultNotice /> <WorktreeList scrollOffsetRef={worktreeScrollOffsetRef} @@ -192,7 +181,6 @@ function Sidebar({ </React.Suspense> {sidebarOpen ? ( <WorkspaceKanbanDrawer - leftSidebarStyle={leftSidebarStyle} open={workspaceBoardOpen} preserveOpenForMenu={workspaceBoardMenuOpen} onOpenChange={handleWorkspaceBoardOpenChange} diff --git a/src/renderer/src/components/sidebar/open-setup-script-settings.ts b/src/renderer/src/components/sidebar/open-setup-script-settings.ts new file mode 100644 index 00000000000..e9918b502dc --- /dev/null +++ b/src/renderer/src/components/sidebar/open-setup-script-settings.ts @@ -0,0 +1,19 @@ +import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets' + +export function openSetupScriptSettings(input: { + repoId: string + setSettingsSearchQuery: (query: string) => void + openSettingsTarget: (target: { pane: 'repo'; repoId: string; sectionId: string }) => void + openSettingsPage: () => void +}): void { + const { openSettingsPage, openSettingsTarget, repoId, setSettingsSearchQuery } = input + // Why: imported setup commands are local repo settings; a stale Settings + // search should not hide the exact editor this action opens. + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'repo', + repoId, + sectionId: getRepositoryLocalCommandsSectionId(repoId) + }) + openSettingsPage() +} diff --git a/src/renderer/src/components/sidebar/project-header-drag.test.ts b/src/renderer/src/components/sidebar/project-header-drag.test.ts index ff251e7da65..b53a4e868bc 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.test.ts @@ -1,32 +1,7 @@ // @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' -import { - isProjectHeaderDragHandleTarget, - isRepoHeaderActionTarget, - useRepoHeaderDrag -} from './project-header-drag' -import type { Repo } from '../../../../shared/types' - -function createRepo(id: string, projectGroupId: string | null = null): Repo { - return { - id, - path: `/tmp/${id}`, - displayName: id, - badgeColor: '#000000', - addedAt: 0, - projectGroupId, - projectGroupOrder: 0 - } -} - -function createPointerEvent(type: string, init: MouseEventInit & { pointerId: number }): Event { - const event = new MouseEvent(type, { bubbles: true, ...init }) - Object.defineProperty(event, 'pointerId', { value: init.pointerId }) - return event -} +import { isRepoHeaderActionTarget } from './project-header-drag' function createHeader(markup: string): HTMLElement { const header = document.createElement('div') @@ -60,95 +35,3 @@ describe('repo header action targets', () => { expect(isRepoHeaderActionTarget(header, header)).toBe(false) }) }) - -describe('project header drag handle targets', () => { - it('accepts pointer events on the project name handle', () => { - const header = createHeader(` - <span data-repo-header-drag-handle="" id="handle">Orca</span> - <span id="chevron"></span> - `) - - const handle = header.querySelector('#handle') as HTMLElement - expect(isProjectHeaderDragHandleTarget(handle, handle)).toBe(true) - }) - - it('rejects pointer events outside the project name handle', () => { - const header = createHeader(` - <span data-repo-header-drag-handle="" id="handle">Orca</span> - <span id="chevron"></span> - `) - - expect(isProjectHeaderDragHandleTarget(header.querySelector('#chevron'), header)).toBe(false) - }) -}) - -describe('repo header drag pointer capture', () => { - it('captures the pointer only after crossing the drag threshold', async () => { - const scrollContainer = document.createElement('div') - document.body.appendChild(scrollContainer) - const repoById = new Map<string, Repo>([ - ['repo-a', createRepo('repo-a')], - ['repo-b', createRepo('repo-b')] - ]) - const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) - const setPointerCapture = vi.fn() - - function DragHarness(): React.ReactElement { - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: ['repo-a', 'repo-b'], - sidebarRepoHeaderIdsByBucket, - repoById, - usesProjectGroupOrdering: false, - onCommitRepoOrder: vi.fn(), - onCommitProjectGroupOrder: vi.fn(), - getScrollContainer: () => scrollContainer - }) - - return createElement('div', { - 'data-repo-header-drag-handle': '', - 'data-repo-header-id': 'repo-a', - 'data-repo-header-index': 0, - 'data-repo-header-bucket': 'ungrouped', - onPointerDown: (event: React.PointerEvent<HTMLElement>) => - repoDrag.onHandlePointerDown(event, 'repo-a'), - ref: (element: HTMLDivElement | null) => { - if (element) { - element.setPointerCapture = setPointerCapture - } - } - }) - } - - const root = createRoot(scrollContainer) - await act(async () => { - root.render(createElement(DragHarness)) - }) - const handle = scrollContainer.querySelector<HTMLElement>('[data-repo-header-drag-handle]') - expect(handle).not.toBeNull() - - await act(async () => { - handle!.dispatchEvent( - createPointerEvent('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 7 }) - ) - }) - expect(setPointerCapture).not.toHaveBeenCalled() - - await act(async () => { - window.dispatchEvent( - createPointerEvent('pointermove', { clientX: 12, clientY: 12, pointerId: 7 }) - ) - }) - expect(setPointerCapture).not.toHaveBeenCalled() - - await act(async () => { - window.dispatchEvent( - createPointerEvent('pointermove', { clientX: 20, clientY: 20, pointerId: 7 }) - ) - }) - expect(setPointerCapture).toHaveBeenCalledWith(7) - - await act(async () => { - root.unmount() - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 0c424ebec08..ac84dc620be 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -1,68 +1,105 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { - computeProjectHeaderDropPreview, - measureProjectHeaderDragRects -} from './project-header-drop' -import { commitProjectHeaderDragDrop } from './project-header-drag-commit' -import { - INITIAL_REPO_DRAG_STATE, - PROJECT_HEADER_DRAG_THRESHOLD_PX, - type ProjectHeaderDragSession, - type RepoDragState, - type RepoHeaderDragController, - type UseRepoHeaderDragArgs -} from './project-header-drag-contract' -import { createProjectHeaderDragSession } from './project-header-drag-start' -import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' - // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire // against stale targets. With pointer events we cache the active set of repo // header positions and compute the drop index from the live pointer Y. +export type RepoDragState = { + draggingRepoId: string | null + // Insertion index in the orderedRepoIds array where the dragged repo would + // land if released now. null while not dragging. + dropIndex: number | null + // Y coordinate (in scrollContainer's local space, i.e. relative to its + // top-left content origin including current scrollTop offset) where the + // insertion bar should be drawn. null while not dragging. + dropIndicatorY: number | null +} + +const INITIAL_STATE: RepoDragState = { + draggingRepoId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseRepoHeaderDragArgs = { + orderedRepoIds: string[] + onCommit: (orderedIds: string[]) => void + // Returns the scroll container that hosts the virtualized rows. Bounding + // rects are read from this element so insertion-bar Y values stay correct + // when the sidebar is resized. + getScrollContainer: () => HTMLElement | null +} + +type HeaderRect = { + repoId: string + // top/bottom in scrollContainer-local space (page-coord top minus container + // page-coord top, plus current scrollTop). + top: number + bottom: number +} + +export type RepoHeaderDragController = { + state: RepoDragState + // Call from the repo header's onPointerDown. The drag does NOT start + // immediately — it arms a pending session that promotes to an active drag + // only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before + // promotion releases without committing, so the surrounding click handler + // still fires and toggles the group's collapsed state. + onHandlePointerDown: (event: React.PointerEvent<HTMLElement>, repoId: string) => void +} + +// Pixels the pointer must travel before we promote a pending press into a +// real drag. Below this we treat the press as a normal click (toggle group). +const DRAG_THRESHOLD_PX = 4 +const REPO_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isRepoHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return currentTarget.contains(target) && target.closest(REPO_HEADER_ACTION_SELECTOR) !== null +} + export function useRepoHeaderDrag({ orderedRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById, - usesProjectGroupOrdering, - onCommitRepoOrder, - onCommitProjectGroupOrder, + onCommit, getScrollContainer }: UseRepoHeaderDragArgs): RepoHeaderDragController { - const [state, setState] = useState<RepoDragState>(INITIAL_REPO_DRAG_STATE) + const [state, setState] = useState<RepoDragState>(INITIAL_STATE) + // Tracks whether a press has begun (armed) regardless of promotion. Used + // only to gate window listeners; visible drag state lives in `state`. const [sessionArmed, setSessionArmed] = useState(false) + // Why: endDrag reads dropIndex on pointerup, but binding the listener with + // dropIndex in deps would re-add window listeners on every pointermove. + // The ref tracks the latest computed value without invalidating the effect. const latestDropIndexRef = useRef<number | null>(null) latestDropIndexRef.current = state.dropIndex + // Keep callbacks stable: they read from refs so we don't re-bind window + // listeners every render. const orderedIdsRef = useRef(orderedRepoIds) orderedIdsRef.current = orderedRepoIds - const sidebarRepoHeaderIdsByBucketRef = useRef(sidebarRepoHeaderIdsByBucket) - sidebarRepoHeaderIdsByBucketRef.current = sidebarRepoHeaderIdsByBucket - const repoByIdRef = useRef(repoById) - repoByIdRef.current = repoById - const usesProjectGroupOrderingRef = useRef(usesProjectGroupOrdering) - usesProjectGroupOrderingRef.current = usesProjectGroupOrdering - const onCommitRepoOrderRef = useRef(onCommitRepoOrder) - onCommitRepoOrderRef.current = onCommitRepoOrder - const onCommitProjectGroupOrderRef = useRef(onCommitProjectGroupOrder) - onCommitProjectGroupOrderRef.current = onCommitProjectGroupOrder + const onCommitRef = useRef(onCommit) + onCommitRef.current = onCommit const getContainerRef = useRef(getScrollContainer) getContainerRef.current = getScrollContainer - const autoscrollLastFrameTimeRef = useRef<number | null>(null) - const autoscrollFrameIdRef = useRef<number | null>(null) - const dragSessionRef = useRef<ProjectHeaderDragSession | null>(null) - - const refreshHeaderRects = useCallback(() => { - const container = getContainerRef.current() - const session = dragSessionRef.current - if (!container || !session) { - return [] - } - const rects = measureProjectHeaderDragRects(container, session.bucketKey) - session.headerRects = rects - return rects - }, []) + const dragSessionRef = useRef<{ + repoId: string + pointerId: number + headerRects: HeaderRect[] + handleEl: HTMLElement + startX: number + startY: number + // false until the pointer moves past DRAG_THRESHOLD_PX. While false the + // session exists but no drop indicator is shown and pointerup is treated + // as a click rather than a drop. + promoted: boolean + } | null>(null) const computeDrop = useCallback( (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { @@ -72,123 +109,107 @@ export function useRepoHeaderDrag({ return null } const containerRect = container.getBoundingClientRect() - return computeProjectHeaderDropPreview({ - pointerY, - containerTop: containerRect.top, - scrollTop: container.scrollTop, - rects: session.headerRects, - sidebarRepoHeaderIds: session.sidebarRepoHeaderIds - }) + // Translate pointer to container-local coords + scroll. + const localY = pointerY - containerRect.top + container.scrollTop + const rects = session.headerRects + if (rects.length === 0) { + return null + } + // Find the first header whose midpoint is below the pointer. + let insertBefore = rects.length + for (let i = 0; i < rects.length; i++) { + const mid = (rects[i].top + rects[i].bottom) / 2 + if (localY < mid) { + insertBefore = i + break + } + } + // Why anchor to the target header (not midpoint between headers): the + // space between two project group headers is filled with worktree cards, + // so the midpoint falls *inside another repo's content*. Sitting the + // indicator just above the target header keeps it at the visual top of + // where the dragged group would land. + const INDICATOR_GAP_PX = 4 + const rawIndicatorY = + insertBefore >= rects.length + ? rects.at(-1)!.bottom + INDICATOR_GAP_PX + : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) + // Why: while scrolled, the topmost mounted header is pinned flush at the + // container top, so `top - GAP` lands above the overflow clip region and + // the line is painted invisibly. Floor the indicator at the current + // scroll offset so a top-of-list drop stays visible just below the edge. + const indicatorY = Math.max(container.scrollTop, rawIndicatorY) + return { dropIndex: insertBefore, dropIndicatorY: indicatorY } }, [] ) - const cancelAutoscroll = useCallback(() => { - if (autoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(autoscrollFrameIdRef.current) - autoscrollFrameIdRef.current = null - } - autoscrollLastFrameTimeRef.current = null - }, []) - - const endDrag = useCallback( - (commit: boolean) => { - cancelAutoscroll() - const session = dragSessionRef.current - if (!session) { - setState(INITIAL_REPO_DRAG_STATE) - setSessionArmed(false) - return - } - try { - session.handleEl.releasePointerCapture(session.pointerId) - } catch { - // capture may already be released (pointercancel, element unmounted) - } - if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - setTimeout(() => window.removeEventListener('click', swallow, true), 0) - } - const sidebarDropIndex = - commit && session.promoted && latestDropIndexRef.current !== null - ? latestDropIndexRef.current - : null - dragSessionRef.current = null - setState(INITIAL_REPO_DRAG_STATE) + const endDrag = useCallback((commit: boolean) => { + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_STATE) setSessionArmed(false) - if (sidebarDropIndex === null) { - return - } - - commitProjectHeaderDragDrop({ - session, - sidebarDropIndex, - orderedRepoIds: orderedIdsRef.current, - repoById: repoByIdRef.current, - usesProjectGroupOrdering: usesProjectGroupOrderingRef.current, - onCommitRepoOrder: onCommitRepoOrderRef.current, - onCommitProjectGroupOrder: onCommitProjectGroupOrderRef.current - }) - }, - [cancelAutoscroll] - ) - - const runAutoscrollFrame = useCallback( - (frameTime: number) => { - autoscrollFrameIdRef.current = null - const session = dragSessionRef.current - const container = getContainerRef.current() - if (!session?.promoted || !container) { - cancelAutoscroll() - return - } - - const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime - autoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point: { clientX: 0, clientY: session.latestPointerY }, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - container.scrollTop = autoscroll.scrollTop - refreshHeaderRects() - } - - const drop = computeDrop(session.latestPointerY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) - } - - autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) - }, - [cancelAutoscroll, computeDrop, refreshHeaderRects] - ) - - const ensureAutoscroll = useCallback(() => { - if (autoscrollFrameIdRef.current !== null) { return } - autoscrollLastFrameTimeRef.current = null - autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) - }, [runAutoscrollFrame]) + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + // After a real drag, the browser still fires a click on the header. + // Swallow exactly one click in capture phase so it doesn't toggle the + // group's collapsed state. Scope to the dragged handle (and ancestors) + // so an unrelated click that races between pointerup and the failsafe + // teardown isn't silently eaten. + const handleEl = session.handleEl + const swallow = (e: MouseEvent): void => { + const target = e.target as Node | null + if (target && handleEl.contains(target)) { + e.stopPropagation() + e.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + // Failsafe: if no click ever arrives (e.g. pointercancel), drop the + // listener after a tick so future clicks aren't silenced. + setTimeout(() => window.removeEventListener('click', swallow, true), 0) + } + // Only commit a reorder if the press was promoted into a real drag — + // otherwise the press was effectively a click, and the surrounding + // header onClick handler will toggle collapse. + const finalIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_STATE) + setSessionArmed(false) + if (finalIndex === null) { + return + } + const ids = orderedIdsRef.current + const fromIndex = ids.indexOf(session.repoId) + if (fromIndex === -1) { + return + } + // Splice fromIndex out, then insert at finalIndex (adjusting if the + // removal shifted indices). + const next = ids.slice() + next.splice(fromIndex, 1) + const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex + if (insertAt === fromIndex) { + return + } + next.splice(insertAt, 0, session.repoId) + onCommitRef.current(next) + }, []) + // Window-level listeners while a session is armed — pointer capture on the + // header element ensures the events still fire even if the pointer leaves + // it. The session may be unpromoted (waiting for a movement past the + // threshold to become a real drag) or promoted (drop indicator visible). useEffect(() => { if (!sessionArmed) { return @@ -198,36 +219,24 @@ export function useRepoHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } - session.latestPointerY = e.clientY if (!session.promoted) { const dx = e.clientX - session.startX const dy = e.clientY - session.startY - if ( - dx * dx + dy * dy < - PROJECT_HEADER_DRAG_THRESHOLD_PX * PROJECT_HEADER_DRAG_THRESHOLD_PX - ) { + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { return } session.promoted = true - try { - session.handleEl.setPointerCapture(session.pointerId) - } catch { - // setPointerCapture can throw if the element is detached; the global - // pointer listeners still fire, so dragging keeps working. - } - refreshHeaderRects() setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) } - refreshHeaderRects() const drop = computeDrop(e.clientY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) + if (!drop) { + return } - ensureAutoscroll() + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingRepoId: session.repoId, ...drop } + ) } const onPointerUp = (e: PointerEvent): void => { const session = dragSessionRef.current @@ -261,12 +270,15 @@ export function useRepoHeaderDrag({ window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('blur', onBlur) - cancelAutoscroll() } - }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + }, [sessionArmed, computeDrop, endDrag]) + // From pointerdown onward (armed session, before/after promotion) force a + // grabbing cursor and disable text selection across the whole window so + // the user gets immediate feedback that the press registered, even before + // they've moved past DRAG_THRESHOLD_PX. useEffect(() => { - if (state.draggingRepoId === null) { + if (!sessionArmed) { return } const body = document.body @@ -278,21 +290,63 @@ export function useRepoHeaderDrag({ body.style.cursor = prevCursor body.style.userSelect = prevUserSelect } - }, [state.draggingRepoId]) + }, [sessionArmed]) const onHandlePointerDown = useCallback( (event: React.PointerEvent<HTMLElement>, repoId: string) => { - const session = createProjectHeaderDragSession({ - event, - repoId, - repoById: repoByIdRef.current, - sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current, - getScrollContainer: getContainerRef.current - }) - if (!session) { + // Only react to primary button. Ignore right/middle clicks. + if (event.button !== 0) { return } - dragSessionRef.current = session + // Don't intercept presses from nested action surfaces; Radix triggers + // and disabled wrappers are not always plain button descendants. + if (isRepoHeaderActionTarget(event.target, event.currentTarget)) { + return + } + const container = getContainerRef.current() + if (!container) { + return + } + // Snapshot every repo header's position in scrollContainer-local space. + // Using a snapshot (vs reading the DOM each pointermove) means the drop + // computation does not depend on those rows still being mounted — + // critical because react-virtual will unmount them as the user scrolls. + const containerRect = container.getBoundingClientRect() + const headerEls = container.querySelectorAll<HTMLElement>('[data-repo-header-id]') + const headerRects: HeaderRect[] = [] + headerEls.forEach((el) => { + const id = el.getAttribute('data-repo-header-id') + if (!id) { + return + } + const rect = el.getBoundingClientRect() + headerRects.push({ + repoId: id, + top: rect.top - containerRect.top + container.scrollTop, + bottom: rect.bottom - containerRect.top + container.scrollTop + }) + }) + headerRects.sort((a, b) => a.top - b.top) + + const handleEl = event.currentTarget + try { + handleEl.setPointerCapture(event.pointerId) + } catch { + // setPointerCapture can throw if the element is detached; the global + // pointer listeners still fire, so dragging keeps working. + } + dragSessionRef.current = { + repoId, + pointerId: event.pointerId, + headerRects, + handleEl, + startX: event.clientX, + startY: event.clientY, + promoted: false + } + // Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a + // simple click on the header still toggles collapse via the surrounding + // onClick handler. setSessionArmed(true) }, [] @@ -300,8 +354,3 @@ export function useRepoHeaderDrag({ return { state, onHandlePointerDown } } - -export { - isRepoHeaderActionTarget, - isProjectHeaderDragHandleTarget -} from './project-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts new file mode 100644 index 00000000000..1f62941d592 --- /dev/null +++ b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts @@ -0,0 +1,39 @@ +import { track } from '@/lib/telemetry' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' +import { buildSetupScriptPromptTelemetry } from '../../../../shared/setup-script-telemetry' + +export function trackSetupScriptPromptExposure(input: { + repoId: string + promptState: SetupScriptPromptInspection | null + trackedPromptKeys: Set<string> +}): void { + const { promptState, repoId, trackedPromptKeys } = input + if ( + promptState?.repoId !== repoId || + promptState.status !== 'ok' || + promptState.hasEffectiveSetup + ) { + return + } + + const telemetry = buildSetupScriptPromptTelemetry({ + candidate: promptState.candidate, + hasSharedHooks: promptState.hasSharedHooks + }) + // Why: React may re-render the sidebar often; this event should represent + // a distinct prompt exposure for this repo/source, not render churn. + const promptKey = [ + repoId, + telemetry.mode, + telemetry.provider ?? 'none', + telemetry.file_count_bucket, + telemetry.unsupported_field_count_bucket, + String(telemetry.has_shared_hooks) + ].join(':') + if (trackedPromptKeys.has(promptKey)) { + return + } + + trackedPromptKeys.add(promptKey) + track('setup_script_prompt_shown', telemetry) +} diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts new file mode 100644 index 00000000000..4d7f67e068e --- /dev/null +++ b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react' +import { getProjectHostSetupForRepo } from '../../../../shared/project-host-setup-projection' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' + +export type SetupScriptPromptState = SetupScriptPromptInspection + +export type LastVisibleSetupScriptPrompt = { + state: SetupScriptPromptState + projectId: string | null +} + +export function getRepoProjectId( + repoId: string, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[], + setupByRepoId: Map<string, { projectId: string }> +): string | null { + const setup = setupByRepoId.get(repoId) + if (setup) { + return setup.projectId + } + const repo = repos.find((candidate) => candidate.id === repoId) + return repo ? getProjectHostSetupForRepo(projectHostSetups, repo).projectId : null +} + +export function getRenderedSetupScriptPromptState(input: { + promptState: SetupScriptPromptState | null + activeRepoId: string + activeProjectId: string | null + lastVisiblePrompt: LastVisibleSetupScriptPrompt | null +}): SetupScriptPromptState | null { + const { activeProjectId, activeRepoId, lastVisiblePrompt, promptState } = input + if (promptState?.repoId === activeRepoId) { + return promptState + } + return !promptState && lastVisiblePrompt?.projectId === activeProjectId + ? lastVisiblePrompt.state + : null +} + +export function useSetupScriptPromptProjectContext( + activeRepo: Repo | null, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[] +): { + activeProjectId: string | null + setupByRepoId: Map<string, { projectId: string }> +} { + const setupByRepoId = useMemo( + () => new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + [projectHostSetups] + ) + const activeProjectId = useMemo(() => { + if (!activeRepo) { + return null + } + return getRepoProjectId(activeRepo.id, repos, projectHostSetups, setupByRepoId) + }, [activeRepo, projectHostSetups, repos, setupByRepoId]) + return { activeProjectId, setupByRepoId } +} diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts new file mode 100644 index 00000000000..46b9671984e --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + buildSidebarHostOptions, + buildSidebarHostScopeOptions, + getSidebarHostVisibilityLabel, + getSidebarHostHealthLabel, + shouldShowHostScopeControls +} from './sidebar-host-options' + +describe('sidebar host options', () => { + it('hides host controls for local-only workspaces', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: null }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts).toEqual([ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + } + ]) + expect(shouldShowHostScopeControls(hosts)).toBe(false) + }) + + it('includes SSH hosts from labels and repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-from-repo' }], + sshTargetLabels: new Map([['ssh-saved', 'Saved SSH']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.map((host) => host.id)).toEqual(['local', 'ssh:ssh-saved', 'ssh:ssh-from-repo']) + expect(hosts.map((host) => host.health)).toEqual(['local', 'disconnected', 'disconnected']) + expect(hosts.find((host) => host.id === 'ssh:ssh-saved')?.presence).toBe('configured') + expect(hosts.find((host) => host.id === 'ssh:ssh-from-repo')?.presence).toBe('project') + expect(shouldShowHostScopeControls(hosts)).toBe(true) + }) + + it('includes SSH health in options', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.find((host) => host.id === 'ssh:ssh-1')).toMatchObject({ + label: 'Builder', + health: 'available' + }) + }) + + it('includes the focused runtime compatibility host', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' } + }) + + expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) + expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ + detail: 'Orca server', + health: 'available' + }) + }) + + it('uses saved runtime environment names for runtime host labels', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: '03ef704c-b180-4b10-998d-e28fbd5de9a3' }, + runtimeEnvironments: [ + { + id: '03ef704c-b180-4b10-998d-e28fbd5de9a3', + name: 'dev box' + } + ] + }) + + expect(hosts.find((host) => host.id.startsWith('runtime:'))).toMatchObject({ + label: 'dev box', + detail: 'Orca server' + }) + }) + + it('marks a runtime host blocked when its live status fails compat', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + // Why: protocol 0 is below the minimum compatible server version, so the + // registry must surface a 'server-too-old' blocked verdict + health when + // the live status map is passed. + runtimeStatusByEnvironmentId: new Map([ + [ + 'runtime-1', + { + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 0, + minCompatibleRuntimeClientVersion: 0 + } + } + ] + ]) + }) + + const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') + expect(runtimeHost?.health).toBe('blocked') + expect(runtimeHost?.compatibility).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old' + }) + }) + + it('leaves a runtime host available when its live status is compatible', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + runtimeStatusByEnvironmentId: new Map([ + [ + 'runtime-1', + { + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3 + } + } + ] + ]) + }) + + const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') + expect(runtimeHost?.health).toBe('available') + expect(runtimeHost?.compatibility?.kind).toBe('ok') + }) + + it('builds all-host plus focused-host scope options', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([ + { id: 'all', label: 'All hosts', detail: 'Local Mac, Builder', health: 'mixed' }, + { id: 'local', label: 'Local Mac', health: 'local' }, + { id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' } + ]) + }) + + it('labels visible host selections for the workspace options menu', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getSidebarHostVisibilityLabel(null, hosts)).toBe('All hosts') + expect(getSidebarHostVisibilityLabel(['ssh:ssh-1'], hosts)).toBe('Builder') + expect(getSidebarHostVisibilityLabel(['local', 'ssh:ssh-1'], hosts)).toBe('All hosts') + }) + + it('carries host kind so the header menu can pick lifecycle actions', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: 'runtime-1' } + }) + + expect(hosts.find((host) => host.id === 'local')?.kind).toBe('local') + expect(hosts.find((host) => host.id === 'ssh:ssh-1')?.kind).toBe('ssh') + expect(hosts.find((host) => host.id === 'runtime:runtime-1')?.kind).toBe('runtime') + }) + + it('labels host health for compact sidebar UI', () => { + expect(getSidebarHostHealthLabel('available')).toBe('Connected') + expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting') + expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed') + expect(getSidebarHostHealthLabel('error')).toBe('Needs attention') + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts new file mode 100644 index 00000000000..248bd98000d --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -0,0 +1,162 @@ +import type { GlobalSettings, Repo, WorkspaceHostScope } from '../../../../shared/types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + buildExecutionHostRegistry, + type ExecutionHostHealth +} from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { translate } from '@/i18n/i18n' + +export type SidebarHostOption = { + id: ExecutionHostId + label: string + detail: string + kind: 'local' | 'ssh' | 'runtime' + health: ExecutionHostHealth + presence: 'local' | 'configured' | 'project' | 'active' + // Why: surfaced to the sidebar host-header menu so it can warn on version skew. + compatibility?: RuntimeCompatVerdict + // Why: lets host headers spell out auth-needed SSH states, not just an icon. + connectionStatus?: SshConnectionStatus +} + +export type SidebarHostScopeOption = { + id: WorkspaceHostScope + label: string + detail: string + health: ExecutionHostHealth | 'mixed' +} + +export function buildSidebarHostOptions(args: { + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] + sshTargetLabels: ReadonlyMap<string, string> + sshConnectionStates?: ReadonlyMap<string, SshConnectionState> + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + // Why: live per-environment runtime status lets the registry surface compat + // verdicts and blocked health in the sidebar without re-probing servers. + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status?: RuntimeStatus | null; appVersion?: string | null } + > + runtimeEnvironments?: readonly Pick<PublicKnownRuntimeEnvironment, 'id' | 'name'>[] + // Why: per-host display-label overrides rename hosts everywhere the sidebar + // options feed (host headers, scope picker, focus menu). + hostLabelOverrides?: ReadonlyMap<ExecutionHostId, string> +}): SidebarHostOption[] { + const configuredSshTargetIds = new Set(args.sshTargetLabels.keys()) + const projectSshTargetIds = new Set<string>() + for (const repo of args.repos) { + if (repo.connectionId?.trim()) { + projectSshTargetIds.add(repo.connectionId.trim()) + } + if (repo.executionHostId?.startsWith('ssh:')) { + projectSshTargetIds.add(decodeURIComponent(repo.executionHostId.slice('ssh:'.length))) + } + } + const activeRuntimeHostId = args.settings?.activeRuntimeEnvironmentId?.trim() + ? (`runtime:${encodeURIComponent(args.settings.activeRuntimeEnvironmentId.trim())}` as const) + : null + return buildExecutionHostRegistry({ + repos: args.repos, + settings: args.settings, + sshTargetLabels: args.sshTargetLabels, + sshConnectionStates: args.sshConnectionStates, + runtimeEnvironments: args.runtimeEnvironments, + runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId, + hostLabelOverrides: args.hostLabelOverrides + }).map((host) => { + if (host.kind === 'local') { + return { ...host, presence: 'local' } + } + if (host.kind === 'ssh') { + const targetId = decodeURIComponent(host.id.slice('ssh:'.length)) + // Why: configured hosts explain why a disconnected target remains + // visible; project-only hosts remain because workspaces still point at it. + return { + ...host, + presence: configuredSshTargetIds.has(targetId) + ? 'configured' + : projectSshTargetIds.has(targetId) + ? 'project' + : 'active' + } + } + return { + ...host, + presence: host.id === activeRuntimeHostId ? 'active' : 'project' + } + }) +} + +export function shouldShowHostScopeControls(hosts: readonly SidebarHostOption[]): boolean { + return hosts.some((host) => host.id !== LOCAL_EXECUTION_HOST_ID) +} + +export function buildSidebarHostScopeOptions( + hosts: readonly SidebarHostOption[] +): SidebarHostScopeOption[] { + return [ + { + id: ALL_EXECUTION_HOSTS_SCOPE, + label: translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts'), + detail: hosts.map((host) => host.label).join(', '), + health: 'mixed' + }, + ...hosts.map((host) => ({ + id: host.id, + label: host.label, + detail: host.detail, + health: host.health + })) + ] +} + +export function getSidebarHostScopeLabel( + scope: WorkspaceHostScope, + options: readonly SidebarHostScopeOption[] +): string { + return options.find((option) => option.id === scope)?.label ?? 'All hosts' +} + +export function getSidebarHostVisibilityLabel( + visibleHostIds: readonly ExecutionHostId[] | null | undefined, + hosts: readonly SidebarHostOption[] +): string { + if (!visibleHostIds || visibleHostIds.length === hosts.length) { + return translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts') + } + if (visibleHostIds.length === 1) { + return hosts.find((host) => host.id === visibleHostIds[0])?.label ?? 'Hosts' + } + return translate( + 'auto.components.sidebar.sidebarHostOptions.visibleHostsCount', + '{{value0}} hosts', + { value0: visibleHostIds.length } + ) +} + +export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string { + switch (health) { + case 'local': + return 'Local' + case 'available': + return 'Connected' + case 'connecting': + return 'Connecting' + case 'blocked': + return 'Update needed' + case 'disconnected': + return 'Disconnected' + case 'error': + return 'Needs attention' + case 'mixed': + return 'Mixed' + } +} diff --git a/src/renderer/src/components/sidebar/sidebar-project-drop.ts b/src/renderer/src/components/sidebar/sidebar-project-drop.ts index 42c29ded37f..fc61c049e38 100644 --- a/src/renderer/src/components/sidebar/sidebar-project-drop.ts +++ b/src/renderer/src/components/sidebar/sidebar-project-drop.ts @@ -61,7 +61,7 @@ export function getSidebarProjectDropAffordance(args: { ), description: translate( 'auto.components.sidebar.sidebar.project.drop.740e8d0d46', - 'Use Add Project for server paths' + 'Use Add Project for host paths' ) } } diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts new file mode 100644 index 00000000000..69ddc54ecd8 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -0,0 +1,194 @@ +import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export const GROUP_BY_OPTIONS = [ + { + id: 'none', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda', 'None') + } + }, + { + id: 'workspace-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775', 'Status') + } + }, + { + id: 'pr-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31', 'PR') + } + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + } + } +] as const + +export const CARD_LAYOUT_OPTIONS = [ + { + id: 'detailed', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b', 'Detailed') + } + }, + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + } +] as const + +export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ + { + id: 'issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', + 'GitHub ticket' + ) + } + }, + { + id: 'linear-issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', + 'Linear issue' + ) + } + }, + { + id: 'pr', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', + 'PR/MR link' + ) + } + }, + { + id: 'comment', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') + } + }, + { + id: 'ports', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') + } + }, + { + id: 'inline-agents', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', + 'Agent activity' + ) + } + } +] + +export const AGENT_ACTIVITY_DISPLAY_OPTIONS: { + id: AgentActivityDisplayMode + label: string +}[] = [ + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + }, + { + id: 'full', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366', + 'Full list' + ) + } + } +] + +export const SORT_OPTIONS = [ + { + id: 'name', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd', 'Name') + }, + description: null + }, + { + id: 'smart', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4', + 'Agent Activity' + ) + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee', + 'Agents that need attention, then most recent activity.' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + description: null + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + }, + description: null + }, + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485', + 'Drag workspaces to arrange them within each group.' + ) + } + } +] as const + +export const PROJECT_ORDER_OPTIONS = [ + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b', + 'Drag projects to arrange them' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505', + 'Most recent workspace activity' + ) + } + } +] as const diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts new file mode 100644 index 00000000000..effec4b5192 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react' + +export function useAddRepoHostChangeReset({ + isOpen, + selectedHostId, + onResetClosed, + onResetHostScopedState +}: { + isOpen: boolean + selectedHostId: string + onResetClosed: () => void + onResetHostScopedState: () => void +}) { + const previousSelectedHostIdRef = useRef(selectedHostId) + + useEffect(() => { + if (!isOpen) { + previousSelectedHostIdRef.current = selectedHostId + onResetClosed() + } + }, [isOpen, onResetClosed, selectedHostId]) + + useEffect(() => { + if (!isOpen || previousSelectedHostIdRef.current === selectedHostId) { + return + } + // Why: Add Project form fields are host-path scoped, so switching hosts must + // clear typed paths and pending defaults before they can be submitted. + previousSelectedHostIdRef.current = selectedHostId + onResetHostScopedState() + }, [isOpen, onResetHostScopedState, selectedHostId]) +} diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts new file mode 100644 index 00000000000..7bb90f4155a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { SidebarHostOption } from './sidebar-host-options' + +const mocks = vi.hoisted(() => ({ + stateValues: [] as unknown[], + stateSetters: [] as ReturnType<typeof vi.fn>[], + stateIndex: 0, + refValues: [] as unknown[], + refIndex: 0, + hostOptions: [] as SidebarHostOption[], + storeState: { + settings: { activeRuntimeEnvironmentId: null as string | null }, + switchRuntimeEnvironment: vi.fn() + } +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal<typeof ReactModule>() + return { + ...actual, + useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: <T>(value: T) => { + const index = mocks.refIndex++ + return { + current: index in mocks.refValues ? (mocks.refValues[index] as T) : value + } + }, + useState: <T>(initial: T | (() => T)) => { + const index = mocks.stateIndex++ + const value = + index in mocks.stateValues + ? mocks.stateValues[index] + : typeof initial === 'function' + ? (initial as () => T)() + : initial + const setter = vi.fn() + mocks.stateSetters[index] = setter + return [value as T, setter] + } + } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState) +})) + +vi.mock('./use-sidebar-host-scope-options', () => ({ + useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions }) +})) + +describe('useAddRepoHostSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.stateIndex = 0 + mocks.stateSetters = [] + mocks.refIndex = 0 + mocks.refValues = [] + mocks.hostOptions = [ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'ssh:ssh-1', + label: 'Builder', + detail: 'SSH', + kind: 'ssh', + health: 'available', + presence: 'configured' + }, + { + id: 'runtime:env-1', + label: 'Server', + detail: 'Runtime', + kind: 'runtime', + health: 'available', + presence: 'active' + } + ] + mocks.storeState.settings = { activeRuntimeEnvironmentId: null } + mocks.storeState.switchRuntimeEnvironment.mockResolvedValue(true) + }) + + it('exposes the selected SSH target id', async () => { + mocks.stateValues = ['ssh:ssh-1', false] + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(result.selectedHostId).toBe('ssh:ssh-1') + expect(result.selectedParsedHost).toMatchObject({ kind: 'ssh', targetId: 'ssh-1' }) + expect(result.selectedSshTargetId).toBe('ssh-1') + }) + + it('switches runtime before selecting a runtime host', async () => { + mocks.stateValues = ['local', false] + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('runtime:env-1') + + expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith('env-1') + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('runtime:env-1') + expect(setStep).toHaveBeenCalledWith('add') + }) + + it('clears the active runtime before selecting a local or SSH host', async () => { + mocks.stateValues = ['runtime:env-1', false] + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('ssh:ssh-1') + + expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith(null) + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1') + expect(setStep).toHaveBeenCalledWith('add') + }) + + it('falls back from a disconnected selected SSH host to Local Mac', async () => { + mocks.stateValues = ['ssh:ssh-1', false] + mocks.hostOptions[1] = { + ...mocks.hostOptions[1], + health: 'disconnected' + } + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(result.selectedHostId).toBe('local') + expect(result.selectedSshTargetId).toBeNull() + }) + + it('does not select a disconnected SSH host', async () => { + mocks.stateValues = ['local', false] + mocks.hostOptions[1] = { + ...mocks.hostOptions[1], + health: 'disconnected' + } + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('ssh:ssh-1') + + expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled() + expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1') + expect(setStep).not.toHaveBeenCalled() + }) + + it('does not auto-select the active runtime host while it is unavailable', async () => { + mocks.stateValues = ['local', false] + mocks.hostOptions[2] = { + ...mocks.hostOptions[2], + health: 'blocked' + } + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('local') + }) +}) diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts new file mode 100644 index 00000000000..56e174f97f8 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { canSelectAddRepoHost } from './add-repo-host-availability' + +export function useAddRepoHostSelection({ + isOpen, + setStep +}: { + isOpen: boolean + setStep: (step: AddRepoDialogStep) => void +}): { + hostOptions: ReturnType<typeof useSidebarHostScopeOptions>['hostOptions'] + selectedHostId: ExecutionHostId + selectedParsedHost: ReturnType<typeof parseExecutionHostId> + selectedSshTargetId: string | null + hostSelectorOpen: boolean + setHostSelectorOpen: (open: boolean) => void + handleSelectAddProjectHost: (hostId: ExecutionHostId) => Promise<void> +} { + const settings = useAppStore((s) => s.settings) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const { hostOptions } = useSidebarHostScopeOptions() + const [selectedAddProjectHostId, setSelectedAddProjectHostId] = + useState<ExecutionHostId>(LOCAL_EXECUTION_HOST_ID) + const [hostSelectorOpen, setHostSelectorOpen] = useState(false) + const previousOpenRef = useRef(false) + + const selectedHost = + hostOptions.find( + (host) => host.id === selectedAddProjectHostId && canSelectAddRepoHost(host) + ) ?? + hostOptions.find((host) => host.id === LOCAL_EXECUTION_HOST_ID && canSelectAddRepoHost(host)) ?? + hostOptions.find((host) => canSelectAddRepoHost(host)) ?? + hostOptions[0] + const selectedHostId = selectedHost?.id ?? LOCAL_EXECUTION_HOST_ID + const selectedParsedHost = parseExecutionHostId(selectedHostId) + const selectedSshTargetId = + selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null + + useEffect(() => { + if (isOpen && !previousOpenRef.current) { + const focusedHostId = getSettingsFocusedExecutionHostId(settings) + const nextHostId = hostOptions.some( + (host) => host.id === focusedHostId && canSelectAddRepoHost(host) + ) + ? focusedHostId + : LOCAL_EXECUTION_HOST_ID + setSelectedAddProjectHostId(nextHostId) + } + if (!isOpen) { + setHostSelectorOpen(false) + } + previousOpenRef.current = isOpen + }, [hostOptions, isOpen, settings]) + + const handleSelectAddProjectHost = useCallback( + async (hostId: ExecutionHostId): Promise<void> => { + const host = hostOptions.find((candidate) => candidate.id === hostId) + if (!host || !canSelectAddRepoHost(host)) { + return + } + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + const switched = await switchRuntimeEnvironment(parsed.environmentId) + if (!switched) { + return + } + } else if (settings?.activeRuntimeEnvironmentId?.trim()) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + return + } + } + setSelectedAddProjectHostId(hostId) + setStep('add') + }, + [hostOptions, settings?.activeRuntimeEnvironmentId, setStep, switchRuntimeEnvironment] + ) + + return { + hostOptions, + selectedHostId, + selectedParsedHost, + selectedSshTargetId, + hostSelectorOpen, + setHostSelectorOpen, + handleSelectAddProjectHost + } +} diff --git a/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts new file mode 100644 index 00000000000..1f451b0a115 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { track } from '@/lib/telemetry' +import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry' +import type { NestedRepoScanResult } from '../../../../shared/types' + +export function useAddRepoRemoteNestedScan({ + setActiveNestedScanId, + showNestedRepoReview +}: { + setActiveNestedScanId: (scanId: string | null) => void + showNestedRepoReview: (options: { + scan: NestedRepoScanResult + selectedPath: string + connectionId: string + attemptId: string + runtimeKind: 'ssh' + inProgress: boolean + scanId: string | null + }) => void +}) { + const showRemoteNestedRepoReview = useCallback( + ( + scan: NestedRepoScanResult, + selectedPath: string, + connectionId: string, + attemptId: string, + inProgress: boolean, + scanId: string | null + ) => { + setActiveNestedScanId(inProgress ? scanId : null) + showNestedRepoReview({ + scan, + selectedPath, + connectionId, + attemptId, + runtimeKind: 'ssh', + inProgress, + scanId + }) + }, + [setActiveNestedScanId, showNestedRepoReview] + ) + + const trackRemoteNestedScanResult = useCallback( + (scan: NestedRepoScanResult | null, attemptId: string) => { + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + scan + }) + ) + }, + [] + ) + + return { showRemoteNestedRepoReview, trackRemoteNestedScanResult } +} diff --git a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts new file mode 100644 index 00000000000..a657bcce397 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts @@ -0,0 +1,55 @@ +import { useCallback, useRef } from 'react' +import { useAppStore } from '@/store' +import { track } from '@/lib/telemetry' +import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' +import { + buildAddRepoExistingWorkspacesTelemetry, + shouldTrackAddRepoExistingWorkspacesDetected +} from './add-repo-existing-workspaces-telemetry' +import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' + +type CompleteGitRepoAddOptions = { + closeModal: () => void + setHideDefaultBranchWorkspace: (hide: boolean) => void +} + +export function useCompleteGitRepoAdd({ + closeModal, + setHideDefaultBranchWorkspace +}: CompleteGitRepoAddOptions): ( + repoId: string, + source: AddRepoExistingWorkspaceSource +) => Promise<void> { + const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set()) + + return useCallback( + async (repoId: string, source: AddRepoExistingWorkspaceSource): Promise<void> => { + const worktrees = useAppStore.getState().worktreesByRepo[repoId] ?? [] + const sortedWorktrees = [...worktrees].sort((a, b) => { + if (a.lastActivityAt !== b.lastActivityAt) { + return b.lastActivityAt - a.lastActivityAt + } + return a.displayName.localeCompare(b.displayName) + }) + const existingWorkspaceTelemetry = buildAddRepoExistingWorkspacesTelemetry( + source, + sortedWorktrees + ) + if ( + existingWorkspaceTelemetry && + shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) && + !detectedTelemetryTrackedRef.current.has(repoId) + ) { + detectedTelemetryTrackedRef.current.add(repoId) + track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) + } + await finishProjectAddWithDefaultCheckout({ + repoId, + source, + closeModal, + setHideDefaultBranchWorkspace + }) + }, + [closeModal, setHideDefaultBranchWorkspace] + ) +} diff --git a/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts new file mode 100644 index 00000000000..039289c73bb --- /dev/null +++ b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + buildSidebarHostOptions, + buildSidebarHostScopeOptions, + type SidebarHostOption, + type SidebarHostScopeOption +} from './sidebar-host-options' + +/** Shared host-scope derivation for the sidebar scope strip and the workspace + * options menu so both surfaces consume the same live runtime status without + * duplicating store wiring. */ +export function useSidebarHostScopeOptions(): { + hostOptions: SidebarHostOption[] + hostScopeOptions: SidebarHostScopeOption[] +} { + const repos = useAppStore((s) => s.repos) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostScopeOptions = useMemo(() => buildSidebarHostScopeOptions(hostOptions), [hostOptions]) + + return { hostOptions, hostScopeOptions } +} diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index b1402be5ae2..5ce06c51ad2 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import type { Repo, Worktree } from '../../../../shared/types' import { computeVisibleWorktreeIds } from './visible-worktrees' +import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' type UseVisibleWorkspaceKanbanWorktreeIdsParams = { allWorktrees: readonly Worktree[] @@ -15,6 +16,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const settings = useAppStore((s) => s.settings) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const tabsByWorktree = useAppStore((s) => (!showSleepingWorkspaces ? s.tabsByWorktree : null)) const ptyIdsByTabId = useAppStore((s) => (!showSleepingWorkspaces ? s.ptyIdsByTabId : null)) @@ -35,6 +39,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, hideDefaultBranchWorkspace, repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), // Why: the board has no nested lineage presentation. Ancestor injection // would make filtered-out parents appear as ordinary cards. worktreeLineageById: {} @@ -45,6 +52,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, filterRepoIds, hideDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, ptyIdsByTabId, repoMap, showSleepingWorkspaces, diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts new file mode 100644 index 00000000000..92c18aca836 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { useMountedRef } from '@/hooks/useMountedRef' +import { parseExplicitGitHubIssueUrl } from './worktree-meta-updates' + +/** Resolves the "open linked issue" affordance for the worktree meta dialog: + * explicit URLs open directly, numbers resolve via the issue cache or an + * owner-routed fetch. */ +export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: string }): { + canOpenIssue: boolean + openingIssue: boolean + handleOpenIssue: () => Promise<void> + resetOpeningIssue: () => void +} { + const { worktreeId, issueInput } = args + const fetchIssue = useAppStore((s) => s.fetchIssue) + const [openingIssue, setOpeningIssue] = useState(false) + const mountedRef = useMountedRef() + + const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput]) + const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput]) + const issueInputLooksLikeUrl = useMemo( + () => /^https?:\/\//i.test(issueInput.trim()), + [issueInput] + ) + const issueRepo = useAppStore((s) => { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((item) => item.id === worktreeId) + if (!worktree) { + return undefined + } + return s.repos.find((repo) => repo.id === worktree.repoId) + }) + const cachedIssueUrl = useAppStore((s) => { + if (!issueRepo || issueNumber === null) { + return null + } + return ( + s.issueCache[ + getIssueCacheKey( + issueRepo.path, + issueRepo.id, + issueNumber, + s.settings, + issueRepo.connectionId, + issueRepo.executionHostId + ) + ]?.data?.url ?? null + ) + }) + const canOpenIssue = issueInputLooksLikeUrl + ? Boolean(issueUrlFromInput) + : Boolean(cachedIssueUrl || (issueRepo && issueNumber)) + + const handleOpenIssue = useCallback(async () => { + if (openingIssue) { + return + } + + if (issueUrlFromInput) { + void window.api.shell.openUrl(issueUrlFromInput) + return + } + + if (issueInputLooksLikeUrl) { + return + } + + if (cachedIssueUrl) { + void window.api.shell.openUrl(cachedIssueUrl) + return + } + + if (!issueRepo || issueNumber === null) { + return + } + + setOpeningIssue(true) + try { + const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id }) + if (issue?.url) { + void window.api.shell.openUrl(issue.url) + } + } finally { + if (mountedRef.current) { + setOpeningIssue(false) + } + } + }, [ + cachedIssueUrl, + fetchIssue, + issueInputLooksLikeUrl, + issueNumber, + issueRepo, + issueUrlFromInput, + mountedRef, + openingIssue + ]) + + const resetOpeningIssue = useCallback(() => setOpeningIssue(false), []) + + return { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } +} diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts new file mode 100644 index 00000000000..3d02f5fe1a9 --- /dev/null +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { Repo } from '../../../../shared/types' + +const mocks = vi.hoisted(() => ({ + stateValues: [] as unknown[], + stateSetters: [] as ReturnType<typeof vi.fn>[], + stateIndex: 0, + refValues: [] as unknown[], + refIndex: 0, + storeState: { + settings: { activeRuntimeEnvironmentId: null as string | null }, + repos: [] as Repo[], + projects: [], + projectHostSetups: [] + }, + cloneRemote: vi.fn(), + cloneLocal: vi.fn(), + pickDirectory: vi.fn(), + onCloneProgress: vi.fn(() => vi.fn()), + callRuntimeRpc: vi.fn(), + fetchWorktrees: vi.fn(), + onGitRepoReady: vi.fn() +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal<typeof ReactModule>() + return { + ...actual, + useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: <T>(value: T) => { + const index = mocks.refIndex++ + return { + current: index in mocks.refValues ? (mocks.refValues[index] as T) : value + } + }, + useState: <T>(initial: T | (() => T)) => { + const index = mocks.stateIndex++ + const value = + index in mocks.stateValues + ? mocks.stateValues[index] + : typeof initial === 'function' + ? (initial as () => T)() + : initial + const setter = vi.fn() + mocks.stateSetters[index] = setter + return [value as T, setter] + } + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), + { + getState: () => mocks.storeState, + setState: (next: Partial<typeof mocks.storeState>) => { + Object.assign(mocks.storeState, next) + } + } + ) + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: mocks.callRuntimeRpc +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +function makeRepo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-cloned', + path: '/srv/orca', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +describe('useAddRepoCloneFlow', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.stateIndex = 0 + mocks.stateSetters = [] + mocks.refIndex = 0 + mocks.refValues = [] + mocks.stateValues = ['https://github.com/stablyai/orca.git', '/srv', false, null, null] + mocks.storeState.repos = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] + vi.stubGlobal('window', { + api: { + repos: { + cloneRemote: mocks.cloneRemote, + clone: mocks.cloneLocal, + pickDirectory: mocks.pickDirectory, + onCloneProgress: mocks.onCloneProgress + } + } + }) + }) + + it('clones through the selected SSH target', async () => { + const repo = makeRepo({ connectionId: 'ssh-1' }) + mocks.cloneRemote.mockResolvedValue(repo) + mocks.callRuntimeRpc.mockReset() + mocks.fetchWorktrees.mockResolvedValue(true) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.cloneRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + expect(mocks.cloneLocal).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url') + }) + + it('does not prefill SSH clone destinations from the local workspace directory', async () => { + mocks.stateValues = ['https://github.com/stablyai/orca.git', '', false, null, null] + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/private/tmp/orca-setup-e2e.hOWO1f', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + + expect(result.cloneDestination).toBe('') + expect(mocks.stateSetters[1]).not.toHaveBeenCalledWith('/private/tmp/orca-setup-e2e.hOWO1f') + }) + + it('strips Electron IPC wrappers from clone errors', async () => { + const cloneError = + 'Clone failed: Destination already exists and is not empty: /srv/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + mocks.cloneRemote.mockRejectedValue( + new Error(`Error invoking remote method 'repos:cloneRemote': Error: ${cloneError}`) + ) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.stateSetters[3]).toHaveBeenCalledWith(cloneError) + }) + + it('clones through the selected runtime environment', async () => { + const repo = makeRepo({ id: 'runtime-repo', executionHostId: 'runtime:env-1' }) + mocks.callRuntimeRpc.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: 'env-1', + sshTargetId: null, + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'repo.clone', + { + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }, + { timeoutMs: 10 * 60_000 } + ) + expect(mocks.cloneLocal).not.toHaveBeenCalled() + expect(mocks.cloneRemote).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url') + }) +}) diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index 44ff4b94b55..cd376a014fa 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -7,16 +7,20 @@ import type { Repo } from '../../../../shared/types' import { getCloneDestinationAutoFill } from './clone-defaults' import type { AddRepoDialogStep } from './add-repo-dialog-types' import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' export function useAddRepoCloneFlow({ step, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, fetchWorktrees, onGitRepoReady }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null workspaceDir: string | null | undefined fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<unknown> onGitRepoReady: (repoId: string, source: AddRepoExistingWorkspaceSource) => Promise<void> @@ -40,6 +44,9 @@ export function useAddRepoCloneFlow({ const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>( null ) + const hostToken = `${activeRuntimeEnvironmentId?.trim() ?? ''}:${sshTargetId?.trim() ?? ''}` + const hostTokenRef = useRef(hostToken) + hostTokenRef.current = hostToken // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) // Why: track whether we've already auto-filled for this entry into the clone step, @@ -57,6 +64,7 @@ export function useAddRepoCloneFlow({ step, cloneDestination, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, cloneStepAutoFilled: cloneStepAutoFilledRef.current }) @@ -79,13 +87,13 @@ export function useAddRepoCloneFlow({ }, []) const handlePickDestination = useCallback(async (): Promise<void> => { - if (activeRuntimeEnvironmentId?.trim()) { + if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim()) { // Why: the native folder picker returns a client-local path. Runtime - // clone destinations must be typed as server paths. + // and SSH clone destinations must be typed as paths on that host. toast.error( translate( 'auto.components.sidebar.useAddRepoCloneFlow.0dc4d1b657', - 'Enter a server path for the clone destination.' + 'Enter a host path for the clone destination.' ) ) return @@ -96,21 +104,32 @@ export function useAddRepoCloneFlow({ setCloneDestination(dir) setCloneError(null) } - }, [activeRuntimeEnvironmentId]) + }, [activeRuntimeEnvironmentId, sshTargetId]) const handleClone = useCallback(async (): Promise<void> => { const trimmedUrl = cloneUrl.trim() if (!trimmedUrl || !cloneDestination.trim()) { return } + const requestHostToken = hostTokenRef.current const gen = ++cloneGenRef.current setIsCloning(true) setCloneError(null) setCloneProgress(null) try { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - const repo = - target.kind === 'environment' + const target = activeRuntimeEnvironmentId?.trim() + ? { kind: 'environment' as const, environmentId: activeRuntimeEnvironmentId.trim() } + : getActiveRuntimeTarget({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: null + }) + const repo = sshTargetId?.trim() + ? await window.api.repos.cloneRemote({ + connectionId: sshTargetId.trim(), + url: trimmedUrl, + destination: cloneDestination.trim() + }) + : target.kind === 'environment' ? ( await callRuntimeRpc<{ repo: Repo }>( target, @@ -126,42 +145,40 @@ export function useAddRepoCloneFlow({ url: trimmedUrl, destination: cloneDestination.trim() })) as Repo) - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } toast.success( translate('auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93', 'Repository cloned'), { description: repo.displayName } ) - // Why: eagerly upsert so step 2 finds the repo before the IPC event. - const state = useAppStore.getState() - const existingIdx = state.repos.findIndex((r) => r.id === repo.id) - if (existingIdx === -1) { - useAppStore.setState({ repos: [...state.repos, repo] }) - } else { - const updated = [...state.repos] - updated[existingIdx] = repo - useAppStore.setState({ repos: updated }) - } + upsertAddedRepoWithProjectHostSetup(repo) // Why: once the repo exists, a transient non-authoritative refresh // should fall through to project reveal instead of leaving the add flow open. await fetchWorktrees(repo.id, { requireAuthoritative: true }) - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } await onGitRepoReady(repo.id, 'clone_url') } catch (err) { - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } - const message = err instanceof Error ? err.message : String(err) + const message = extractIpcErrorMessage(err, String(err)) setCloneError(message) } finally { - if (gen === cloneGenRef.current) { + if (gen === cloneGenRef.current && requestHostToken === hostTokenRef.current) { setIsCloning(false) } } - }, [cloneUrl, cloneDestination, fetchWorktrees, onGitRepoReady]) + }, [ + activeRuntimeEnvironmentId, + cloneUrl, + cloneDestination, + fetchWorktrees, + onGitRepoReady, + sshTargetId + ]) return { cloneUrl, diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts index 6f3c8eeeb48..f11f85a540a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts @@ -72,7 +72,7 @@ export function useAddRepoLocalFolderFlow({ toast.error( translate( 'auto.components.sidebar.useAddRepoLocalFolderFlow.7ab10e4974', - 'Use a server path to add projects from a remote runtime.' + 'Use a host path to add projects from a remote host.' ) ) closeModal() diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts index 941362ca5e8..aa81789d7e9 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -63,14 +63,16 @@ function useHarness(overrides: Partial<Parameters<typeof useCreateProjectDefault mocks.stateIndex = 0 mocks.refIndex = 0 const setCreateParent = vi.fn() + const setCreateKind = vi.fn() const result = useCreateProjectDefaults({ step: 'create', activeRuntimeEnvironmentId: null, createParent: '', setCreateParent, + setCreateKind, ...overrides }) - return { result, setCreateParent } + return { result, setCreateParent, setCreateKind } } describe('useCreateProjectDefaults', () => { @@ -91,15 +93,16 @@ describe('useCreateProjectDefaults', () => { mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects') }) - it('auto-fills the local default parent and records Git availability', async () => { + it('auto-fills the local default parent and defaults to git when available', async () => { mocks.isGitAvailable.mockResolvedValue(true) - const { setCreateParent } = useHarness() + const { setCreateParent, setCreateKind } = useHarness() await flushAsync() expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects') expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available') + expect(setCreateKind).toHaveBeenCalledWith('git') expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled() expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) @@ -125,22 +128,24 @@ describe('useCreateProjectDefaults', () => { expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') }) - it('records unavailable Git without changing project kind', async () => { + it('defaults to folder with a visible fallback when Git is unavailable', async () => { mocks.isGitAvailable.mockResolvedValue(false) - useHarness() + const { setCreateKind } = useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable') + expect(setCreateKind).toHaveBeenCalledWith('folder') }) - it('reports unknown availability when the Git probe fails', async () => { + it('reports unknown availability and keeps the kind when the Git probe fails', async () => { mocks.isGitAvailable.mockRejectedValue(new Error('probe failed')) - useHarness() + const { setCreateKind } = useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') + expect(setCreateKind).not.toHaveBeenCalled() }) it('does not overwrite a parent the user already chose', async () => { @@ -156,7 +161,7 @@ describe('useCreateProjectDefaults', () => { mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' }) mocks.callRuntimeRpc.mockResolvedValue({ available: true }) - const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) + const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) await flushAsync() expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~') @@ -171,6 +176,7 @@ describe('useCreateProjectDefaults', () => { { timeoutMs: 3000 } ) expect(mocks.isGitAvailable).not.toHaveBeenCalled() + expect(setCreateKind).toHaveBeenCalledWith('git') }) it('replaces an untouched local default when switching to a runtime target', async () => { @@ -242,11 +248,26 @@ describe('useCreateProjectDefaults', () => { expect(setCreateParent).not.toHaveBeenCalled() }) - it('does nothing outside the create step', async () => { - const { setCreateParent } = useHarness({ step: 'add' }) + it('does not use client defaults or Git probing for SSH targets', async () => { + mocks.isGitAvailable.mockResolvedValue(true) + + const { setCreateParent, setCreateKind } = useHarness({ sshTargetId: 'ssh-1' }) await flushAsync() expect(setCreateParent).not.toHaveBeenCalled() + expect(setCreateKind).not.toHaveBeenCalled() + expect(mocks.getDefaultCreateProjectParent).not.toHaveBeenCalled() + expect(mocks.isGitAvailable).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') + }) + + it('does nothing outside the create step', async () => { + const { setCreateParent, setCreateKind } = useHarness({ step: 'add' }) + await flushAsync() + + expect(setCreateParent).not.toHaveBeenCalled() + expect(setCreateKind).not.toHaveBeenCalled() expect(mocks.isGitAvailable).not.toHaveBeenCalled() expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts index f93e61ee3d1..e1df52b084b 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -5,7 +5,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoDialogStep } from './add-repo-dialog-types' -import { getDefaultCreateProjectParent, type GitAvailability } from './create-project-defaults' +import { + getDefaultCreateProjectParent, + type GitAvailability, + type RepoKind +} from './create-project-defaults' const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500 const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000 @@ -14,12 +18,12 @@ export type CreateRuntimeParentStatus = 'idle' | 'checking' | 'failed' type AutoFilledCreateParent = { parent: string - runtimeEnvironmentId: string | null + targetKey: string } type CreateParentProvenance = { parent: string - runtimeEnvironmentId: string | null + targetKey: string } function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { @@ -46,13 +50,17 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { export function useCreateProjectDefaults({ step, activeRuntimeEnvironmentId, + sshTargetId, createParent, - setCreateParent + setCreateParent, + setCreateKind }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null | undefined createParent: string setCreateParent: (value: string) => void + setCreateKind: (kind: RepoKind) => void }): { createDefaultParent: string createGitAvailability: GitAvailability @@ -60,6 +68,7 @@ export function useCreateProjectDefaults({ createParentDefaultPending: boolean resetCreateDefaultState: () => void markCreateParentTouched: (value?: string) => void + markCreateKindTouched: () => void } { const [createDefaultParent, setCreateDefaultParent] = useState('') const [createGitAvailability, setCreateGitAvailability] = useState<GitAvailability>('unknown') @@ -69,9 +78,16 @@ export function useCreateProjectDefaults({ const autoFilledCreateParentRef = useRef<AutoFilledCreateParent | null>(null) const createParentProvenanceRef = useRef<CreateParentProvenance | null>(null) const createParentTouchedRef = useRef(false) + const createKindTouchedRef = useRef(false) const createParentDefaultGenRef = useRef(0) const createGitProbeGenRef = useRef(0) const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null + const activeCreateParentSshTargetId = sshTargetId?.trim() || null + const activeCreateParentTargetKey = activeCreateParentRuntimeEnvironmentId + ? `runtime:${activeCreateParentRuntimeEnvironmentId}` + : activeCreateParentSshTargetId + ? `ssh:${activeCreateParentSshTargetId}` + : 'local' const canReplaceCreateParentDefault = useCallback((parent: string): boolean => { if (createParentTouchedRef.current) { @@ -88,6 +104,7 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = null createParentTouchedRef.current = false + createKindTouchedRef.current = false setCreateDefaultParent('') setCreateGitAvailability('unknown') setCreateRuntimeParentStatus('idle') @@ -99,33 +116,34 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = { parent: (value ?? createParent).trim(), - runtimeEnvironmentId: activeCreateParentRuntimeEnvironmentId + targetKey: activeCreateParentTargetKey } createParentTouchedRef.current = true }, - [activeCreateParentRuntimeEnvironmentId, createParent] + [activeCreateParentTargetKey, createParent] ) + const markCreateKindTouched = useCallback(() => { + createKindTouchedRef.current = true + }, []) const createParentDefaultPending = step === 'create' && !createParentTouchedRef.current && Boolean(createParent.trim()) && autoFilledCreateParentRef.current?.parent === createParent.trim() && - autoFilledCreateParentRef.current.runtimeEnvironmentId !== - activeCreateParentRuntimeEnvironmentId + autoFilledCreateParentRef.current.targetKey !== activeCreateParentTargetKey const createParentTargetPending = step === 'create' && Boolean(createParent.trim()) && createParentProvenanceRef.current?.parent === createParent.trim() && - createParentProvenanceRef.current.runtimeEnvironmentId !== - activeCreateParentRuntimeEnvironmentId + createParentProvenanceRef.current.targetKey !== activeCreateParentTargetKey const createParentPending = createParentDefaultPending || createParentTargetPending useEffect(() => { if (step !== 'create') { return } - if (activeCreateParentRuntimeEnvironmentId) { + if (activeCreateParentRuntimeEnvironmentId || activeCreateParentSshTargetId) { return } // Why: invalidate any in-flight runtime parent probe once local mode owns the default. @@ -135,7 +153,7 @@ export function useCreateProjectDefaults({ } if ( createParent.trim() && - autoFilledCreateParentRef.current?.runtimeEnvironmentId !== null && + autoFilledCreateParentRef.current?.targetKey !== 'local' && autoFilledCreateParentRef.current?.parent === createParent.trim() ) { setCreateDefaultParent('') @@ -143,7 +161,7 @@ export function useCreateProjectDefaults({ return } if ( - autoFilledCreateParentRef.current?.runtimeEnvironmentId === null && + autoFilledCreateParentRef.current?.targetKey === 'local' && autoFilledCreateParentRef.current.parent === createParent.trim() ) { return @@ -161,8 +179,8 @@ export function useCreateProjectDefaults({ } setCreateDefaultParent(parent) createStepAutoFilledRef.current = true - autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId: null } - createParentProvenanceRef.current = { parent, runtimeEnvironmentId: null } + autoFilledCreateParentRef.current = { parent, targetKey: 'local' } + createParentProvenanceRef.current = { parent, targetKey: 'local' } setCreateParent(parent) }) .catch(() => { @@ -171,6 +189,7 @@ export function useCreateProjectDefaults({ }, [ activeRuntimeEnvironmentId, activeCreateParentRuntimeEnvironmentId, + activeCreateParentSshTargetId, canReplaceCreateParentDefault, createParent, setCreateParent, @@ -182,7 +201,7 @@ export function useCreateProjectDefaults({ return } const runtimeEnvironmentId = activeCreateParentRuntimeEnvironmentId - if (!runtimeEnvironmentId) { + if (!runtimeEnvironmentId || activeCreateParentSshTargetId) { setCreateRuntimeParentStatus('idle') return } @@ -192,7 +211,7 @@ export function useCreateProjectDefaults({ } if ( createParent.trim() && - autoFilledCreateParentRef.current?.runtimeEnvironmentId !== runtimeEnvironmentId && + autoFilledCreateParentRef.current?.targetKey !== `runtime:${runtimeEnvironmentId}` && autoFilledCreateParentRef.current?.parent === createParent.trim() ) { setCreateDefaultParent('') @@ -201,7 +220,7 @@ export function useCreateProjectDefaults({ return } if ( - autoFilledCreateParentRef.current?.runtimeEnvironmentId === runtimeEnvironmentId && + autoFilledCreateParentRef.current?.targetKey === `runtime:${runtimeEnvironmentId}` && autoFilledCreateParentRef.current.parent === createParent.trim() ) { setCreateRuntimeParentStatus('idle') @@ -224,8 +243,8 @@ export function useCreateProjectDefaults({ } const parent = getDefaultCreateProjectParent(result.resolvedPath) createStepAutoFilledRef.current = true - autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId } - createParentProvenanceRef.current = { parent, runtimeEnvironmentId } + autoFilledCreateParentRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` } + createParentProvenanceRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` } setCreateDefaultParent(parent) setCreateParent(parent) setCreateRuntimeParentStatus('idle') @@ -239,6 +258,7 @@ export function useCreateProjectDefaults({ }, [ activeRuntimeEnvironmentId, activeCreateParentRuntimeEnvironmentId, + activeCreateParentSshTargetId, canReplaceCreateParentDefault, createParent, setCreateParent, @@ -251,6 +271,12 @@ export function useCreateProjectDefaults({ } const runtimeEnvironmentId = activeRuntimeEnvironmentId?.trim() const gen = ++createGitProbeGenRef.current + if (activeCreateParentSshTargetId) { + // Why: SSH creation happens through the relay; probing client Git would + // make the selected host look healthier or less healthy than it is. + setCreateGitAvailability('unknown') + return + } setCreateGitAvailability('checking') const probe = runtimeEnvironmentId ? callRuntimeRpc<{ available: boolean }>( @@ -270,6 +296,10 @@ export function useCreateProjectDefaults({ return } setCreateGitAvailability(available ? 'available' : 'unavailable') + if (createKindTouchedRef.current) { + return + } + setCreateKind(available ? 'git' : 'folder') }) .catch(() => { if (gen !== createGitProbeGenRef.current) { @@ -277,7 +307,7 @@ export function useCreateProjectDefaults({ } setCreateGitAvailability('unknown') }) - }, [activeRuntimeEnvironmentId, step]) + }, [activeRuntimeEnvironmentId, activeCreateParentSshTargetId, setCreateKind, step]) return { createDefaultParent, @@ -285,6 +315,7 @@ export function useCreateProjectDefaults({ createRuntimeParentStatus, createParentDefaultPending: createParentPending, resetCreateDefaultState, - markCreateParentTouched + markCreateParentTouched, + markCreateKindTouched } } diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 78a26a3848c..116d97ffad7 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -9,9 +9,13 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], + projects: [], + projectHostSetups: [], worktreesByRepo: {} as Record<string, unknown[]> }, createRepo: vi.fn(), + createRemoteRepo: vi.fn(), + callRuntimeRpc: vi.fn(), fetchWorktrees: vi.fn(), onGitRepoReady: vi.fn(), activateAndRevealWorktree: vi.fn(), @@ -72,6 +76,11 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: mocks.callRuntimeRpc +})) + function makeRepo(overrides: Partial<Repo> = {}): Repo { return { id: 'repo-created', @@ -89,14 +98,19 @@ describe('useCreateRepo default-checkout handoff', () => { vi.clearAllMocks() mocks.stateIndex = 0 mocks.stateSetters = [] - mocks.stateValues = ['created', '/projects', null, false] + mocks.stateValues = ['created', '/projects', 'git', null, false] mocks.storeState.repos = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] mocks.storeState.worktreesByRepo = {} + mocks.createRepo.mockReset() + mocks.createRemoteRepo.mockReset() mocks.storeState.settings.activeRuntimeEnvironmentId = null vi.stubGlobal('window', { api: { repos: { create: mocks.createRepo, + createRemote: mocks.createRemoteRepo, pickDirectory: vi.fn() } } @@ -120,6 +134,12 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) }) @@ -135,10 +155,11 @@ describe('useCreateRepo default-checkout handoff', () => { }) it('does not return a parent path when the runtime target blocks the local picker', async () => { - mocks.storeState.settings.activeRuntimeEnvironmentId = 'env-1' const { useCreateRepo } = await import('./useCreateRepo') - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady) + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + runtimeEnvironmentId: 'env-1' + }) await expect(result.handlePickParent()).resolves.toBeNull() expect(window.api.repos.pickDirectory).not.toHaveBeenCalled() @@ -163,11 +184,11 @@ describe('useCreateRepo default-checkout handoff', () => { ) }) - it('uses the folder completion path if IPC returns a folder project', async () => { + it('marks onboarding folder progress when a created folder project opens', async () => { const repo = makeRepo({ kind: 'folder' }) const worktree = { id: `${repo.id}::/projects/created` } const closeModal = vi.fn() - mocks.stateValues = ['created', '/projects', null, false] + mocks.stateValues = ['created', '/projects', 'folder', null, false] mocks.createRepo.mockResolvedValue({ repo }) mocks.fetchWorktrees.mockImplementation(async (repoId: string) => { mocks.storeState.worktreesByRepo = { [repoId]: [worktree] } @@ -181,7 +202,7 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.createRepo).toHaveBeenCalledWith({ parentPath: '/projects', name: 'created', - kind: 'git' + kind: 'folder' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { @@ -191,4 +212,58 @@ describe('useCreateRepo default-checkout handoff', () => { expect(closeModal).toHaveBeenCalled() expect(mocks.onGitRepoReady).not.toHaveBeenCalled() }) + + it('creates projects through the SSH host when an SSH target is selected', async () => { + const repo = makeRepo({ connectionId: 'ssh-1', path: '/srv/created' }) + mocks.createRemoteRepo.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useCreateRepo } = await import('./useCreateRepo') + + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + sshTargetId: 'ssh-1' + }) + await result.handleCreate() + + expect(mocks.createRemoteRepo).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + parentPath: '/projects', + name: 'created', + kind: 'git' + }) + expect(mocks.createRepo).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) + }) + + it('creates projects through the selected runtime environment', async () => { + const repo = makeRepo({ executionHostId: 'runtime:env-1', path: '/srv/created' }) + mocks.callRuntimeRpc.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useCreateRepo } = await import('./useCreateRepo') + + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + hostId: 'runtime:env-1', + runtimeEnvironmentId: 'env-1' + }) + await result.handleCreate() + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'repo.create', + { + parentPath: '/projects', + name: 'created', + kind: 'git' + }, + { timeoutMs: 60_000 } + ) + expect(mocks.createRepo).not.toHaveBeenCalled() + expect(mocks.createRemoteRepo).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) + }) }) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 9d9aa95e418..052c96922a1 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -10,6 +10,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import type { RepoKind } from './create-project-defaults' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' export function useCreateRepo( fetchWorktrees: ( @@ -17,13 +20,22 @@ export function useCreateRepo( options?: { requireAuthoritative?: boolean } ) => Promise<boolean>, closeModal: () => void, - onGitRepoReady?: (repoId: string) => void | Promise<void> + onGitRepoReady?: (repoId: string) => void | Promise<void>, + options: { + hostId?: string | null + runtimeEnvironmentId?: string | null + sshTargetId?: string | null + } = {} ) { const [createName, setCreateName] = useState('') const [createParent, setCreateParent] = useState('') + const [createKind, setCreateKind] = useState<RepoKind>('git') const [createError, setCreateError] = useState<string | null>(null) const [isCreating, setIsCreating] = useState(false) const mountedRef = useMountedRef() + const hostToken = options.hostId ?? options.sshTargetId ?? '' + const hostTokenRef = useRef(hostToken) + hostTokenRef.current = hostToken // Why: monotonic ID so stale create callbacks can detect they were superseded // when the user clicks Back or closes the dialog mid-create. Mirrors the @@ -34,18 +46,30 @@ export function useCreateRepo( createGenRef.current++ setCreateName('') setCreateParent('') + setCreateKind('git') setCreateError(null) setIsCreating(false) }, []) const handlePickParent = useCallback(async (): Promise<string | null> => { - if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) { + if (options.sshTargetId) { + // Why: the native picker can only browse the client machine. SSH create + // uses a host path typed by the user until remote folder picking exists. + toast.error( + translate( + 'auto.components.sidebar.AddRepoCreateStep.ssh_parent_manual', + 'Enter an SSH parent path.' + ) + ) + return null + } + if (options.runtimeEnvironmentId?.trim()) { // Why: the native folder picker returns a client-local path. Runtime - // project creation needs an explicit server parent path. + // project creation needs an explicit host parent path. toast.error( translate( 'auto.components.sidebar.AddRepoCreateStep.875dda0995', - 'Enter a server parent path.' + 'Enter a host parent path.' ) ) return null @@ -58,7 +82,7 @@ export function useCreateRepo( return dir } return null - }, [mountedRef]) + }, [mountedRef, options.runtimeEnvironmentId, options.sshTargetId]) const handleCreate = useCallback(async () => { const name = createName.trim() @@ -66,31 +90,47 @@ export function useCreateRepo( if (!name || !parentPath) { return } + const requestHostToken = hostTokenRef.current const gen = ++createGenRef.current setIsCreating(true) setCreateError(null) try { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - const result = - target.kind === 'environment' + const target = options.runtimeEnvironmentId?.trim() + ? { kind: 'environment' as const, environmentId: options.runtimeEnvironmentId.trim() } + : getActiveRuntimeTarget({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: null + }) + const result = options.sshTargetId + ? await window.api.repos.createRemote({ + connectionId: options.sshTargetId, + parentPath, + name, + kind: createKind + }) + : target.kind === 'environment' ? await callRuntimeRpc<{ repo: Repo } | { error: string }>( target, 'repo.create', { parentPath, name, - kind: 'git' + kind: createKind }, { timeoutMs: 60_000 } ) : await window.api.repos.create({ parentPath, name, - kind: 'git' + kind: createKind }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } if ('error' in result) { @@ -98,8 +138,6 @@ export function useCreateRepo( return } const repo = result.repo - // Upsert into the store before the repos:changed event round-trips, - // so the next step can find the repo immediately. const state = useAppStore.getState() const existingIdx = state.repos.findIndex((r) => r.id === repo.id) // Why: the IPC handler dedupes by path (see repos:create) and returns @@ -107,13 +145,7 @@ export function useCreateRepo( // handler took the dedup path — no new project was created, so don't // claim one was. const wasDeduped = existingIdx !== -1 - if (existingIdx === -1) { - useAppStore.setState({ repos: [...state.repos, repo] }) - } else { - const updated = [...state.repos] - updated[existingIdx] = repo - useAppStore.setState({ repos: updated }) - } + upsertAddedRepoWithProjectHostSetup(repo) if (wasDeduped) { toast.info( translate( @@ -137,7 +169,11 @@ export function useCreateRepo( // Why: if refresh is temporarily non-authoritative, the shared opener // still reveals the project so the user is not left in a completed add flow. await fetchWorktrees(repo.id, { requireAuthoritative: true }) - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } await onGitRepoReady?.(repo.id) @@ -145,7 +181,11 @@ export function useCreateRepo( // Why: folder repos skip the Git default-checkout handoff, so activate the synthetic // root workspace before closing. Matches addNonGitFolder's behavior. await fetchWorktrees(repo.id) - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0] @@ -156,26 +196,46 @@ export function useCreateRepo( closeModal() } } catch (err) { - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } - setCreateError(err instanceof Error ? err.message : String(err)) + setCreateError(extractIpcErrorMessage(err, String(err))) } finally { // Why: only clear the loading state if this invocation is still current; // a superseded create must not flip the flag back off for a new flow. - if (gen === createGenRef.current && mountedRef.current) { + if ( + gen === createGenRef.current && + requestHostToken === hostTokenRef.current && + mountedRef.current + ) { setIsCreating(false) } } - }, [createName, createParent, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) + }, [ + createName, + createParent, + createKind, + fetchWorktrees, + mountedRef, + closeModal, + onGitRepoReady, + options.runtimeEnvironmentId, + options.sshTargetId + ]) return { createName, createParent, + createKind, createError, isCreating, setCreateName, setCreateParent, + setCreateKind, setCreateError, resetCreateState, handlePickParent, diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index 2f0ba7539b2..f44048aea87 100644 --- a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -70,7 +70,7 @@ export function useSidebarProjectDrop(): { { description: translate( 'auto.components.sidebar.useSidebarProjectDrop.5ccb56c7be', - 'Use Add Project to enter a server path.' + 'Use Add Project to enter a host path.' ) } ) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index baec0eff6d3..c0a2bb9d924 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -7,6 +7,7 @@ import { sidebarHasActiveFilters } from './visible-worktrees' import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { return { @@ -81,6 +82,8 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions browserTabsByWorktree: {}, hideDefaultBranchWorkspace: false, repoMap, + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {}, ...overrides } @@ -206,6 +209,122 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([folder.id]) }) + it('filters worktrees to a selected SSH host scope', () => { + const local = makeWorktree('local', 'repo1') + const remote = makeWorktree('remote', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'win vm' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [remote] }, + [local.id, remote.id], + visibleOptions({ + repoMap: scopedRepoMap, + workspaceHostScope: 'ssh:win%20vm' + }) + ) + + expect(result).toEqual([remote.id]) + }) + + it('filters non-SSH worktrees to the focused runtime host compatibility scope', () => { + const runtime = makeWorktree('runtime', 'repo1') + const ssh = makeWorktree('ssh', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [runtime], repo2: [ssh] }, + [runtime.id, ssh.id], + visibleOptions({ + repoMap: scopedRepoMap, + defaultHostId: 'runtime:env-1', + workspaceHostScope: 'runtime:env-1' + }) + ) + + expect(result).toEqual([runtime.id]) + }) + + it('filters explicit runtime-owned repos independently of the focused default host', () => { + const local = makeWorktree('local', 'repo1') + const runtime = makeWorktree('runtime', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo1', { + ...makeRepo('repo1', 'Repo 1', '#000'), + executionHostId: 'local' + }) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + executionHostId: 'runtime:env-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [runtime] }, + [local.id, runtime.id], + visibleOptions({ + repoMap: scopedRepoMap, + defaultHostId: 'runtime:env-1', + workspaceHostScope: 'local' + }) + ) + + expect(result).toEqual([local.id]) + }) + + it('keeps every host visible when workspace host scope is all', () => { + const local = makeWorktree('local', 'repo1') + const remote = makeWorktree('remote', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [remote] }, + [local.id, remote.id], + visibleOptions({ + repoMap: scopedRepoMap, + workspaceHostScope: 'all' + }) + ) + + expect(result).toEqual([local.id, remote.id]) + }) + + it('filters worktrees to a selected set of visible hosts', () => { + const local = makeWorktree('local', 'repo1') + const ssh = makeWorktree('ssh', 'repo2') + const runtime = makeWorktree('runtime', 'repo3') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + scopedRepoMap.set('repo3', { + ...makeRepo('repo3', 'Repo 3', '#222'), + executionHostId: 'runtime:env-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [ssh], repo3: [runtime] }, + [local.id, ssh.id, runtime.id], + visibleOptions({ + repoMap: scopedRepoMap, + visibleWorkspaceHostIds: ['local', 'ssh:ssh-1'] + }) + ) + + expect(result).toEqual([local.id, ssh.id]) + }) + it('hides branch-backed mains across every repo in a multi-repo workspace', () => { const main1 = makeWorktree('main1', 'repo1') main1.isMainWorktree = true @@ -405,6 +524,10 @@ describe('sidebarHasActiveFilters', () => { it('returns true when only filterRepoIds is non-empty', () => { expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true) }) + + it('returns true when only host visibility is narrowed', () => { + expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true) + }) }) describe('computeClearFilterActions', () => { @@ -412,7 +535,8 @@ describe('computeClearFilterActions', () => { expect(computeClearFilterActions(filterState())).toEqual({ resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, - resetHideDefaultBranchWorkspace: false + resetHideDefaultBranchWorkspace: false, + resetVisibleWorkspaceHostIds: false }) }) @@ -423,7 +547,8 @@ describe('computeClearFilterActions', () => { expect(computeClearFilterActions(filterState({ hideDefaultBranchWorkspace: true }))).toEqual({ resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, - resetHideDefaultBranchWorkspace: true + resetHideDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: false }) }) @@ -446,13 +571,15 @@ describe('computeClearFilterActions', () => { filterState({ showSleepingWorkspaces: false, filterRepoIds: ['repo1', 'repo2'], - hideDefaultBranchWorkspace: true + hideDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: ['local'] }) ) ).toEqual({ resetShowSleepingWorkspaces: true, resetFilterRepoIds: true, - resetHideDefaultBranchWorkspace: true + resetHideDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: true }) }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index a4ce1d05549..41cbe9753b5 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -4,6 +4,13 @@ import { isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' /** * Whether a worktree represents the repo's default-branch row that the @@ -23,6 +30,7 @@ export type SidebarFilterState = { showSleepingWorkspaces: boolean filterRepoIds: readonly string[] hideDefaultBranchWorkspace: boolean + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null } /** @@ -38,7 +46,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { return ( state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES || state.filterRepoIds.length > 0 || - state.hideDefaultBranchWorkspace + state.hideDefaultBranchWorkspace || + state.visibleWorkspaceHostIds != null ) } @@ -48,6 +57,7 @@ export type ClearFilterActions = { resetShowSleepingWorkspaces: boolean resetFilterRepoIds: boolean resetHideDefaultBranchWorkspace: boolean + resetVisibleWorkspaceHostIds: boolean } /** @@ -64,7 +74,8 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte return { resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES, resetFilterRepoIds: state.filterRepoIds.length > 0, - resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace + resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null } } @@ -93,6 +104,9 @@ export function computeVisibleWorktreeIds( // forgetting to pass it. hideDefaultBranchWorkspace: boolean repoMap: Map<string, Repo> + workspaceHostScope: ExecutionHostScope + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + defaultHostId: ExecutionHostId worktreeLineageById: Record<string, WorktreeLineage> } ): string[] { @@ -109,6 +123,24 @@ export function computeVisibleWorktreeIds( all = all.filter((w) => !isDefaultBranchWorkspace(w)) } + const visibleHostIds = + opts.visibleWorkspaceHostIds ?? + (opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope]) + if (visibleHostIds) { + const visibleHostIdSet = new Set(visibleHostIds) + all = all.filter((w) => { + const repo = opts.repoMap.get(w.repoId) + if (!repo) { + return false + } + const hostId = + repo.connectionId || repo.executionHostId + ? getRepoExecutionHostId(repo) + : opts.defaultHostId + return visibleHostIdSet.has(hostId) + }) + } + // Filter by repo if (opts.filterRepoIds.length > 0) { const selectedRepoIds = new Set(opts.filterRepoIds) @@ -258,6 +290,9 @@ export function getVisibleWorktreeIds(): string[] { browserTabsByWorktree: state.browserTabsByWorktree, hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, repoMap, + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(state.settings), worktreeLineageById: state.worktreeLineageById }) } diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 75c1091b85c..2d312983d3b 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -6,6 +6,7 @@ export type WorktreeDragUnitGroup = WorktreeDragGroup & { } type WorktreeDragUnitRow = + | { type: 'host-header' } | { type: 'header'; key: string } | { type: 'item'; worktree: { id: string }; depth: number } | { type: 'imported-worktrees-card' } @@ -29,6 +30,7 @@ export function getWorktreeDragUnitGroups( continue } if ( + row.type === 'host-header' || row.type === 'imported-worktrees-card' || row.type === 'pending-creation' || row.type === 'folder-workspace' diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 6726a397a2d..afc712abeac 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -14,6 +14,8 @@ import { } from './worktree-list-groups' import type { DetectedWorktree, + Project, + ProjectHostSetup, FolderWorkspace, Repo, ProjectGroup, @@ -51,6 +53,59 @@ const worktree: Worktree = { const repoMap = new Map([[repo.id, repo]]) +const remoteRepo: Repo = { + id: 'repo-remote', + path: '/home/alice/orca', + displayName: 'orca', + badgeColor: '#111111', + addedAt: 1, + connectionId: 'gpu-vm' +} + +const remoteWorktree: Worktree = { + ...worktree, + id: 'wt-remote', + repoId: remoteRepo.id, + path: '/home/alice/orca-feature', + displayName: 'remote feature' +} + +const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: [repo.id, remoteRepo.id], + createdAt: 1, + updatedAt: 1 +} + +const projectHostSetups: ProjectHostSetup[] = [ + { + id: repo.id, + projectId: project.id, + hostId: 'local', + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: remoteRepo.id, + projectId: project.id, + hostId: 'ssh:gpu-vm', + repoId: remoteRepo.id, + path: remoteRepo.path, + displayName: remoteRepo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } +] + function makeDetectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree { return { ...worktree, @@ -254,6 +309,177 @@ describe('buildRows with pinned worktrees', () => { expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' }) }) + it('groups multiple host setups for the same project under one project header', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, repo], + [remoteRepo.id, remoteRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [remoteWorktree.id, remoteWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { projects: [project], projectHostSetups } + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id }, hostContextLabel: 'Local Mac' }, + { type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' } + ]) + }) + + it('uses saved host labels for mixed-host sidebar card badges', () => { + const runtimeRepo: Repo = { + ...remoteRepo, + id: 'repo-runtime', + path: '/Users/alice/runtime-orca', + connectionId: null, + executionHostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' + } + const runtimeWorktree: Worktree = { + ...remoteWorktree, + id: 'wt-runtime', + repoId: runtimeRepo.id + } + const runtimeSetup: ProjectHostSetup = { + ...projectHostSetups[1]!, + id: runtimeRepo.id, + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + repoId: runtimeRepo.id, + path: runtimeRepo.path + } + const rows = buildRows( + 'repo', + [worktree, runtimeWorktree], + new Map([ + [repo.id, repo], + [runtimeRepo.id, runtimeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [runtimeWorktree.id, runtimeWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] }, + [], + new Map([ + ['local', 'Local Mac'], + ['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box'] + ]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id }, hostContextLabel: 'Local Mac' }, + { type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev box' } + ]) + }) + + it('omits host context labels when a project group only has one host', () => { + const secondLocalWorktree: Worktree = { + ...worktree, + id: 'wt-local-2', + displayName: 'local-only' + } + const rows = buildRows( + 'repo', + [worktree, secondLocalWorktree], + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [secondLocalWorktree.id, secondLocalWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id] }], + projectHostSetups: [projectHostSetups[0]] + } + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id } }, + { type: 'item', worktree: { id: secondLocalWorktree.id } } + ]) + for (const row of rows) { + if (row.type === 'item') { + expect(row.hostContextLabel).toBeUndefined() + } + } + }) + + it('keeps same-named repos separate without project setup identity', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, { ...repo, displayName: 'orca' }], + [remoteRepo.id, { ...remoteRepo, displayName: 'orca' }] + ]), + null, + new Set() + ) + + expect(rows.filter((row) => row.type === 'header')).toMatchObject([ + { key: 'repo:repo-1' }, + { key: 'repo:repo-remote' } + ]) + }) + + it('returns project group keys for worktree reveal when project setup identity exists', () => { + expect( + getGroupKeyForWorktree( + 'repo', + remoteWorktree, + new Map([[remoteRepo.id, remoteRepo]]), + null, + undefined, + undefined, + { + projects: [project], + projectHostSetups + } + ) + ).toBe('project:github:stablyai/orca') + }) + it('emits an imported worktrees card at the top of repo-group rows', () => { const hidden = [ makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }), @@ -1185,77 +1411,6 @@ describe('project groups', () => { ]) }) - it('keeps missing projectGroupOrder siblings in manual fallback slots', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoA: Repo = { - ...repo, - id: 'repo-a', - displayName: 'alpha', - projectGroupId: group.id - } - const repoB: Repo = { - ...repo, - id: 'repo-b', - displayName: 'beta', - projectGroupId: group.id, - projectGroupOrder: 1000 - } - const repoC: Repo = { - ...repo, - id: 'repo-c', - displayName: 'gamma', - projectGroupId: group.id - } - const repoMap = new Map([ - [repoA.id, repoA], - [repoB.id, repoB], - [repoC.id, repoC] - ]) - const repoOrder = new Map([ - [repoA.id, 0], - [repoB.id, 1], - [repoC.id, 2] - ]) - - const rows = buildRows( - 'repo', - [ - { ...worktree, id: 'wt-a', repoId: repoA.id }, - { ...worktree, id: 'wt-b', repoId: repoB.id }, - { ...worktree, id: 'wt-c', repoId: repoC.id } - ], - repoMap, - null, - new Set(), - repoOrder, - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-a', - 'repo:repo-b', - 'repo:repo-c' - ]) - }) - it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => { const groupA: ProjectGroup = { id: 'group-a', @@ -1432,6 +1587,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1508,6 +1664,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1576,6 +1733,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 43a7e5397f4..97e073fd43e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -3,6 +3,8 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' import type { DetectedWorktree, + Project, + ProjectHostSetup, FolderWorkspace, Repo, ProjectGroup, @@ -27,8 +29,9 @@ import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-stat import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' -import { getRepoDisplayLabelsByPath } from '../../lib/repo-display-labels' -import { translate } from '../../i18n/i18n' +import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' export { branchName } @@ -57,6 +60,7 @@ export type WorktreeRow = { lineageChildCount: number lineageGroupKey?: string lineageCollapsed?: boolean + hostContextLabel?: string } export type ImportedWorktreesCardCandidate = { @@ -113,7 +117,63 @@ function buildPendingCreationRow( } } -type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }] +type OrderedGroupEntry = [string, WorktreeGroupEntry] + +export type ProjectGroupingModel = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} + +type WorktreeGroupEntry = { + label: string + items: Worktree[] + repo?: Repo + repoIds: Set<string> +} + +type ProjectGroupingIndex = { + projectById: Map<string, Project> + setupByRepoId: Map<string, ProjectHostSetup> +} + +function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { + const projects = model?.projects ?? [] + const projectHostSetups = model?.projectHostSetups ?? [] + if (projects.length === 0 || projectHostSetups.length === 0) { + return null + } + return { + projectById: new Map(projects.map((project) => [project.id, project])), + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) + } +} + +function getProjectGroupingForRepo( + repoId: string, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null +): { key: string; label: string; repo?: Repo; projectId?: string } { + const repo = repoMap.get(repoId) + const setup = projectIndex?.setupByRepoId.get(repoId) + const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined + if (!setup || !project) { + return { + key: `repo:${repoId}`, + label: repo?.displayName ?? 'Unknown', + repo + } + } + return { + key: `project:${project.id}`, + label: project.displayName, + repo, + projectId: project.id + } +} + +function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { + group.repoIds.add(repoId) +} export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' @@ -226,7 +286,14 @@ export function getPRGroupKey( const branch = branchName(worktree.branch) const repoScopedCacheKey = repo && branch - ? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId) + ? getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) : '' const canUseLegacyPRCache = repo !== undefined && !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId @@ -328,7 +395,8 @@ function buildWorktreeRow( lineageTrail: boolean[], isLastLineageChild: boolean, lineageChildCount: number, - lineageCollapsed: boolean + lineageCollapsed: boolean, + hostContextLabel?: string ): WorktreeRow { return { type: 'item', @@ -339,6 +407,7 @@ function buildWorktreeRow( lineageTrail, isLastLineageChild, lineageChildCount, + ...(hostContextLabel ? { hostContextLabel } : {}), ...(lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}), ...(lineageChildCount > 0 ? { lineageCollapsed } : {}) } @@ -354,12 +423,25 @@ function appendWorktreeRows( nestLineage: boolean collapsedGroups: Set<string> groupDepth: number + hostContextLabelByRepoId?: ReadonlyMap<string, string> } ): void { - const { nestLineage, collapsedGroups, groupDepth } = options + const { nestLineage, collapsedGroups, groupDepth, hostContextLabelByRepoId } = options if (!nestLineage) { for (const worktree of worktrees) { - result.push(buildWorktreeRow(worktree, repoMap, 0, groupDepth, [], false, 0, false)) + result.push( + buildWorktreeRow( + worktree, + repoMap, + 0, + groupDepth, + [], + false, + 0, + false, + hostContextLabelByRepoId?.get(worktree.repoId) + ) + ) } return } @@ -401,7 +483,8 @@ function appendWorktreeRows( lineageTrail, isLastChild, children.length, - lineageCollapsed + lineageCollapsed, + hostContextLabelByRepoId?.get(worktree.repoId) ) ) if (lineageCollapsed) { @@ -432,6 +515,43 @@ function appendWorktreeRows( } } +function getRepoHostLabel( + repoId: string, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap<string, string> | undefined +): string | null { + const setup = projectIndex?.setupByRepoId.get(repoId) + if (setup) { + return hostLabelById?.get(setup.hostId) ?? getExecutionHostLabel(setup.hostId) + } + const repo = repoMap.get(repoId) + if (!repo) { + return null + } + const hostId = getRepoExecutionHostId(repo) + return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) +} + +function getMixedHostContextLabels( + group: WorktreeGroupEntry, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap<string, string> | undefined +): Map<string, string> | undefined { + const labelsByRepoId = new Map<string, string>() + const uniqueLabels = new Set<string>() + for (const repoId of group.repoIds) { + const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById) + if (!label) { + continue + } + labelsByRepoId.set(repoId, label) + uniqueLabels.add(label) + } + return uniqueLabels.size > 1 ? labelsByRepoId : undefined +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -561,9 +681,12 @@ export function buildRows( placeholderRepoIds: ReadonlySet<string> = new Set(), importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(), pendingCreations: readonly PendingCreationRef[] = [], - folderWorkspaces: readonly FolderWorkspace[] = [] + projectGrouping?: ProjectGroupingModel, + folderWorkspaces: readonly FolderWorkspace[] = [], + hostLabelById?: ReadonlyMap<string, string> ): Row[] { const result: Row[] = [] + const projectIndex = buildProjectGroupingIndex(projectGrouping) const pendingByRepo = new Map<string, PendingCreationRef[]>() for (const creation of pendingCreations) { @@ -618,15 +741,16 @@ export function buildRows( return result } - const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>() + const grouped = new Map<string, WorktreeGroupEntry>() for (const w of unpinned) { let key: string let label: string let repo: Repo | undefined if (groupBy === 'repo') { - repo = repoMap.get(w.repoId) - key = `repo:${w.repoId}` - label = repo?.displayName ?? 'Unknown' + const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex) + key = grouping.key + label = grouping.label + repo = grouping.repo } else if (groupBy === 'workspace-status') { const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses) key = getWorkspaceStatusGroupKey(workspaceStatus) @@ -638,45 +762,65 @@ export function buildRows( label = PR_GROUP_META[prGroup].label } if (!grouped.has(key)) { - grouped.set(key, { label, items: [], repo }) + grouped.set(key, { label, items: [], repo, repoIds: new Set() }) } - grouped.get(key)!.items.push(w) + const group = grouped.get(key)! + group.items.push(w) + addRepoIdToGroup(group, w.repoId) } if (groupBy === 'repo') { for (const repoId of placeholderRepoIds) { - const repo = repoMap.get(repoId) - if (!repo) { + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + if (!grouping.repo) { continue } - const key = `repo:${repoId}` + const key = grouping.key if (!grouped.has(key)) { // Why: repos can arrive before worktree scans, but stale IDs passed by // older snapshots must not render an "Unknown" project header. - grouped.set(key, { label: repo.displayName, items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const [repoId, candidate] of importedWorktreesByRepo) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { grouped.set(key, { - label: candidate.repo.displayName, + label: grouping.label, items: [], - repo: candidate.repo + repo: grouping.repo ?? candidate.repo, + repoIds: new Set([repoId]) }) + } else if (grouped.has(key)) { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const repoId of pendingByRepo.keys()) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key)) { // Why: creating the first worktree in a repo leaves it with no group yet; // ensure one so the in-progress row nests under its repo instead of being // dropped. - const repo = repoMap.get(repoId) - grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } @@ -764,23 +908,39 @@ export function buildRows( result.push(header) if (!isCollapsed) { if (groupBy === 'repo') { - const repoId = repo?.id ?? key.slice('repo:'.length) - const candidate = importedWorktreesByRepo.get(repoId) - if (candidate) { - result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + const repoIds = + group.repoIds.size > 0 + ? [...group.repoIds] + : repo + ? [repo.id] + : key.startsWith('repo:') + ? [key.slice('repo:'.length)] + : [] + for (const repoId of repoIds) { + const candidate = importedWorktreesByRepo.get(repoId) + if (candidate) { + result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + } } // Why: surface in-progress creates at the top of their own repo so the // new workspace appears where it will land, not flashed to the very top // of the sidebar. - for (const creation of pendingByRepo.get(repoId) ?? []) { - result.push(buildPendingCreationRow(creation, repoMap)) + for (const repoId of repoIds) { + for (const creation of pendingByRepo.get(repoId) ?? []) { + result.push(buildPendingCreationRow(creation, repoMap)) + } } } const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items + const hostContextLabelByRepoId = + groupBy === 'repo' + ? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById) + : undefined appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups, - groupDepth: projectGroupDepth + groupDepth: projectGroupDepth, + hostContextLabelByRepoId }) } } @@ -808,27 +968,20 @@ export function buildRows( compareRecentRank(recentRankForEntry(left), recentRankForEntry(right)) ) } - const manualFallbackRank = new Map( - entries.map((entry) => [entry[0], manualRankForEntry(entry, repoOrder)]) - ) - // Why: legacy grouped projects may not have projectGroupOrder yet. Falling - // back to manual rank keeps one-project drag writes able to land between - // old siblings instead of any finite order jumping ahead of all missing ones. + // Manual: within a Project Group, projects order by their per-group rank + // (projectGroupOrder), not the global repoOrder. return [...entries].sort((left, right) => { const leftOrder = left[1].repo?.projectGroupOrder const rightOrder = right[1].repo?.projectGroupOrder const leftRank = typeof leftOrder === 'number' && Number.isFinite(leftOrder) ? leftOrder - : (manualFallbackRank.get(left[0]) ?? Number.POSITIVE_INFINITY) * 1000 + : Number.POSITIVE_INFINITY const rightRank = typeof rightOrder === 'number' && Number.isFinite(rightOrder) ? rightOrder - : (manualFallbackRank.get(right[0]) ?? Number.POSITIVE_INFINITY) * 1000 - if (leftRank !== rightRank) { - return leftRank - rightRank - } - return left[1].label.localeCompare(right[1].label) + : Number.POSITIVE_INFINITY + return leftRank - rightRank }) } @@ -925,7 +1078,8 @@ export function getGroupKeyForWorktree( repoMap: Map<string, Repo>, prCache: Record<string, unknown> | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - settings?: AppState['settings'] + settings?: AppState['settings'], + projectGrouping?: ProjectGroupingModel ): string | null { if (groupBy === 'none') { return ALL_GROUP_KEY @@ -934,7 +1088,11 @@ export function getGroupKeyForWorktree( return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) } if (groupBy === 'repo') { - return `repo:${worktree.repoId}` + return getProjectGroupingForRepo( + worktree.repoId, + repoMap, + buildProjectGroupingIndex(projectGrouping) + ).key } return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}` } @@ -946,7 +1104,8 @@ export function getGroupKeysForWorktree( prCache: Record<string, unknown> | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), settings?: AppState['settings'], - projectGroups: readonly ProjectGroup[] = [] + projectGroups: readonly ProjectGroup[] = [], + projectGrouping?: ProjectGroupingModel ): string[] { const groupKey = getGroupKeyForWorktree( groupBy, @@ -954,7 +1113,8 @@ export function getGroupKeysForWorktree( repoMap, prCache, workspaceStatuses, - settings + settings, + projectGrouping ) if (!groupKey) { return [] diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts new file mode 100644 index 00000000000..e0449b6eeca --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import type { VirtualItem } from '@tanstack/react-virtual' +import { + HOST_STICKY_PINNED_HEIGHT, + extractWorktreeVirtualRowIndexes, + getActiveStickyIndexesForScroll, + getStickyHeaderIndexes, + type RenderRow +} from './worktree-list-virtual-rows' + +function hostRow(hostId: string): RenderRow { + return { + type: 'host-header', + key: `host:${hostId}`, + hostId: hostId as never, + kind: 'ssh', + label: hostId, + detail: 'SSH', + health: 'available', + collapsed: false, + count: 1 + } +} + +function groupRow(key: string): RenderRow { + return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +} + +function itemStub(id: string): RenderRow { + return { type: 'item', key: id } as unknown as RenderRow +} + +function virtualItem(index: number, start: number): VirtualItem { + return { index, start } as VirtualItem +} + +// rows: [host-a, group-a1, item, item, host-b, group-b1, item] +const rows: RenderRow[] = [ + hostRow('a'), + groupRow('a1'), + itemStub('wt-1'), + itemStub('wt-2'), + hostRow('b'), + groupRow('b1'), + itemStub('wt-3') +] +const stickyHeaderIndexes = getStickyHeaderIndexes(rows) +// Geometry: each row 100px tall for easy math. +const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) + +describe('getActiveStickyIndexesForScroll', () => { + it('pins the host and its inner group while scrolled inside a section', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 2, + scrollOffset: 250, + stickyHeaderIndexes, + virtualItems + }) + ).toEqual({ hostIndex: 0, groupIndex: 1 }) + }) + + it('hands the host tier off when the next host card reaches the top', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + ).toMatchObject({ hostIndex: 4 }) + }) + + it('never pins the previous host group beneath the next host card', () => { + const result = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + // group-a1 (index 1) must not survive into host b's tenure; group-b1 only + // pins once it reaches the slot beneath the pinned host card. + expect(result.groupIndex === 1).toBe(false) + }) + + it('offsets the group handoff by the pinned host height', () => { + // group-b1 starts at 500; with host pinned it should activate once + // scrollOffset + HOST_STICKY_PINNED_HEIGHT reaches 500. + const before = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT - 1, + stickyHeaderIndexes, + virtualItems + }) + const after = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT, + stickyHeaderIndexes, + virtualItems + }) + expect(before.groupIndex).not.toBe(5) + expect(after).toEqual({ hostIndex: 4, groupIndex: 5 }) + }) + + it('degrades to single-tier rules when no host sections exist', () => { + const flatRows: RenderRow[] = [ + groupRow('g1'), + itemStub('wt-1'), + groupRow('g2'), + itemStub('wt-2') + ] + const flatSticky = getStickyHeaderIndexes(flatRows) + const flatItems = flatRows.map((_, index) => virtualItem(index, index * 100)) + expect( + getActiveStickyIndexesForScroll({ + rows: flatRows, + rangeStartIndex: 1, + scrollOffset: 150, + stickyHeaderIndexes: flatSticky, + virtualItems: flatItems + }) + ).toEqual({ hostIndex: null, groupIndex: 0 }) + }) +}) + +describe('extractWorktreeVirtualRowIndexes', () => { + it('keeps the pinned host mounted even when scrolled out of range', () => { + const indexes = extractWorktreeVirtualRowIndexes({ + range: { + startIndex: 3, + endIndex: 3, + overscan: 0, + count: rows.length, + getItemIndex: (i: number) => i + } as never, + stickyHeaderIndexes, + rows + }) + expect(indexes).toContain(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 04c011409fd..e98b9c1ee09 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -1,16 +1,19 @@ import { defaultRangeExtractor } from '@tanstack/react-virtual' import type { Range, VirtualItem } from '@tanstack/react-virtual' -import type { Row } from './worktree-list-groups' +import type { HostSectionRow } from './host-section-rows' import { PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 +export const HOST_HEADER_ROW_HEIGHT = 32 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 const FOLDER_WORKSPACE_ROW_HEIGHT = 64 -type WorktreeItemRow = Extract<Row, { type: 'item' }> -export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } +type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }> +export type RenderRow = + | HostSectionRow + | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } export function shouldUseHeaderTopSpacing(args: { rows: readonly RenderRow[] @@ -30,6 +33,18 @@ export function estimateRenderRowSize( _activeStickyHeaderIndex: number | null ): number { const row = rows[index] + if (row?.type === 'host-header') { + return ( + HOST_HEADER_ROW_HEIGHT + + (shouldUseHeaderTopSpacing({ + rows, + index, + firstHeaderIndex + }) + ? SECONDARY_GROUP_HEADER_TOP_MARGIN + : 0) + ) + } if (row?.type === 'header') { return ( GROUP_HEADER_ROW_HEIGHT + @@ -66,13 +81,97 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { rows.forEach((row, index) => { // Why: project groups are the top-level repo sidebar context; nested repo // headers should not replace their containing group as the pinned header. - if (row.type === 'header' && (row.projectGroupDepth ?? 0) === 0) { + if ( + row.type === 'host-header' || + (row.type === 'header' && (row.projectGroupDepth ?? 0) === 0) + ) { indexes.push(index) } }) return indexes } +// Why: the pinned host card is h-8 (32px) inside a pt-1 (4px) wrapper; the +// group tier pins one pixel up to sit flush beneath it. Keep in sync with +// HostSectionHeader's layout. +export const HOST_STICKY_PINNED_HEIGHT = 36 + +export type ActiveStickyIndexes = { + /** Pinned host card (tier 1), or null outside host sections. */ + hostIndex: number | null + /** Pinned group header (tier 2), offset below the host when one is pinned. */ + groupIndex: number | null +} + +function getHostStickyIndexes(rows: readonly RenderRow[], sticky: readonly number[]): number[] { + return sticky.filter((index) => rows[index]?.type === 'host-header') +} + +/** Two-tier sticky resolution: the host card is the outer hierarchy level so + * it stays pinned for the whole section while group headers hand off beneath + * it. Without host sections this degrades to the original single-tier rules. */ +export function getActiveStickyIndexesForScroll(args: { + rows: readonly RenderRow[] + rangeStartIndex: number + scrollOffset: number + stickyHeaderIndexes: readonly number[] + virtualItems: readonly VirtualItem[] +}): ActiveStickyIndexes { + const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) + + const resolveWithHandoff = ( + candidates: readonly number[], + pinnedOffset: number, + fallbackToCandidate: boolean + ): number | null => { + const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex) + if (candidateIndex === null) { + return null + } + const candidate = args.virtualItems.find((item) => item.index === candidateIndex) + if (!candidate) { + return candidateIndex + } + // Why: hand off the moment the incoming header reaches its pinned slot + // (top of the viewport, or the bottom edge of the pinned host card). + if (args.scrollOffset + pinnedOffset >= candidate.start) { + return candidateIndex + } + const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex) + if (previous !== null) { + return previous + } + // Why: a host section's first group is still in flow below the pinned + // host card until it reaches the slot — pinning it early would double + // it up. The host tier keeps the legacy fallback. + return fallbackToCandidate ? candidateIndex : null + } + + const hostIndex = resolveWithHandoff(hostIndexes, 0, true) + + const hostPosition = hostIndex === null ? -1 : hostIndexes.indexOf(hostIndex) + const nextHostIndex = + hostPosition >= 0 ? (hostIndexes[hostPosition + 1] ?? Number.POSITIVE_INFINITY) : null + const groupIndexes = args.stickyHeaderIndexes.filter((index) => { + if (args.rows[index]?.type !== 'header') { + return false + } + // Why: a group from the previous host must never pin beneath the next + // host's card — only groups inside the pinned host's section qualify. + if (hostIndex !== null) { + return index > hostIndex && index < (nextHostIndex ?? Number.POSITIVE_INFINITY) + } + return true + }) + const groupIndex = resolveWithHandoff( + groupIndexes, + hostIndex !== null ? HOST_STICKY_PINNED_HEIGHT : 0, + hostIndex === null + ) + + return { hostIndex, groupIndex } +} + export function getActiveStickyHeaderIndex( stickyHeaderIndexes: readonly number[], rangeStartIndex: number @@ -100,6 +199,7 @@ export function getPreviousStickyHeaderIndex( export function extractWorktreeVirtualRowIndexes(args: { range: Range stickyHeaderIndexes: readonly number[] + rows?: readonly RenderRow[] }): number[] { const activeStickyHeaderIndex = getActiveStickyHeaderIndex( args.stickyHeaderIndexes, @@ -113,10 +213,15 @@ export function extractWorktreeVirtualRowIndexes(args: { args.stickyHeaderIndexes, activeStickyHeaderIndex ) + // Why: the pinned host card (tier 1) can be far above the visible range + // while group headers hand off beneath it — keep it mounted regardless. + const hostIndexes = args.rows ? getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) : [] + const activeHostIndex = getActiveStickyHeaderIndex(hostIndexes, args.range.startIndex) return Array.from( new Set([ activeStickyHeaderIndex, ...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]), + ...(activeHostIndex === null ? [] : [activeHostIndex]), ...defaultRangeExtractor(args.range) ]) ).sort((a, b) => a - b) diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts new file mode 100644 index 00000000000..7f6f5d70935 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -0,0 +1,51 @@ +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import type { WorktreeMeta } from '../../../../shared/types' + +export type WorktreeMetaSavedPayload = { + worktreeId: string + updates: Partial<WorktreeMeta> +} + +export function parseExplicitGitHubIssueUrl(input: string): string | null { + const trimmed = input.trim() + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link || link.type !== 'issue') { + return null + } + + return trimmed +} + +/** Pure save-payload builder for the worktree meta dialog: empty inputs clear + * the link (null), unparseable inputs leave it untouched (omitted). */ +export function buildWorktreeMetaUpdates(args: { + displayNameInput: string + currentDisplayName: string + issueInput: string + prInput: string + commentInput: string +}): Partial<WorktreeMeta> { + const trimmedIssue = args.issueInput.trim() + const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) + const finalLinkedIssue = + trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined + const trimmedPR = args.prInput.trim() + const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR) + const finalLinkedPR = + trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined + + const trimmedDisplayName = args.displayNameInput.trim() + const updates: Partial<WorktreeMeta> = { + comment: args.commentInput.trim(), + ...(trimmedDisplayName !== args.currentDisplayName && { + displayName: trimmedDisplayName || undefined + }) + } + if (finalLinkedIssue !== undefined) { + updates.linkedIssue = finalLinkedIssue + } + if (finalLinkedPR !== undefined) { + updates.linkedPR = finalLinkedPR + } + return updates +} diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx index 6d3ea2e6e68..0626137c010 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { scanWorkspacePortsForTarget, - workspacePortRuntimeTargetKey + workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' import { getExternalWorkspacePorts, getWorkspacePortGroups } from '@/lib/workspace-port-groups' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -25,11 +25,12 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [open, setOpen] = useState(false) const [externalOpen, setExternalOpen] = useState(false) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + const scanKey = workspacePortScanKeyForTarget(runtimeTarget) const workspaceGroups = useMemo(() => getWorkspacePortGroups(scan), [scan]) const externalPorts = useMemo(() => getExternalWorkspacePorts(scan), [scan]) @@ -46,6 +47,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React // popover should still collapse that stale window without flashing icons. void scanWorkspacePortsForTarget(runtimeTarget) .then((result) => { + setWorkspacePortScanForKey(scanKey, result) setWorkspacePortScan({ key: scanKey, result }) }) .catch((error) => { @@ -61,7 +63,13 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React }) }) }, - [recordFeatureInteraction, runtimeTarget, scanKey, setWorkspacePortScan] + [ + recordFeatureInteraction, + runtimeTarget, + scanKey, + setWorkspacePortScan, + setWorkspacePortScanForKey + ] ) return ( diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx new file mode 100644 index 00000000000..a62296f6999 --- /dev/null +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx @@ -0,0 +1,117 @@ +import { useCallback, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useMountedRef } from '@/hooks/useMountedRef' + +export type RuntimeHostConnectionState = 'connected' | 'available' | 'checking' | 'disconnected' + +function runtimeStatusLabel(state: RuntimeHostConnectionState): string { + switch (state) { + case 'connected': + return translate('auto.components.status.bar.SshStatusSegment.runtime_online', 'Connected') + case 'available': + return translate('auto.components.status.bar.SshStatusSegment.runtime_available', 'Available') + case 'checking': + return translate('auto.components.status.bar.SshStatusSegment.runtime_checking', 'Checking') + case 'disconnected': + return translate( + 'auto.components.status.bar.SshStatusSegment.runtime_unavailable', + 'Disconnected' + ) + } +} + +function runtimeDotColor(state: RuntimeHostConnectionState): string { + switch (state) { + case 'connected': + return 'bg-emerald-500' + case 'checking': + return 'bg-yellow-500' + case 'available': + case 'disconnected': + return 'bg-muted-foreground/40' + } +} + +function runtimeStatusTone(state: RuntimeHostConnectionState): string { + if (state === 'checking') { + return 'text-yellow-500' + } + return 'text-muted-foreground' +} + +function runtimeActionLabel(state: RuntimeHostConnectionState): string | null { + switch (state) { + case 'connected': + return translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect') + case 'available': + case 'disconnected': + return translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect') + case 'checking': + return null + } +} + +export function RuntimeHostStatusRow({ + label, + state, + onConnect, + onDisconnect +}: { + label: string + state: RuntimeHostConnectionState + onConnect?: () => Promise<void> + onDisconnect?: () => Promise<void> +}): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + const actionLabel = runtimeActionLabel(state) + + const handleAction = useCallback(async () => { + const action = state === 'connected' ? onDisconnect : onConnect + if (!action) { + return + } + setBusy(true) + try { + await action() + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, onConnect, onDisconnect, state]) + + return ( + <div className="flex items-center gap-2.5 px-2 py-1.5"> + <span className={`size-1.5 shrink-0 rounded-full ${runtimeDotColor(state)}`} /> + <div className="min-w-0 flex-1"> + <div className="truncate text-[12px] font-medium">{label}</div> + <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> + <span> + {translate( + 'auto.components.status.bar.SshStatusSegment.remote_server', + 'Remote Server' + )} + </span> + <span aria-hidden="true">·</span> + <span className={`inline-flex min-w-0 items-center gap-1 ${runtimeStatusTone(state)}`}> + {state === 'checking' ? <Loader2 className="size-2.5 shrink-0 animate-spin" /> : null} + <span className="truncate">{runtimeStatusLabel(state)}</span> + </span> + </div> + </div> + {busy ? ( + <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + ) : actionLabel && (state === 'connected' ? onDisconnect : onConnect) ? ( + <button + type="button" + onClick={() => void handleAction()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" + > + {actionLabel} + </button> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 9a959648c4d..529f2da5130 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState } from 'react' -import { AlertTriangle, Cloud, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' +import React, { useCallback, useMemo } from 'react' +import { AlertTriangle, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { DropdownMenu, @@ -8,23 +8,22 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '../../store' -import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh' import { translate } from '@/i18n/i18n' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' +import { SshTargetStatusRow } from './SshTargetStatusRow' function isConnecting(status: SshConnectionStatus): boolean { return ['connecting', 'deploying-relay', 'reconnecting'].includes(status) } -function isReconnectable(status: SshConnectionStatus): boolean { - return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) -} +type HostStatus = 'connected' | 'disconnected' | 'connecting' function overallStatus( - statuses: SshConnectionStatus[] + statuses: HostStatus[] ): 'connected' | 'partial' | 'disconnected' | 'connecting' { if (statuses.length === 0) { return 'disconnected' @@ -32,7 +31,7 @@ function overallStatus( if (statuses.every((s) => s === 'connected')) { return 'connected' } - if (statuses.some((s) => isConnecting(s))) { + if (statuses.some((s) => s === 'connecting')) { return 'connecting' } if (statuses.some((s) => s === 'connected')) { @@ -41,12 +40,15 @@ function overallStatus( return 'disconnected' } -function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string { +function overallDotColor( + status: 'connected' | 'partial' | 'disconnected' | 'connecting', + connectedCount: number +): string { switch (status) { case 'connected': return 'bg-emerald-500' case 'partial': - return 'bg-yellow-500' + return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40' case 'connecting': return 'bg-yellow-500' case 'disconnected': @@ -54,149 +56,45 @@ function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'con } } -function overallLabel(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string { - switch (status) { - case 'connected': - return 'Connected' - case 'partial': - return 'Partial' - case 'connecting': - return 'Connecting…' - case 'disconnected': - return 'Disconnected' - } +function connectedHostCountLabel(count: number): string { + return `${count} ${count === 1 ? 'host' : 'hosts'}` } -function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string { - switch (status?.phase) { - case 'pulling': - return 'Sync pulling' - case 'pushing': - return 'Sync pushing' - case 'synced': - return status.direction === 'pull' ? 'Sync pulled' : 'Sync uploaded' - case 'conflict': - return 'Sync conflict' - case 'error': - return 'Sync error' - case 'offline': - return 'Sync unavailable' - case 'idle': - case undefined: - return 'Sync idle' +function sshStatusForOverall(status: SshConnectionStatus): HostStatus { + if (status === 'connected') { + return 'connected' } + return isConnecting(status) ? 'connecting' : 'disconnected' } -function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string { - switch (status?.phase) { - case 'conflict': - case 'error': - return 'text-destructive' - case 'offline': - return 'text-muted-foreground' - case 'pulling': - case 'pushing': - return 'text-yellow-500' - case 'synced': - return 'text-emerald-500' - case 'idle': - case undefined: - return 'text-muted-foreground' - } -} - -function TargetRow({ - targetId, - label, - status, - syncStatus +function runtimeHostConnectionState({ + hasStatus, + online, + active }: { - targetId: string - label: string - status: SshConnectionStatus - syncStatus: RemoteWorkspaceSyncStatus | undefined -}): React.JSX.Element { - const [busy, setBusy] = useState(false) - const mountedRef = useMountedRef() - const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + hasStatus: boolean + online: boolean + active: boolean +}): RuntimeHostConnectionState { + if (!hasStatus) { + return 'checking' + } + if (!online) { + return 'disconnected' + } + return active ? 'connected' : 'available' +} - const handleConnect = useCallback(async () => { - setBusy(true) - try { - await window.api.ssh.connect({ targetId }) - recordFeatureInteraction('ssh') - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed') - ) - } finally { - if (mountedRef.current) { - setBusy(false) - } - } - }, [mountedRef, recordFeatureInteraction, targetId]) - - const handleDisconnect = useCallback(async () => { - setBusy(true) - try { - await window.api.ssh.disconnect({ targetId }) - recordFeatureInteraction('ssh') - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.status.bar.SshStatusSegment.bf07aee59e', 'Disconnect failed') - ) - } finally { - if (mountedRef.current) { - setBusy(false) - } - } - }, [mountedRef, recordFeatureInteraction, targetId]) - - return ( - <div className="flex items-center gap-2.5 px-2 py-1.5"> - <span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} /> - <div className="min-w-0 flex-1"> - <div className="truncate text-[12px] font-medium">{label}</div> - <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> - <span>{STATUS_LABELS[status]}</span> - <span aria-hidden="true">·</span> - <span className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}> - {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( - <Loader2 className="size-2.5 shrink-0 animate-spin" /> - ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( - <AlertTriangle className="size-2.5 shrink-0" /> - ) : ( - <Cloud className="size-2.5 shrink-0" /> - )} - <span className="truncate">{syncStatusLabel(syncStatus)}</span> - </span> - </div> - </div> - {busy ? ( - <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> - ) : isReconnectable(status) ? ( - <button - type="button" - onClick={() => void handleConnect()} - className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70" - > - {translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')} - </button> - ) : status === 'connected' ? ( - <button - type="button" - onClick={() => void handleDisconnect()} - className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" - > - {translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect')} - </button> - ) : null} - </div> - ) +function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { + switch (state) { + case 'connected': + return 'connected' + case 'checking': + return 'connecting' + case 'available': + case 'disconnected': + return 'disconnected' + } } export function SshStatusSegment({ @@ -208,6 +106,11 @@ export function SshStatusSegment({ }): React.JSX.Element | null { const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId ) @@ -215,6 +118,7 @@ export function SshStatusSegment({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => { const state = sshConnectionStates.get(id) return { @@ -224,13 +128,70 @@ export function SshStatusSegment({ syncStatus: remoteWorkspaceSyncStatusByTargetId[id] } }) + const runtimeHosts = runtimeEnvironments.map((environment) => { + const statusEntry = runtimeStatusByEnvironmentId.get(environment.id) + const override = hostLabelOverrides.get(toRuntimeExecutionHostId(environment.id)) + return { + id: environment.id, + label: override || environment.name || environment.id, + hasStatus: Boolean(statusEntry), + online: Boolean(statusEntry?.status), + active: settings?.activeRuntimeEnvironmentId === environment.id + } + }) + const runtimeHostRows = runtimeHosts.map((host) => ({ + ...host, + state: runtimeHostConnectionState(host) + })) + const connectedRuntimeHosts = runtimeHostRows.filter((host) => host.state === 'connected') + const inactiveRuntimeHosts = runtimeHostRows.filter((host) => host.state !== 'connected') + const connectedTargets = targets.filter((target) => target.status === 'connected') + const disconnectedTargets = targets.filter((target) => target.status !== 'connected') + const connectRuntimeHost = useCallback( + async (environmentId: string): Promise<void> => { + const switched = await switchRuntimeEnvironment(environmentId) + if (switched) { + recordFeatureInteraction('ssh') + } + }, + [recordFeatureInteraction, switchRuntimeEnvironment] + ) + const disconnectRuntimeHost = useCallback( + async (environmentId: string, isActive: boolean): Promise<void> => { + try { + if (isActive) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + return + } + } + await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) + setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.status.bar.SshStatusSegment.runtime_disconnect_failed', + 'Disconnect failed' + ) + ) + } + }, + [recordFeatureInteraction, setRuntimeEnvironmentStatus, switchRuntimeEnvironment] + ) - if (targets.length === 0) { + if (targets.length === 0 && runtimeHosts.length === 0) { return null } - const statuses = targets.map((t) => t.status) + const statuses = [ + ...targets.map((t) => sshStatusForOverall(t.status)), + ...runtimeHostRows.map((host) => runtimeStatusForOverall(host.state)) + ] const overall = overallStatus(statuses) + const connectedHostCount = statuses.filter((status) => status === 'connected').length const anyConnecting = overall === 'connecting' const syncProblem = targets.find( (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error' @@ -240,7 +201,6 @@ export function SshStatusSegment({ ? 'Workspace conflict' : 'Workspace sync error' : null - return ( <DropdownMenu onOpenChange={(open) => { @@ -255,14 +215,14 @@ export function SshStatusSegment({ className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70" aria-label={translate( 'auto.components.status.bar.SshStatusSegment.fdc57e9970', - 'SSH connection status' + 'Remote host connection status' )} > {iconOnly ? ( <span className="inline-flex items-center gap-1"> <span className={`inline-block size-2 rounded-full ${ - syncProblem ? 'bg-destructive' : overallDotColor(overall) + syncProblem ? 'bg-destructive' : overallDotColor(overall, connectedHostCount) }`} /> {syncProblem ? ( @@ -288,15 +248,15 @@ export function SshStatusSegment({ )} {!compact && ( <span className="text-[11px]"> - {translate('auto.components.status.bar.SshStatusSegment.d09ec41831', 'SSH')}{' '} <span className={syncProblem ? 'text-destructive' : 'text-muted-foreground'}> - {syncProblemLabel ?? overallLabel(overall)} + {syncProblemLabel ?? + (anyConnecting ? 'Connecting…' : connectedHostCountLabel(connectedHostCount))} </span> </span> )} <span className={`inline-block size-1.5 rounded-full ${ - syncProblem ? 'bg-destructive' : overallDotColor(overall) + syncProblem ? 'bg-destructive' : overallDotColor(overall, connectedHostCount) }`} /> </span> @@ -310,10 +270,37 @@ export function SshStatusSegment({ className="w-[min(20rem,calc(100vw-1rem))]" > <div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> - {translate('auto.components.status.bar.SshStatusSegment.6e8a9a4242', 'SSH Connections')} + {translate('auto.components.status.bar.SshStatusSegment.6e8a9a4242', 'Remote Hosts')} </div> - {targets.map((t) => ( - <TargetRow + {connectedRuntimeHosts.map((host) => ( + <RuntimeHostStatusRow + key={host.id} + label={host.label} + state={host.state} + onConnect={() => connectRuntimeHost(host.id)} + onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + /> + ))} + {connectedTargets.map((t) => ( + <SshTargetStatusRow + key={t.id} + targetId={t.id} + label={t.label} + status={t.status} + syncStatus={t.syncStatus} + /> + ))} + {inactiveRuntimeHosts.map((host) => ( + <RuntimeHostStatusRow + key={host.id} + label={host.label} + state={host.state} + onConnect={() => connectRuntimeHost(host.id)} + onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + /> + ))} + {disconnectedTargets.map((t) => ( + <SshTargetStatusRow key={t.id} targetId={t.id} label={t.label} @@ -325,11 +312,14 @@ export function SshStatusSegment({ <DropdownMenuItem onSelect={() => { recordFeatureInteraction('ssh') - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsTarget({ pane: 'servers', repoId: null }) setActiveView('settings') }} > - {translate('auto.components.status.bar.SshStatusSegment.3ad70e0365', 'Manage SSH…')} + {translate( + 'auto.components.status.bar.SshStatusSegment.3ad70e0365', + 'Manage Remote Hosts…' + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx new file mode 100644 index 00000000000..a3f050bf0e6 --- /dev/null +++ b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx @@ -0,0 +1,152 @@ +import { useCallback, useState } from 'react' +import { AlertTriangle, Cloud, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '../../store' +import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh' + +function isReconnectable(status: SshConnectionStatus): boolean { + return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) +} + +function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string | null { + switch (status?.phase) { + case 'pulling': + case 'pushing': + return 'Workspace syncing' + case 'conflict': + return 'Workspace sync conflict' + case 'error': + return 'Workspace sync error' + case 'offline': + return 'Workspace sync unavailable' + case 'synced': + case 'idle': + case undefined: + return null + } +} + +function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string { + switch (status?.phase) { + case 'conflict': + case 'error': + return 'text-destructive' + case 'offline': + return 'text-muted-foreground' + case 'pulling': + case 'pushing': + return 'text-yellow-500' + case 'synced': + return 'text-emerald-500' + case 'idle': + case undefined: + return 'text-muted-foreground' + } +} + +export function SshTargetStatusRow({ + targetId, + label, + status, + syncStatus +}: { + targetId: string + label: string + status: SshConnectionStatus + syncStatus: RemoteWorkspaceSyncStatus | undefined +}): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const visibleSyncStatusLabel = syncStatusLabel(syncStatus) + + const handleConnect = useCallback(async () => { + setBusy(true) + try { + await window.api.ssh.connect({ targetId }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed') + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, recordFeatureInteraction, targetId]) + + const handleDisconnect = useCallback(async () => { + setBusy(true) + try { + await window.api.ssh.disconnect({ targetId }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.status.bar.SshStatusSegment.bf07aee59e', 'Disconnect failed') + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, recordFeatureInteraction, targetId]) + + return ( + <div className="flex items-center gap-2.5 px-2 py-1.5"> + <span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} /> + <div className="min-w-0 flex-1"> + <div className="truncate text-[12px] font-medium">{label}</div> + <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> + <span>SSH Host</span> + <span aria-hidden="true">·</span> + <span>{STATUS_LABELS[status]}</span> + {visibleSyncStatusLabel ? ( + <> + <span aria-hidden="true">·</span> + <span + className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`} + > + {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( + <Loader2 className="size-2.5 shrink-0 animate-spin" /> + ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( + <AlertTriangle className="size-2.5 shrink-0" /> + ) : ( + <Cloud className="size-2.5 shrink-0" /> + )} + <span className="truncate">{visibleSyncStatusLabel}</span> + </span> + </> + ) : null} + </div> + </div> + {busy ? ( + <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + ) : isReconnectable(status) ? ( + <button + type="button" + onClick={() => void handleConnect()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70" + > + {translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')} + </button> + ) : status === 'connected' ? ( + <button + type="button" + onClick={() => void handleDisconnect()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" + > + {translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect')} + </button> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 3929d01e000..d64fea08500 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1905,7 +1905,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele }} > <Server className="size-3.5" /> - {translate('auto.components.status.bar.StatusBar.24ac89df1a', 'SSH Status')} + {translate('auto.components.status.bar.StatusBar.24ac89df1a', 'Remote Hosts')} </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={statusBarItems.includes('resource-usage')} diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index dbd505533e4..7e759dbaac7 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -29,7 +29,7 @@ import type { AgentStatusEntry, MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' -import type { GitStatusResult, TerminalTab, Worktree } from '../../../../shared/types' +import type { GitStatusResult, Repo, TerminalTab, Worktree } from '../../../../shared/types' import type { WorkspaceSpaceItem, WorkspaceSpaceWorktree @@ -38,8 +38,9 @@ import { cn } from '@/lib/utils' import { toast } from 'sonner' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' -import { getWorktreeMapFromState } from '../../store/selectors' +import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' +import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github' import { refreshGitStatusForWorktree } from '../right-sidebar/git-status-refresh' import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' import { branchDisplayName } from '../sidebar/WorktreeCardHelpers' @@ -120,6 +121,7 @@ type WorkspaceDecisionDetails = { } type WorkspaceDecisionInputs = { + repoMap: Map<string, Repo> worktreeMap: Map<string, Worktree> tabsByWorktree: Record<string, TerminalTab[]> ptyIdsByTabId: Record<string, string[]> @@ -211,7 +213,20 @@ function getWorkspaceDecisionDetails( ? `PR #${linkedPR}` : null const linkedIssue = workspaceRecord?.linkedIssue ?? null - const issue = linkedIssue ? inputs.issueCache[`${worktree.repoId}::${linkedIssue}`]?.data : null + const repo = inputs.repoMap.get(worktree.repoId) + const issue = + linkedIssue && repo + ? inputs.issueCache[ + getIssueCacheKey( + repo.path, + repo.id, + linkedIssue, + inputs.settings, + repo.connectionId, + repo.executionHostId + ) + ]?.data + : null const issueLabel = linkedIssue ? issue ? `#${issue.number} ${issue.state}: ${issue.title}` @@ -1205,6 +1220,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) const removeWorktree = useAppStore((state) => state.removeWorktree) const deleteStateByWorktreeId = useAppStore((state) => state.deleteStateByWorktreeId) + const repoMap = useAppStore((state) => getRepoMapFromState(state)) const worktreeMap = useAppStore((state) => getWorktreeMapFromState(state)) const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId) @@ -1260,6 +1276,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { details.set( worktree.worktreeId, getWorkspaceDecisionDetails(worktree, { + repoMap, worktreeMap, tabsByWorktree, ptyIdsByTabId, @@ -1294,6 +1311,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { linearIssueCache, openFiles, ptyIdsByTabId, + repoMap, remoteStatusesByWorktree, retainedAgentsByPaneKey, migrationUnsupportedByPtyId, @@ -1999,6 +2017,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { decisionDetails={ decisionDetailsByWorktreeId.get(worktree.worktreeId) ?? getWorkspaceDecisionDetails(worktree, { + repoMap, worktreeMap, tabsByWorktree, ptyIdsByTabId, diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx index ccb52012710..e949b9f306d 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx @@ -15,6 +15,7 @@ import { import type { WorkspacePortGroup } from '@/lib/workspace-port-groups' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { translate } from '@/i18n/i18n' @@ -72,12 +73,22 @@ export function PortRow({ external?: boolean }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree( + s, + port.kind === 'workspace' ? port.owner.worktreeId : activeWorktreeId + ) + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId, settings] + ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings) const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId) @@ -161,6 +172,8 @@ export function PortRow({ const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -182,6 +195,7 @@ export function PortRow({ recordFeatureInteraction, runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index be3d5529f11..655bf95f62d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -38,6 +38,7 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, @@ -233,8 +234,10 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + // Why: probe Windows shell capabilities on the host that owns this worktree, so + // the offered shells match the host that actually runs the terminal. const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId?.trim() || null + (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null ) const worktreeHasRemoteConnection = useAppStore((s) => { const worktree = Object.values(s.worktreesByRepo ?? {}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index fc353ae6359..db56eab1db2 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -194,4 +194,31 @@ describe('openTabEntryWithOperations', () => { }) expect(operations.createBrowserTab).not.toHaveBeenCalled() }) + + it('falls back to a local browser tab when paired runtime browser creation fails', async () => { + const operations = makeOperations({ + createWebRuntimeSessionBrowserTab: vi.fn().mockResolvedValue(false), + isWebRuntimeSessionActive: vi.fn().mockReturnValue(true) + }) + + await openTabEntryWithOperations({ + ...baseArgs, + query: 'https://example.com', + activeRuntimeEnvironmentId: 'runtime-1', + operations + }) + + expect(operations.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'runtime-1', + url: 'https://example.com/', + targetGroupId: 'group-1' + }) + expect(operations.createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true, + browserRuntimeEnvironmentId: null, + targetGroupId: 'group-1', + title: 'https://example.com/' + }) + }) }) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts index 7e84b5fc5de..c9b9c515fa7 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts @@ -14,6 +14,7 @@ import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import type { BrowserTab as BrowserTabState } from '../../../../shared/types' import type { RuntimeFileListState } from '../quick-open-file-list' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { classifyTabEntryQuery, type TabEntryActionClassification @@ -41,6 +42,7 @@ export type TabEntryOperations = { url: string, options?: { activate?: boolean + browserRuntimeEnvironmentId?: string | null targetGroupId?: string title?: string } @@ -146,18 +148,26 @@ export async function openTabEntryWithOperations({ } if (classification.kind === 'explicit-url' || classification.kind === 'host-url') { - if ( - operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && - !(await operations.createWebRuntimeSessionBrowserTab({ + const runtimeSessionActive = operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId) + if (runtimeSessionActive) { + const created = await operations.createWebRuntimeSessionBrowserTab({ worktreeId, environmentId: activeRuntimeEnvironmentId, url: classification.url, targetGroupId: groupId - })) - ) { - throw new Error('Failed to create browser tab.') - } - if (!operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + }) + if (created) { + return + } + // Why: headless remote runtimes cannot host browser panes yet; a URL open + // should still give the user a usable client-local browser tab. + operations.createBrowserTab(worktreeId, classification.url, { + activate: true, + browserRuntimeEnvironmentId: null, + targetGroupId: groupId, + title: classification.url + }) + } else { operations.createBrowserTab(worktreeId, classification.url, { activate: true, targetGroupId: groupId, @@ -210,7 +220,9 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> { throw new Error('No active worktree.') } const runtimeContext: RuntimeFileOperationArgs = { - settings: state.settings, + settings: { + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + }, worktreeId: args.worktreeId, worktreePath: worktree.path, connectionId: getConnectionId(args.worktreeId) ?? undefined @@ -222,7 +234,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> { groupId: args.groupId, worktreePath: worktree.path, runtimeContext, - activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId?.trim() ?? null, + activeRuntimeEnvironmentId: runtimeContext.settings?.activeRuntimeEnvironmentId?.trim() ?? null, classification: args.classification, operations: { createBrowserTab: state.createBrowserTab, diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 99cc14cd095..86deb40852d 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -29,6 +29,7 @@ import { type HoveredTabInsertion } from './tab-insertion' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export type { HoveredTabInsertion } @@ -71,7 +72,7 @@ function mirrorWebRuntimeTabMove( worktreeId: string } ): void { - const environmentId = useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ?? null + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) if (!isWebRuntimeSessionActive(environmentId)) { return } diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 6e7828e416f..735cde1bec7 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -27,6 +27,7 @@ import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void { if (!createdTerminal) { @@ -237,9 +238,10 @@ export function useTabGroupWorkspaceModel({ if (item.isPinned) { return } - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (item.contentType === 'terminal') { closeTerminalTab(item.entityId) if (!opts?.skipEmptyCheck) { @@ -290,9 +292,10 @@ export function useTabGroupWorkspaceModel({ if (!item || item.isPinned) { continue } - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if ( (item.contentType === 'terminal' || item.contentType === 'browser') && isWebRuntimeSessionActive(runtimeEnvironmentId) @@ -332,9 +335,10 @@ export function useTabGroupWorkspaceModel({ } focusGroup(worktreeId, groupId) activateTab(item.id) - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId, @@ -402,9 +406,10 @@ export function useTabGroupWorkspaceModel({ } focusGroup(worktreeId, groupId) activateTab(item.id) - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId, @@ -604,6 +609,7 @@ export function useTabGroupWorkspaceModel({ if ( await createWebRuntimeSessionBrowserTab({ worktreeId, + environmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), url: source.url, profileId: source.sessionProfileId, targetGroupId: groupId @@ -633,6 +639,7 @@ export function useTabGroupWorkspaceModel({ if ( await createWebRuntimeSessionTerminal({ worktreeId, + environmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId), targetGroupId: groupId, command: shellOverride, activate: true diff --git a/src/renderer/src/components/task-drawer-source-boundary.test.ts b/src/renderer/src/components/task-drawer-source-boundary.test.ts new file mode 100644 index 00000000000..0ebea790c34 --- /dev/null +++ b/src/renderer/src/components/task-drawer-source-boundary.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('task drawer source boundaries', () => { + it('threads GitHub task source context through detail mutations', () => { + const source = componentSource('GitHubItemDialog.tsx') + const issueUpdate = sourceBetween( + source, + 'async function runIssueUpdate', + 'async function runWorkItemBodyUpdate' + ) + const commentUpdate = sourceBetween( + source, + 'function addIssueCommentForRepo', + 'function addPRReviewCommentForRepo' + ) + const editSection = sourceBetween( + source, + 'function GHEditSection', + 'function GHCommentComposer' + ) + + expect(issueUpdate).toContain('sourceContext: args.sourceContext') + expect(commentUpdate).toContain('sourceContext: args.sourceContext') + expect(editSection).toContain('sourceContext,') + expect(editSection).toContain( + 'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })' + ) + expect(editSection).toContain( + 'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })' + ) + }) + + it('threads GitLab task source context through the shared drawer selector', () => { + const source = componentSource('GitLabItemDialog.tsx') + const selector = sourceBetween( + source, + 'const repoSelector = useMemo', + 'const updateCommentDraft' + ) + + expect(selector).toContain('...(repoId ? { repoId } : {})') + expect(selector).toContain('...(sourceContext ? { sourceContext } : {})') + expect(selector).toContain('}, [repoId, repoPath, sourceContext])') + expect(source).toContain('workItemDetails({ ...repoSelector') + expect(source).toContain('updateMR({ ...repoSelector') + expect(source).toContain('addMRComment({ ...repoSelector') + expect(source).toContain('addIssueComment({ ...repoSelector') + }) + + it('uses Linear task source context for drawer reads, mutations, and optimistic patches', () => { + const source = componentSource('LinearItemDrawer.tsx') + const editSection = sourceBetween( + source, + 'export function LinearIssueEditSection', + 'export function LinearIssueCommentFooter' + ) + const drawer = sourceBetween(source, 'export default function LinearItemDrawer', 'return (') + + expect(editSection).toContain('const providerSettings = sourceContext ?? settings') + expect(editSection).toContain('linearUpdateIssue(providerSettings') + expect(editSection).toContain( + 'patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })' + ) + expect(editSection).toContain( + 'patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })' + ) + expect(drawer).toContain('const providerSettings = sourceContext ?? settings') + expect(drawer).toContain('linearGetIssue(providerSettings') + expect(drawer).toContain('linearIssueComments(providerSettings') + }) + + it('uses Jira task source context for drawer reads, mutations, and optimistic patches', () => { + const source = componentSource('JiraIssueWorkspace.tsx') + const drawer = sourceBetween(source, 'export default function JiraIssueWorkspace', 'return (') + + expect(drawer).toContain('const providerSettings = sourceContext ?? settings') + expect(drawer).toContain('jiraIssueComments(providerSettings') + expect(drawer).toContain('jiraGetIssue(providerSettings') + expect(drawer).toContain('jiraListTransitions(providerSettings') + expect(drawer).toContain('jiraUpdateIssue(providerSettings') + expect(drawer).toContain('jiraAddIssueComment(') + expect(drawer).toContain('patchJiraIssue(displayed.key, optimistic, { sourceContext })') + expect(drawer).toContain('patchJiraIssue(previous.key, previous, { sourceContext })') + }) +}) diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index c58a863a6f6..4e476aa9def 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -66,12 +66,35 @@ describe('task page cache selectors', () => { { repoId: 'repo-1', repoPath: '/repo/one', + sourceKey: 'repo-1::local', sources: null, error: null } ]) }) + it('scopes repo source rows by source cache scope for retry ownership', () => { + const localRepo = { + id: 'repo-1', + path: '/same/path', + sourceCacheScope: 'source:local:github:stablyai/orca' + } + const sshRepo = { + id: 'repo-1', + path: '/same/path', + sourceCacheScope: 'source:ssh:devbox:github:stablyai/orca' + } + + expect(buildTaskPageRepoSourceState([localRepo, sshRepo], [])).toMatchObject([ + { + sourceKey: 'repo-1::source:local:github:stablyai/orca' + }, + { + sourceKey: 'repo-1::source:ssh:devbox:github:stablyai/orca' + } + ]) + }) + it('selects work-item cache entries by repo id, not legacy path keys', () => { const repo = { id: 'repo-1', path: '/same/path' } const repoEntry = entry<GitHubWorkItem[]>([workItem('issue-1', 'repo-1')]) @@ -84,6 +107,18 @@ describe('task page cache selectors', () => { expect(selectTaskPageWorkItemsCacheEntries(cache, [repo], 20, '')).toEqual([repoEntry]) }) + it('selects host-scoped work-item cache entries for remote repos', () => { + const repo = { id: 'repo-1', path: '/same/path', executionHostId: 'runtime:env-1' } + const remoteEntry = entry<GitHubWorkItem[]>([workItem('issue-remote', 'repo-1')]) + const localEntry = entry<GitHubWorkItem[]>([workItem('issue-local', 'repo-1')]) + const cache = { + [workItemsCacheKey(repo.id, 20, '')]: localEntry, + [workItemsCacheKey(repo.id, 20, '', repo.executionHostId)]: remoteEntry + } + + expect(selectTaskPageWorkItemsCacheEntries(cache, [repo], 20, '')).toEqual([remoteEntry]) + }) + it('returns null while the GitHub dialog is closed so cache writes do not re-render it', () => { const item = workItem('issue-1', 'repo-1') const cache = { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index a02601784ed..a61710abc65 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -11,6 +11,8 @@ import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../ export type TaskPageRepoCacheInput = { id: string path: string + executionHostId?: string | null + sourceCacheScope?: string | null } export type TaskPageDialogWorkItemKey = { @@ -21,6 +23,7 @@ export type TaskPageDialogWorkItemKey = { export type TaskPageRepoSourceState = { repoId: string repoPath: string + sourceKey: string sources: WorkItemsCacheSources | null error: WorkItemsCacheError | null } @@ -51,7 +54,12 @@ export function selectTaskPageWorkItemsCacheEntries( limit: number, query: string ): (CacheEntry<GitHubWorkItem[]> | undefined)[] { - return repos.map((repo) => workItemsCache[workItemsCacheKey(repo.id, limit, query)]) + return repos.map( + (repo) => + workItemsCache[ + workItemsCacheKey(repo.id, limit, query, repo.sourceCacheScope ?? repo.executionHostId) + ] + ) } export function buildTaskPageRepoSourceState( @@ -63,6 +71,7 @@ export function buildTaskPageRepoSourceState( return { repoId: repo.id, repoPath: repo.path, + sourceKey: `${repo.id}::${repo.sourceCacheScope ?? repo.executionHostId ?? 'local'}`, sources: entry?.sources ?? null, error: entry?.error ?? null } diff --git a/src/renderer/src/components/task-page-default-repo-selection.test.ts b/src/renderer/src/components/task-page-default-repo-selection.test.ts new file mode 100644 index 00000000000..80d57bcfae3 --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' +import { + getDefaultTaskRepoSelection, + getTaskProjectPickerGroups, + getTaskProjectPickerRepos, + normalizeTaskRepoSelection +} from './task-page-default-repo-selection' + +function repo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +describe('getDefaultTaskRepoSelection', () => { + it('selects one source per logical GitHub project', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'local-orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'other', + upstream: { owner: 'stablyai', repo: 'other' } + }) + ]) + + expect([...selection].sort()).toEqual(['local-orca', 'other']) + }) + + it('prefers local checkout over a remote checkout for the same project', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'ssh-orca', + addedAt: 1, + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'local-orca', + addedAt: 2, + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ]) + + expect([...selection]).toEqual(['local-orca']) + }) + + it('keeps same-named folders separate when provider identity is missing', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ id: 'local-app', displayName: 'app' }), + repo({ id: 'ssh-app', displayName: 'app', connectionId: 'builder' }) + ]) + + expect([...selection].sort()).toEqual(['local-app', 'ssh-app']) + }) + + it('uses GitHub repo icon metadata to identify legacy duplicate projects', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'local-claude-swap', + displayName: 'claude-swap', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/claude-swap' + } + }), + repo({ + id: 'ssh-claude-swap', + displayName: 'claude-swap', + connectionId: 'builder', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/claude-swap' + } + }) + ]) + + expect([...selection]).toEqual(['local-claude-swap']) + }) +}) + +describe('getTaskProjectPickerRepos', () => { + it('shows one picker row per logical GitHub project', () => { + const pickerRepos = getTaskProjectPickerRepos([ + repo({ + id: 'local-orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'other', + upstream: { owner: 'stablyai', repo: 'other' } + }) + ]) + + expect(pickerRepos.map((candidate) => candidate.id)).toEqual(['local-orca', 'other']) + }) + + it('uses an explicitly selected remote source as the visible project row', () => { + const pickerRepos = getTaskProjectPickerRepos( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect(pickerRepos.map((candidate) => candidate.id)).toEqual(['ssh-orca']) + }) + + it('collapses legacy local and SSH rows that share a GitHub repo icon identity', () => { + const pickerRepos = getTaskProjectPickerRepos([ + repo({ + id: 'local-claude-swap', + displayName: 'claude-swap', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/claude-swap' + } + }), + repo({ + id: 'ssh-claude-swap', + displayName: 'claude-swap', + connectionId: 'builder', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/claude-swap' + } + }) + ]) + + expect(pickerRepos.map((candidate) => candidate.id)).toEqual(['local-claude-swap']) + }) +}) + +describe('getTaskProjectPickerGroups', () => { + it('keeps all host sources under one logical project row', () => { + const groups = getTaskProjectPickerGroups([ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'docs', + upstream: { owner: 'stablyai', repo: 'docs' } + }) + ]) + + expect(groups).toHaveLength(2) + expect(groups[0]).toMatchObject({ + projectKey: 'github:stablyai/orca', + repo: { id: 'local-orca' } + }) + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local-orca', 'ssh-orca']) + expect(groups[1]).toMatchObject({ + projectKey: 'github:stablyai/docs', + repo: { id: 'docs' } + }) + }) + + it('uses the explicitly selected source as the project representative', () => { + const groups = getTaskProjectPickerGroups( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect(groups[0]?.repo.id).toBe('ssh-orca') + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local-orca', 'ssh-orca']) + }) +}) + +describe('normalizeTaskRepoSelection', () => { + it('collapses duplicate selected sources for the same logical project', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['local-orca', 'ssh-orca']) + ) + + expect([...selection]).toEqual(['local-orca']) + }) + + it('preserves a single explicit remote source selection', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect([...selection]).toEqual(['ssh-orca']) + }) + + it('normalizes raw all-host selection to one source per logical project', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'docs', + upstream: { owner: 'stablyai', repo: 'docs' } + }) + ], + new Set(['local-orca', 'ssh-orca', 'docs']) + ) + + expect([...selection].sort()).toEqual(['docs', 'local-orca']) + }) +}) diff --git a/src/renderer/src/components/task-page-default-repo-selection.ts b/src/renderer/src/components/task-page-default-repo-selection.ts new file mode 100644 index 00000000000..1c10ae637b7 --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.ts @@ -0,0 +1,101 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { getProjectIdentityKey } from '../../../shared/project-host-setup-projection' +import type { Repo } from '../../../shared/types' + +export type TaskProjectPickerGroup = { + projectKey: string + repo: Repo + sources: Repo[] +} + +export function getDefaultTaskRepoSelection(repos: readonly Repo[]): Set<string> { + const selectedByProject = new Map<string, Repo>() + for (const repo of repos) { + const projectKey = getTaskRepoProjectKey(repo) + const current = selectedByProject.get(projectKey) + if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { + selectedByProject.set(projectKey, repo) + } + } + return new Set([...selectedByProject.values()].map((repo) => repo.id)) +} + +export function getTaskProjectPickerRepos( + repos: readonly Repo[], + preferredSelection: ReadonlySet<string> = new Set() +): Repo[] { + return getTaskProjectPickerGroups(repos, preferredSelection).map((group) => group.repo) +} + +export function getTaskProjectPickerGroups( + repos: readonly Repo[], + preferredSelection: ReadonlySet<string> = new Set() +): TaskProjectPickerGroup[] { + const groupsByProject = new Map<string, TaskProjectPickerGroup>() + for (const repo of repos) { + const projectKey = getTaskRepoProjectKey(repo) + const current = groupsByProject.get(projectKey) + if (!current) { + groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) + continue + } + current.sources.push(repo) + if (compareTaskProjectPickerCandidate(repo, current.repo, preferredSelection) < 0) { + current.repo = repo + } + } + return [...groupsByProject.values()].map((group) => ({ + ...group, + sources: [...group.sources].sort(compareDefaultTaskRepoCandidate) + })) +} + +export function normalizeTaskRepoSelection( + repos: readonly Repo[], + selection: ReadonlySet<string> +): Set<string> { + const selectedByProject = new Map<string, Repo>() + const selectedIds = new Set(selection) + for (const repo of repos) { + if (!selectedIds.has(repo.id)) { + continue + } + const projectKey = getTaskRepoProjectKey(repo) + const current = selectedByProject.get(projectKey) + if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { + selectedByProject.set(projectKey, repo) + } + } + if (selectedByProject.size === 0) { + return getDefaultTaskRepoSelection(repos) + } + return new Set([...selectedByProject.values()].map((repo) => repo.id)) +} + +export function getTaskRepoProjectKey(repo: Repo): string { + return getProjectIdentityKey(repo) +} + +function compareTaskProjectPickerCandidate( + a: Repo, + b: Repo, + preferredSelection: ReadonlySet<string> +): number { + const aPreferred = preferredSelection.has(a.id) + const bPreferred = preferredSelection.has(b.id) + if (aPreferred !== bPreferred) { + return aPreferred ? -1 : 1 + } + return compareDefaultTaskRepoCandidate(a, b) +} + +function compareDefaultTaskRepoCandidate(a: Repo, b: Repo): number { + // Why: when the same logical project exists on multiple hosts, default to + // the local checkout to avoid surprising remote auth/network work on first load. + const aLocal = getRepoExecutionHostId(a) === LOCAL_EXECUTION_HOST_ID + const bLocal = getRepoExecutionHostId(b) === LOCAL_EXECUTION_HOST_ID + if (aLocal !== bLocal) { + return aLocal ? -1 : 1 + } + return (a.addedAt ?? 0) - (b.addedAt ?? 0) || a.id.localeCompare(b.id) +} diff --git a/src/renderer/src/components/task-page-empty-state.test.ts b/src/renderer/src/components/task-page-empty-state.test.ts new file mode 100644 index 00000000000..25cd9bd6223 --- /dev/null +++ b/src/renderer/src/components/task-page-empty-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { getRepoBackedTaskEmptyState } from './task-page-empty-state' + +describe('getRepoBackedTaskEmptyState', () => { + it('explains when no repo-backed task source is selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: 0 + }) + ).toEqual({ + title: 'No project sources selected', + description: + 'Select at least one project source so Orca knows which host/account to fetch tasks from.' + }) + }) + + it('keeps GitHub no-match copy when sources are selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: 2 + }) + ).toEqual({ + title: 'No matching GitHub work', + description: 'Change the query or clear it.' + }) + }) + + it('uses GitLab view-specific no-match copy when sources are selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'gitlab', + selectedRepoCount: 1, + gitlabView: 'mrs' + }) + ).toEqual({ + title: 'No GitLab merge requests', + description: 'No GitLab MRs match this filter.' + }) + }) +}) diff --git a/src/renderer/src/components/task-page-empty-state.ts b/src/renderer/src/components/task-page-empty-state.ts new file mode 100644 index 00000000000..cb874a88d4c --- /dev/null +++ b/src/renderer/src/components/task-page-empty-state.ts @@ -0,0 +1,43 @@ +export type RepoBackedTaskEmptyStateProvider = 'github' | 'gitlab' + +export type RepoBackedTaskEmptyState = { + title: string + description: string +} + +export function getRepoBackedTaskEmptyState(args: { + provider: RepoBackedTaskEmptyStateProvider + selectedRepoCount: number + gitlabView?: 'issues' | 'mrs' | 'todos' +}): RepoBackedTaskEmptyState { + if (args.selectedRepoCount === 0) { + return { + title: 'No project sources selected', + description: + 'Select at least one project source so Orca knows which host/account to fetch tasks from.' + } + } + if (args.provider === 'github') { + return { + title: 'No matching GitHub work', + description: 'Change the query or clear it.' + } + } + switch (args.gitlabView) { + case 'issues': + return { + title: 'No GitLab issues', + description: 'No GitLab issues match this filter.' + } + case 'mrs': + return { + title: 'No GitLab merge requests', + description: 'No GitLab MRs match this filter.' + } + default: + return { + title: 'No GitLab work', + description: 'No GitLab work matches this filter.' + } + } +} diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.test.ts b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts new file mode 100644 index 00000000000..f5f60d3cbe2 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' +import type { JiraIssue } from '../../../shared/types' +import { findTaskPageJiraIssue } from './task-page-jira-cache-selectors' + +function jiraSourceContext(environmentId: string): TaskSourceContext { + return { + kind: 'task-source', + provider: 'jira', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'jira', + siteId: 'site-1' + } + } +} + +function jiraIssue(key: string, title: string, siteId = 'site-1'): JiraIssue { + return { + id: `${siteId}:${key}`, + key, + title, + url: `https://example.atlassian.net/browse/${key}`, + siteId, + siteName: 'Example Jira', + project: { id: '10000', key: 'ALP', name: 'Alpha', siteId }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z' + } +} + +describe('findTaskPageJiraIssue', () => { + it('keeps same-key Jira issues separated by source context', () => { + const localSource = jiraSourceContext('local-runtime') + const remoteSource = jiraSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + const found = findTaskPageJiraIssue( + { + [`${localScope}::site-1::ALP-1`]: { + data: jiraIssue('ALP-1', 'Local issue'), + fetchedAt: Date.now() + } + }, + { + [`${remoteScope}::site-1::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Remote issue')], + fetchedAt: Date.now() + } + }, + 'ALP-1', + { + sourceContext: remoteSource, + siteId: 'site-1' + } + ) + + expect(found?.title).toBe('Remote issue') + }) + + it('filters same-key Jira issues by site id', () => { + const source = jiraSourceContext('remote-runtime') + const scope = getTaskSourceCacheScope(source) + + const found = findTaskPageJiraIssue( + {}, + { + [`${scope}::site-1::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Site one issue', 'site-1')], + fetchedAt: Date.now() + }, + [`${scope}::site-2::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Site two issue', 'site-2')], + fetchedAt: Date.now() + } + }, + 'ALP-1', + { + sourceContext: source, + siteId: 'site-2' + } + ) + + expect(found?.title).toBe('Site two issue') + }) +}) diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 36fb5125901..5426c404dbf 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -1,26 +1,51 @@ import type { CacheEntry } from '@/store/slices/github' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' import type { JiraIssue } from '../../../shared/types' type JiraIssueCache = Record<string, CacheEntry<JiraIssue>> type JiraSearchCache = Record<string, CacheEntry<JiraIssue[]>> +export type TaskPageJiraIssueLookupOptions = { + sourceContext?: TaskSourceContext | null + siteId?: string | null +} + export function findTaskPageJiraIssue( jiraIssueCache: JiraIssueCache, jiraSearchCache: JiraSearchCache, - jiraIssueKey: string | null + jiraIssueKey: string | null, + options: TaskPageJiraIssueLookupOptions = {} ): JiraIssue | null { if (!jiraIssueKey) { return null } + const sourceScope = + options.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const matchesLookup = (cacheKey: string, issue: JiraIssue | null | undefined): boolean => { + if (!issue || issue.key !== jiraIssueKey) { + return false + } + if (options.siteId && issue.siteId !== options.siteId) { + return false + } + // Why: Jira issue keys are only unique within a site/source, so drawer lookup + // must not borrow a same-key issue cached for another host/account. + return sourceScope === null || cacheKey.startsWith(`${sourceScope}::`) + } - for (const entry of Object.values(jiraIssueCache)) { - if (entry?.data?.key === jiraIssueKey) { + for (const [cacheKey, entry] of Object.entries(jiraIssueCache)) { + if (matchesLookup(cacheKey, entry?.data)) { return entry.data } } - for (const entry of Object.values(jiraSearchCache)) { - const found = entry?.data?.find((issue) => issue.key === jiraIssueKey) + for (const [cacheKey, entry] of Object.entries(jiraSearchCache)) { + const found = entry?.data?.find((issue) => matchesLookup(cacheKey, issue)) if (found) { return found } diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts new file mode 100644 index 00000000000..8207399c98b --- /dev/null +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('TaskPage source switching host boundary', () => { + it('switches task source without mutating the focused run host', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + '{visibleSourceOptions.map((source) => {', + "{taskSource === 'linear' && linearConnected ?" + ) + + expect(section).toContain('openTaskPage(') + expect(section).toContain('taskSource: source.id') + expect(section).toContain('defaultTaskSource: source.id') + expect(section).not.toContain('activeRuntimeEnvironmentId') + expect(section).not.toContain('projectHostSetupId') + expect(section).not.toContain('workspaceRunContext') + }) + + it('treats missing remote task-source capability as source unavailable', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'function getTaskSourceHostAvailabilityForHost', + 'function getTaskPageRepoCacheInput' + ) + + expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') + expect(section).toContain("reason: 'checking-task-source-capability'") + expect(section).toContain("reason: 'missing-task-source-capability'") + }) + + it('checks runtime-owned provider auth on the owning runtime', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'const runtimeTaskSourceHostIds = useMemo(() => {', + 'const getTaskPickerRepoHostLabel = useCallback(' + ) + + expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') + expect(section).toContain("'preflight.check'") + expect(section).toContain("{ kind: 'environment', environmentId: parsed.environmentId }") + expect(TASK_PAGE_SOURCE).toContain('runtimePreflightStatusByHostId') + }) + + it('preserves exact GitLab project identity when opening or starting from an item', () => { + const sourceContextBuilder = sourceBetween( + TASK_PAGE_SOURCE, + 'function getTaskPageRepoSourceContext', + 'function getTaskSourceHostAvailabilityForHost' + ) + expect(sourceContextBuilder).toContain('gitlabProjectRef?: GitLabProjectRef | null') + expect(sourceContextBuilder).toContain('buildGitLabProviderIdentity(gitlabProjectRef)') + + const openGitLabDetail = sourceBetween( + TASK_PAGE_SOURCE, + 'const openGitLabDetailPage = useCallback(', + 'const patchTaskPageWorkItemRows = useCallback(' + ) + expect(openGitLabDetail).toContain('item.projectRef') + + const startGitLabWorkspace = sourceBetween( + TASK_PAGE_SOURCE, + 'const openComposerForGitLabItem = useCallback(', + 'const handleUseGitLabItem = useCallback(' + ) + expect(startGitLabWorkspace).toContain('item.projectRef') + }) +}) diff --git a/src/renderer/src/components/task-project-source-combobox-model.ts b/src/renderer/src/components/task-project-source-combobox-model.ts new file mode 100644 index 00000000000..248adfcfa11 --- /dev/null +++ b/src/renderer/src/components/task-project-source-combobox-model.ts @@ -0,0 +1,41 @@ +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/types' +import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' + +export function selectedTaskProjectGroups( + groups: readonly TaskProjectPickerGroup[], + selected: ReadonlySet<string> +): TaskProjectPickerGroup[] { + return groups.filter((group) => group.sources.some((source) => selected.has(source.id))) +} + +export function isTaskProjectGroupSelected( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string> +): boolean { + return group.sources.some((source) => selected.has(source.id)) +} + +export function getSelectedTaskProjectSource( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string> +): Repo { + return group.sources.find((source) => selected.has(source.id)) ?? group.repo +} + +export function hasMultipleTaskProjectHosts(groups: readonly TaskProjectPickerGroup[]): boolean { + const hostIds = new Set<string>() + for (const group of groups) { + for (const source of group.sources) { + hostIds.add(getRepoExecutionHostId(source)) + if (hostIds.size > 1) { + return true + } + } + } + return false +} + +export function hasMultipleTaskProjectHostsInGroup(group: TaskProjectPickerGroup): boolean { + return hasMultipleTaskProjectHosts([group]) +} diff --git a/src/renderer/src/components/task-project-source-combobox.tsx b/src/renderer/src/components/task-project-source-combobox.tsx new file mode 100644 index 00000000000..a2c9d6fa9dc --- /dev/null +++ b/src/renderer/src/components/task-project-source-combobox.tsx @@ -0,0 +1,406 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronRight, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../shared/types' +import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' +import { + getSelectedTaskProjectSource, + hasMultipleTaskProjectHosts, + hasMultipleTaskProjectHostsInGroup, + isTaskProjectGroupSelected, + selectedTaskProjectGroups +} from './task-project-source-combobox-model' + +type TaskProjectSourceStatus = { + label: string + title?: string + disabled?: boolean +} + +type TaskProjectSourceComboboxProps = { + groups: TaskProjectPickerGroup[] + selected: ReadonlySet<string> + onChange: (next: ReadonlySet<string>) => void + onSelectAll: () => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined + getRepoSourceStatus?: (repo: Repo) => TaskProjectSourceStatus | null | undefined + triggerClassName?: string +} + +function renderTriggerLabel( + groups: readonly TaskProjectPickerGroup[], + selected: ReadonlySet<string> +): React.JSX.Element { + if (groups.length === 0) { + return ( + <span className="text-muted-foreground"> + {translate('auto.components.task.project.source.combobox.noProjects', 'No projects')} + </span> + ) + } + const selectedProjectGroups = selectedTaskProjectGroups(groups, selected) + if (selectedProjectGroups.length === groups.length) { + return ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + {translate('auto.components.task.project.source.combobox.allProjects', 'All projects')} + </span> + ) + } + const [first, second, ...rest] = selectedProjectGroups + return ( + <span className="inline-flex min-w-0 items-center gap-1.5 truncate"> + {first ? ( + <RepoBadgeLabel + name={first.repo.displayName} + color={first.repo.badgeColor} + badgeClassName="size-1.5" + /> + ) : null} + {second ? <span className="text-muted-foreground">, {second.repo.displayName}</span> : null} + {rest.length > 0 ? <span className="text-muted-foreground">+{rest.length}</span> : null} + </span> + ) +} + +function getProjectDetail( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string>, + showHostLabels: boolean, + getRepoHostLabel?: (repo: Repo) => string | null | undefined +): string { + const selectedSource = getSelectedTaskProjectSource(group, selected) + const hostLabel = showHostLabels ? getRepoHostLabel?.(selectedSource)?.trim() : '' + if (hasMultipleTaskProjectHostsInGroup(group)) { + const hostCount = translate( + 'auto.components.task.project.source.combobox.hostCount', + '{{value0}} hosts', + { + value0: String(group.sources.length) + } + ) + return hostLabel ? `${hostLabel} · ${hostCount}` : hostCount + } + return hostLabel ? `${hostLabel} · ${selectedSource.path}` : selectedSource.path +} + +function getSourceDetail(repo: Repo, status?: TaskProjectSourceStatus | null): string { + return status?.label ? `${repo.path} · ${status.label}` : repo.path +} + +export default function TaskProjectSourceCombobox({ + groups, + selected, + onChange, + onSelectAll, + getRepoHostLabel, + getRepoSourceStatus, + triggerClassName +}: TaskProjectSourceComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [sourceMenuProjectKey, setSourceMenuProjectKey] = useState<string | null>(null) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const sourceMenuCloseTimerRef = useRef<number | null>(null) + const sourceMenuHoverRef = useRef<{ + projectKey: string | null + row: boolean + content: boolean + }>({ projectKey: null, row: false, content: false }) + + const filteredGroups = useMemo(() => { + const trimmed = query.trim() + if (!trimmed) { + return groups + } + return groups.filter((group) => searchRepos(group.sources, trimmed).length > 0) + }, [groups, query]) + const showHostLabels = useMemo(() => hasMultipleTaskProjectHosts(groups), [groups]) + const allSelected = + groups.length > 0 && selectedTaskProjectGroups(groups, selected).length === groups.length + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setQuery('') + setSourceMenuProjectKey(null) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + } + }, []) + + const clearSourceMenuCloseTimer = useCallback(() => { + if (sourceMenuCloseTimerRef.current !== null) { + window.clearTimeout(sourceMenuCloseTimerRef.current) + sourceMenuCloseTimerRef.current = null + } + }, []) + + const setSourceMenuHover = useCallback( + (projectKey: string, region: 'row' | 'content', hovered: boolean) => { + clearSourceMenuCloseTimer() + if (sourceMenuHoverRef.current.projectKey !== projectKey) { + sourceMenuHoverRef.current = { projectKey, row: false, content: false } + } + sourceMenuHoverRef.current[region] = hovered + if (hovered) { + setSourceMenuProjectKey(projectKey) + return + } + sourceMenuCloseTimerRef.current = window.setTimeout(() => { + const hover = sourceMenuHoverRef.current + if (hover.projectKey === projectKey && !hover.row && !hover.content) { + setSourceMenuProjectKey((current) => (current === projectKey ? null : current)) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + } + sourceMenuCloseTimerRef.current = null + }, 100) + }, + [clearSourceMenuCloseTimer] + ) + + useEffect(() => clearSourceMenuCloseTimer, [clearSourceMenuCloseTimer]) + + const toggleProject = useCallback( + (group: TaskProjectPickerGroup) => { + const next = new Set(selected) + const selectedSource = group.sources.find((source) => next.has(source.id)) + if (selectedSource) { + if (selectedTaskProjectGroups(groups, selected).length <= 1) { + return + } + for (const source of group.sources) { + next.delete(source.id) + } + } else { + next.add(group.repo.id) + } + onChange(next) + }, + [groups, onChange, selected] + ) + + const selectProjectSource = useCallback( + (group: TaskProjectPickerGroup, source: Repo) => { + const status = getRepoSourceStatus?.(source) + if (status?.disabled) { + return + } + const next = new Set(selected) + for (const candidate of group.sources) { + next.delete(candidate.id) + } + next.add(source.id) + onChange(next) + setSourceMenuProjectKey(null) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + }, + [getRepoSourceStatus, onChange, selected] + ) + + const handleSelectAll = useCallback(() => { + if (allSelected) { + const first = groups[0] + if (!first) { + return + } + onChange(new Set([first.repo.id])) + return + } + onSelectAll() + }, [allSelected, groups, onChange, onSelectAll]) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)} + > + {renderTriggerLabel(groups, selected)} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(360px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + autoFocus + placeholder={translate( + 'auto.components.task.project.source.combobox.searchProjects', + 'Search projects...' + )} + value={query} + onValueChange={setQuery} + className="text-xs" + /> + <div className="border-b border-border"> + <button + type="button" + onClick={handleSelectAll} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => setCommandValue('')} + className={cn( + 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', + allSelected && 'opacity-80' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + allSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate( + 'auto.components.task.project.source.combobox.allProjects', + 'All projects' + )} + </span> + </button> + </div> + <CommandList> + {filteredGroups.length === 0 ? ( + <div className="px-3 py-6 text-center text-xs text-muted-foreground"> + {translate( + 'auto.components.task.project.source.combobox.noMatches', + 'No projects match your search.' + )} + </div> + ) : null} + {filteredGroups.map((group) => { + const selectedProject = isTaskProjectGroupSelected(group, selected) + const selectedSource = getSelectedTaskProjectSource(group, selected) + const detail = getProjectDetail(group, selected, showHostLabels, getRepoHostLabel) + const hasSourceMenu = hasMultipleTaskProjectHostsInGroup(group) + return ( + <div + key={group.projectKey} + onMouseEnter={() => { + setCommandValue(group.repo.id) + if (hasSourceMenu) { + setSourceMenuHover(group.projectKey, 'row', true) + } + }} + onMouseLeave={() => { + if (hasSourceMenu) { + setSourceMenuHover(group.projectKey, 'row', false) + } + }} + className={cn( + 'group/source-row flex items-stretch transition-colors hover:bg-accent hover:text-accent-foreground', + commandValue === group.repo.id && 'bg-accent text-accent-foreground' + )} + > + <button + type="button" + onClick={() => toggleProject(group)} + onMouseDown={(event) => event.preventDefault()} + className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + selectedProject ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <span className="inline-flex items-center gap-1.5 text-xs"> + <RepoBadgeLabel + name={group.repo.displayName} + color={group.repo.badgeColor} + className="max-w-full" + /> + </span> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> + </div> + </button> + {hasSourceMenu ? ( + <Popover + open={sourceMenuProjectKey === group.projectKey} + onOpenChange={(nextOpen) => + setSourceMenuProjectKey(nextOpen ? group.projectKey : null) + } + > + <PopoverTrigger asChild> + <button + type="button" + title={translate( + 'auto.components.task.project.source.combobox.chooseSource', + 'Choose task source' + )} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => event.preventDefault()} + className="flex w-8 shrink-0 items-center justify-center text-muted-foreground" + > + <ChevronRight className="size-3.5" /> + </button> + </PopoverTrigger> + <PopoverContent + side="right" + align="start" + sideOffset={6} + className="w-[min(280px,calc(100vw-1rem))] p-1" + onMouseEnter={() => setSourceMenuHover(group.projectKey, 'content', true)} + onMouseLeave={() => setSourceMenuHover(group.projectKey, 'content', false)} + > + <div className="py-1"> + {group.sources.map((source) => { + const status = getRepoSourceStatus?.(source) + const sourceSelected = source.id === selectedSource.id + const sourceDetail = getSourceDetail(source, status) + return ( + <button + key={source.id} + type="button" + disabled={status?.disabled} + title={status?.title} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectProjectSource(group, source)} + className={cn( + 'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground', + status?.disabled && 'cursor-not-allowed opacity-50' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + sourceSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-xs"> + {getRepoHostLabel?.(source) ?? source.displayName} + </div> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground"> + {sourceDetail} + </p> + </div> + </button> + ) + })} + </div> + </PopoverContent> + </Popover> + ) : null} + </div> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/task-source-context-summary.test.ts b/src/renderer/src/components/task-source-context-summary.test.ts new file mode 100644 index 00000000000..e403ab07aa1 --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' +import { + getTaskSourceAvailabilityNotice, + getTaskSourceContextSummary +} from './task-source-context-summary' + +describe('task source context summary', () => { + it('shows provider, host, and provider identity for a single repo-backed source', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ] + }) + + expect(summary.label).toBe('GitHub · devbox · stablyai/orca') + expect(summary.title).toBe('GitHub · Host: devbox · Source: stablyai/orca') + }) + + it('shows repo-backed provider account labels when accounts can differ by host', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 2, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'setup-local', + repoId: 'repo-local', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'personal-gh' + }, + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'repo-builder', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'work-gh' + } + ] + }) + + expect(summary.label).toBe('GitHub · Local Mac, builder · personal-gh, work-gh') + expect(summary.title).toBe( + 'GitHub · Host: Local Mac, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects' + ) + }) + + it('shows disconnected source-host availability for a single SSH repo source', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [{ hostId: 'ssh:devbox', status: 'disconnected' }] + }) + + expect(summary.label).toBe('GitHub · devbox · disconnected · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: devbox · Availability: devbox disconnected · Source: stablyai/orca' + ) + }) + + it('summarizes multiple unavailable source hosts without cluttering the label', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 2, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'project-a', + hostId: 'ssh:devbox', + repoId: 'repo-a' + }, + { + kind: 'task-source', + provider: 'github', + projectId: 'project-b', + hostId: 'ssh:buildbox', + repoId: 'repo-b' + } + ], + hostAvailability: [ + { hostId: 'ssh:devbox', status: 'auth-failed' }, + { hostId: 'ssh:buildbox', status: 'reconnecting' } + ] + }) + + expect(summary.label).toBe('GitHub · devbox, buildbox · 2 unavailable · 2 projects') + expect(summary.title).toBe( + 'GitHub · Host: devbox, buildbox · Availability: devbox auth needed, buildbox connecting · 2 selected projects' + ) + }) + + it('summarizes multiple repo-backed hosts without hiding the selected count', () => { + const summary = getTaskSourceContextSummary({ + provider: 'gitlab', + providerLabel: 'GitLab', + selectedRepoCount: 3, + repoContexts: [ + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-a', + hostId: 'local', + repoId: 'repo-a' + }, + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-b', + hostId: 'ssh:build', + repoId: 'repo-b' + }, + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-c', + hostId: 'runtime:linux', + repoId: 'repo-c' + } + ] + }) + + expect(summary.label).toBe('GitLab · Local Mac +2 · 3 projects') + expect(summary.title).toBe('GitLab · Host: Local Mac, build, linux · 3 selected projects') + }) + + it('shows blocked remote-server source-host availability', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'project-a', + hostId: 'runtime:old-server', + repoId: 'repo-a', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [{ hostId: 'runtime:old-server', health: 'blocked' }] + }) + + expect(summary.label).toBe('GitHub · old-server · server update needed · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: old-server · Availability: old-server server update needed · Source: stablyai/orca' + ) + }) + + it('shows remote-server task-source capability checks', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'project-a', + hostId: 'runtime:old-server', + repoId: 'repo-a', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [ + { hostId: 'runtime:old-server', reason: 'checking-task-source-capability' } + ] + }) + + expect(summary.label).toBe('GitHub · old-server · checking server capabilities · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: old-server · Availability: old-server checking server capabilities · Source: stablyai/orca' + ) + }) + + it('uses saved remote server labels in repo-backed source summaries and notices', () => { + const hostLabelById = new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']]) + + expect( + getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + hostLabelById, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + repoId: 'repo-runtime', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [ + { + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + health: 'blocked' + } + ] + }) + ).toEqual({ + label: 'GitHub · dev box · server update needed · stablyai/orca', + title: + 'GitHub · Host: dev box · Availability: dev box server update needed · Source: stablyai/orca' + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 1, + hostLabelById, + hostAvailability: [ + { + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + reason: 'missing-task-source-capability' + } + ] + })?.label + ).toBe('GitHub source unavailable: dev box server update needed for task sources') + }) + + it('shows remote-server task-source capability version skew', () => { + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 1, + hostAvailability: [ + { hostId: 'runtime:old-server', reason: 'missing-task-source-capability' } + ] + }) + ).toEqual({ + label: 'GitHub source unavailable: old-server server update needed for task sources', + title: + 'Reconnect or update old-server server update needed for task sources to load this source.', + blocking: true + }) + }) + + it('shows account-backed Linear and Jira sources', () => { + expect( + getTaskSourceContextSummary({ + provider: 'linear', + providerLabel: 'Linear', + accountHostId: 'local', + linearWorkspaceName: 'Stably' + }).label + ).toBe('Linear · Local Mac · Stably') + + expect( + getTaskSourceContextSummary({ + provider: 'jira', + providerLabel: 'Jira', + accountHostId: 'runtime:server', + jiraSiteName: 'Stably Jira' + }).label + ).toBe('Jira · server · Stably Jira') + }) + + it('shows account-backed source host availability', () => { + const summary = getTaskSourceContextSummary({ + provider: 'linear', + providerLabel: 'Linear', + accountHostId: 'runtime:old-server', + linearWorkspaceName: 'Stably', + hostAvailability: [{ hostId: 'runtime:old-server', health: 'blocked' }] + }) + + expect(summary.label).toBe('Linear · old-server · server update needed · Stably') + expect(summary.title).toBe( + 'Linear source · Host: old-server · Availability: old-server server update needed · Account: Stably' + ) + }) + + it('builds a visible unavailable-source notice from host availability', () => { + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + hostAvailability: [{ hostId: 'ssh:devbox', status: 'auth-failed' }] + }) + ).toEqual({ + label: 'GitHub source unavailable: devbox auth needed', + title: 'Reconnect or update devbox auth needed to load this source.', + blocking: true + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitLab', + sourceCount: 3, + hostAvailability: [ + { hostId: 'ssh:devbox', status: 'disconnected' }, + { hostId: 'runtime:old-server', health: 'blocked' } + ] + })?.label + ).toBe('Some GitLab source hosts unavailable: 2 source hosts') + }) + + it('shows provider-specific source availability reasons', () => { + expect( + getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [{ hostId: 'ssh:devbox', reason: 'missing-provider-auth' }] + }) + ).toEqual({ + label: 'GitHub · devbox · provider auth needed · stablyai/orca', + title: + 'GitHub · Host: devbox · Availability: devbox provider auth needed · Source: stablyai/orca' + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 3, + hostAvailability: [ + { hostId: 'ssh:devbox', reason: 'unavailable-source-tool' }, + { hostId: 'runtime:linux', reason: 'unsupported-provider' } + ] + }) + ).toEqual({ + label: 'Some GitHub source hosts unavailable: 2 source hosts', + title: + 'Reconnect or update devbox source tool unavailable, linux provider unsupported on this host to load this source.', + blocking: false + }) + }) +}) diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts new file mode 100644 index 00000000000..a277298aecd --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -0,0 +1,301 @@ +import { getExecutionHostLabel } from '../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../shared/execution-host-registry' +import type { SshConnectionStatus } from '../../../shared/ssh-types' +import type { TaskProvider } from '../../../shared/types' +import type { TaskProviderIdentity, TaskSourceContext } from '../../../shared/task-source-context' + +export type TaskSourceContextSummary = { + label: string + title: string +} + +export type TaskSourceAvailabilityNotice = { + label: string + title: string + blocking: boolean +} + +export type TaskSourceHostAvailability = { + hostId: ExecutionHostScope + status?: SshConnectionStatus + health?: ExecutionHostHealth + reason?: + | 'checking-task-source-capability' + | 'missing-task-source-capability' + | 'missing-provider-auth' + | 'unavailable-source-tool' + | 'unsupported-provider' +} + +type HostLabelLookup = ReadonlyMap<string, string> | undefined + +function getHostLabel(hostId: ExecutionHostScope, hostLabelById: HostLabelLookup): string { + return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) +} + +export function getTaskSourceContextSummary(args: { + provider: TaskProvider + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + accountHostId?: ExecutionHostScope | null + selectedRepoCount?: number + linearWorkspaceName?: string | null + jiraSiteName?: string | null +}): TaskSourceContextSummary { + switch (args.provider) { + case 'github': + case 'gitlab': + return getRepoBackedTaskSourceSummary(args) + case 'linear': + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: args.linearWorkspaceName, + accountHostId: args.accountHostId, + hostLabelById: args.hostLabelById, + hostAvailability: args.hostAvailability + }) + case 'jira': + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: args.jiraSiteName, + accountHostId: args.accountHostId, + hostLabelById: args.hostLabelById, + hostAvailability: args.hostAvailability + }) + } +} + +export function getTaskSourceAvailabilityNotice(args: { + providerLabel: string + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + sourceCount?: number +}): TaskSourceAvailabilityNotice | null { + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + if (unavailableHosts.length === 0) { + return null + } + const sourceCount = Math.max(args.sourceCount ?? unavailableHosts.length, unavailableHosts.length) + const blocking = unavailableHosts.length >= sourceCount + const hostStatusLabels = unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + const target = + unavailableHosts.length === 1 ? hostStatusLabels[0] : `${unavailableHosts.length} source hosts` + return { + label: blocking + ? `${args.providerLabel} source unavailable: ${target}` + : `Some ${args.providerLabel} source hosts unavailable: ${target}`, + title: `Reconnect or update ${formatLongList(hostStatusLabels)} to load this source.`, + blocking + } +} + +function getRepoBackedTaskSourceSummary(args: { + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + selectedRepoCount?: number +}): TaskSourceContextSummary { + const contexts = args.repoContexts ?? [] + const hostLabels = uniqueLabels( + contexts.map((context) => getHostLabel(context.hostId, args.hostLabelById)) + ) + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + const availabilityLabel = getAvailabilityLabel(unavailableHosts) + const identityLabels = uniqueLabels( + contexts.map((context) => getProviderIdentityLabel(context.providerIdentity)) + ) + const accountLabels = uniqueLabels(contexts.map((context) => context.accountLabel)) + const repoCount = args.selectedRepoCount ?? contexts.length + const hostLabel = hostLabels.length === 0 ? 'No host' : formatShortList(hostLabels) + const accountLabel = accountLabels.length > 0 ? `Account: ${formatLongList(accountLabels)}` : null + const targetLabel = + accountLabels.length > 1 + ? formatShortList(accountLabels) + : repoCount > 1 + ? `${repoCount} projects` + : (identityLabels[0] ?? contexts[0]?.accountLabel ?? 'Selected project') + const titleParts = [ + args.providerLabel, + hostLabels.length > 0 ? `Host: ${formatLongList(hostLabels)}` : null, + unavailableHosts.length > 0 + ? `Availability: ${formatLongList( + unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + )}` + : null, + accountLabel, + identityLabels.length > 0 ? `Source: ${formatLongList(identityLabels)}` : null, + repoCount > 1 ? `${repoCount} selected projects` : null + ].filter((part): part is string => Boolean(part)) + + return { + label: [args.providerLabel, hostLabel, availabilityLabel, targetLabel] + .filter((part): part is string => Boolean(part)) + .join(' · '), + title: titleParts.join(' · ') + } +} + +function getAccountBackedTaskSourceSummary( + providerLabel: string, + args: { + accountLabel: string | null | undefined + accountHostId: ExecutionHostScope | null | undefined + hostLabelById?: HostLabelLookup + hostAvailability?: readonly TaskSourceHostAvailability[] + } +): TaskSourceContextSummary { + const target = args.accountLabel?.trim() || 'Current account' + const hostLabel = getHostLabel(args.accountHostId ?? 'local', args.hostLabelById) + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + const availabilityLabel = getAvailabilityLabel(unavailableHosts) + const titleParts = [ + `${providerLabel} source`, + `Host: ${hostLabel}`, + availabilityLabel + ? `Availability: ${formatLongList( + unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + )}` + : null, + `Account: ${target}` + ].filter((part): part is string => Boolean(part)) + return { + label: [providerLabel, hostLabel, availabilityLabel, target] + .filter((part): part is string => Boolean(part)) + .join(' · '), + title: titleParts.join(' · ') + } +} + +function getProviderIdentityLabel( + identity: TaskProviderIdentity | null | undefined +): string | null { + if (!identity) { + return null + } + switch (identity.provider) { + case 'github': + return `${identity.owner}/${identity.repo}` + case 'gitlab': + return identity.namespace && identity.project + ? `${identity.namespace}/${identity.project}` + : (identity.projectId ?? null) + case 'linear': + return identity.workspaceName ?? identity.workspaceId ?? null + case 'jira': + return identity.siteUrl ?? identity.siteId ?? null + } +} + +function uniqueLabels(labels: readonly (string | null | undefined)[]): string[] { + const seen = new Set<string>() + const result: string[] = [] + for (const label of labels) { + const trimmed = label?.trim() + if (!trimmed || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + return result +} + +function getUnavailableHosts( + hostAvailability: readonly TaskSourceHostAvailability[], + hostLabelById?: HostLabelLookup +): { + hostLabel: string + statusLabel: string +}[] { + const seen = new Set<string>() + const unavailableHosts: { hostLabel: string; statusLabel: string }[] = [] + for (const availability of hostAvailability) { + const statusLabel = getAvailabilityStatusLabel(availability) + if (!statusLabel) { + continue + } + const hostLabel = getHostLabel(availability.hostId, hostLabelById) + const key = `${hostLabel}\u0000${statusLabel}` + if (seen.has(key)) { + continue + } + seen.add(key) + unavailableHosts.push({ hostLabel, statusLabel }) + } + return unavailableHosts +} + +function getAvailabilityStatusLabel(availability: TaskSourceHostAvailability): string | null { + switch (availability.reason) { + case 'checking-task-source-capability': + return 'checking server capabilities' + case 'missing-task-source-capability': + return 'server update needed for task sources' + case 'missing-provider-auth': + return 'provider auth needed' + case 'unavailable-source-tool': + return 'source tool unavailable' + case 'unsupported-provider': + return 'provider unsupported on this host' + } + if (availability.status) { + return availability.status === 'connected' ? null : getSshStatusLabel(availability.status) + } + switch (availability.health) { + case 'local': + case 'available': + case undefined: + return null + case 'connecting': + return 'connecting' + case 'blocked': + return 'server update needed' + case 'disconnected': + return 'disconnected' + case 'error': + return 'connection issue' + } +} + +function getAvailabilityLabel( + unavailableHosts: readonly { hostLabel: string; statusLabel: string }[] +): string | null { + if (unavailableHosts.length === 0) { + return null + } + if (unavailableHosts.length === 1) { + return unavailableHosts[0].statusLabel + } + return `${unavailableHosts.length} unavailable` +} + +function getSshStatusLabel(status: SshConnectionStatus): string { + switch (status) { + case 'connected': + return 'connected' + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return 'connecting' + case 'auth-failed': + return 'auth needed' + case 'reconnection-failed': + case 'error': + return 'connection issue' + case 'disconnected': + return 'disconnected' + } +} + +function formatShortList(labels: readonly string[]): string { + if (labels.length <= 2) { + return labels.join(', ') + } + return `${labels[0]} +${labels.length - 1}` +} + +function formatLongList(labels: readonly string[]): string { + return labels.join(', ') +} diff --git a/src/renderer/src/components/task-source-provider-availability.test.ts b/src/renderer/src/components/task-source-provider-availability.test.ts new file mode 100644 index 00000000000..8fd08bbf8c2 --- /dev/null +++ b/src/renderer/src/components/task-source-provider-availability.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { PreflightStatus } from '../../../preload/api-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getRepoBackedProviderAvailability } from './task-source-provider-availability' + +const readyPreflight: PreflightStatus = { + git: { installed: true }, + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } +} + +function source(hostId: TaskSourceContext['hostId']): TaskSourceContext { + return { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId, + repoId: `repo-${hostId}` + } +} + +describe('task source provider availability', () => { + it('marks desktop-owned GitHub sources unavailable when gh auth is missing', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('local'), source('ssh:builder')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + gh: { installed: true, authenticated: false } + } + }) + ).toEqual([ + { hostId: 'local', reason: 'missing-provider-auth' }, + { hostId: 'ssh:builder', reason: 'missing-provider-auth' } + ]) + }) + + it('marks desktop-owned GitLab sources unavailable when glab is missing', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('local')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + glab: { installed: false, authenticated: false } + } + }) + ).toEqual([{ hostId: 'local', reason: 'unavailable-source-tool' }]) + }) + + it('marks GitLab unsupported when a host preflight payload predates GitLab support', () => { + const { glab: _glab, ...preGitLabPreflight } = readyPreflight + + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('local')], + preflightReady: true, + preflightStatus: preGitLabPreflight + }) + ).toEqual([{ hostId: 'local', reason: 'unsupported-provider' }]) + }) + + it('does not apply desktop preflight to runtime-owned sources', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + gh: { installed: false, authenticated: false } + } + }) + ).toEqual([]) + }) + + it('marks runtime-owned GitHub sources unavailable from their own preflight', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: true, + status: { + ...readyPreflight, + gh: { installed: true, authenticated: false } + } + } + ] + ]) + }) + ).toEqual([{ hostId: 'runtime:server', reason: 'missing-provider-auth' }]) + }) + + it('waits for runtime preflight before reporting runtime provider availability', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: false, + status: null + } + ] + ]) + }) + ).toEqual([]) + }) + + it('marks runtime-owned GitLab sources unsupported when runtime preflight lacks GitLab', () => { + const { glab: _glab, ...preGitLabPreflight } = readyPreflight + + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: true, + status: preGitLabPreflight + } + ] + ]) + }) + ).toEqual([{ hostId: 'runtime:server', reason: 'unsupported-provider' }]) + }) + + it('waits for preflight before reporting provider availability', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('local')], + preflightReady: false, + preflightStatus: { + ...readyPreflight, + gh: { installed: false, authenticated: false } + } + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/task-source-provider-availability.ts b/src/renderer/src/components/task-source-provider-availability.ts new file mode 100644 index 00000000000..e01e0fef057 --- /dev/null +++ b/src/renderer/src/components/task-source-provider-availability.ts @@ -0,0 +1,77 @@ +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { TaskProvider } from '../../../shared/types' +import type { PreflightStatus } from '../../../preload/api-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { TaskSourceHostAvailability } from './task-source-context-summary' + +type ProviderToolStatus = { + installed: boolean + authenticated: boolean +} + +type ProviderAvailabilityStatus = ProviderToolStatus | 'unsupported' + +export type RuntimeProviderPreflightStatus = { + checked: boolean + status: PreflightStatus | null +} + +function isDesktopOwnedHost(hostId: TaskSourceContext['hostId']): boolean { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' +} + +function getRepoBackedProviderToolStatus( + provider: Extract<TaskProvider, 'github' | 'gitlab'>, + preflightStatus: PreflightStatus | null +): ProviderAvailabilityStatus | null { + if (!preflightStatus) { + return null + } + if (provider === 'github') { + return preflightStatus.gh + } + // Why: older remote servers can predate GitLab preflight entirely. That is a + // host capability gap, not a user-fixable missing `glab` install. + return Object.hasOwn(preflightStatus, 'glab') + ? (preflightStatus.glab ?? { installed: false, authenticated: false }) + : 'unsupported' +} + +function getProviderReason( + status: ProviderAvailabilityStatus +): TaskSourceHostAvailability['reason'] | null { + if (status === 'unsupported') { + return 'unsupported-provider' + } + if (!status.installed) { + return 'unavailable-source-tool' + } + if (!status.authenticated) { + return 'missing-provider-auth' + } + return null +} + +export function getRepoBackedProviderAvailability(args: { + provider: Extract<TaskProvider, 'github' | 'gitlab'> + contexts: readonly TaskSourceContext[] + preflightStatus: PreflightStatus | null + preflightReady: boolean + runtimePreflightStatusByHostId?: ReadonlyMap< + TaskSourceContext['hostId'], + RuntimeProviderPreflightStatus + > +}): TaskSourceHostAvailability[] { + return args.contexts.flatMap((context) => { + const hostPreflight = isDesktopOwnedHost(context.hostId) + ? { checked: args.preflightReady, status: args.preflightStatus } + : args.runtimePreflightStatusByHostId?.get(context.hostId) + if (!hostPreflight?.checked) { + return [] + } + const status = getRepoBackedProviderToolStatus(args.provider, hostPreflight.status) + const reason = status ? getProviderReason(status) : null + return reason ? [{ hostId: context.hostId, reason }] : [] + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index bdcd2ce2c8d..e845dabb8c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -51,7 +51,12 @@ type StoreState = { workspaceStatus?: string }[] > - repos: { id: string; connectionId?: string | null; displayName?: string }[] + repos: { + id: string + connectionId?: string | null + displayName?: string + executionHostId?: string | null + }[] sshConnectionStates: Map<string, { status: string }> cacheTimerByKey: Record<string, number | null> settings: { @@ -4735,6 +4740,81 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') }) + it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport('remote:owner-runtime@@terminal-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'runtime:owner-runtime' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith( + 'owner-runtime', + expect.any(Object) + ) + expect(transport.connect).toHaveBeenCalled() + }) + + it('spawns fresh PTYs locally for explicitly local worktrees while a runtime is focused', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport('pty-local-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'local' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalled() + }) + it('attaches restored remote PTYs for later split panes instead of spawning host tabs', async () => { const { connectPanePty } = await import('./pty-connection') const existingTransport = createMockTransport('remote:env-1@@terminal-1') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 3a0429622f2..3d1331ef5b1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,6 +84,7 @@ import { cancelScheduledHiddenOutputRestore, scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { @@ -1460,8 +1461,8 @@ export function connectPanePty( (restoredPtyIdForTransport ? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport) : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null) - const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null - const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId + const runtimeEnvironmentId = + remoteRuntimeOwnerForTransport ?? getRuntimeEnvironmentIdForWorktree(state, deps.worktreeId) const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 @@ -1798,40 +1799,35 @@ export function connectPanePty( } const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] - // Why: agentStatusByPaneKey is in-memory only. After an app restart, the - // quit-captured sleeping record is the only surviving source of this - // pane's provider session id (#5232). Live entries win when present — - // they are fresher and setAgentStatus already cleared the record. - const sleepingRecord = entry ? null : state.sleepingAgentSessionsByPaneKey[cacheKey] - const agentType = entry?.agentType ?? sleepingRecord?.agent - const agentState = entry?.state ?? sleepingRecord?.state - const rawProviderSession = entry?.providerSession ?? sleepingRecord?.providerSession - if (!agentType || agentState === 'done' || !isResumableTuiAgent(agentType)) { + const sleepingRecord = state.sleepingAgentSessionsByPaneKey[cacheKey] + const useLiveEntry = entry && entry.state !== 'done' + const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + if (!agent || !isResumableTuiAgent(agent)) { return false } - const providerSession = normalizeAgentProviderSession(rawProviderSession) + const providerSession = normalizeAgentProviderSession( + useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + ) if (!providerSession) { return false } const startupPlan = buildAgentResumeStartupPlan({ - agent: agentType, + agent, providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agentType, state.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agentType, state.settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), platform: getColdRestoreAgentResumePlatform() }) if (!startupPlan) { return false } - if (sleepingRecord) { - // Why: the record is one-shot — consuming it here prevents a later - // worktree activation from launching a duplicate resume tab. - useAppStore.getState().clearSleepingAgentSession(cacheKey) - } // Why: cold restore means the PTY process is gone but the agent provider // session is still resumable, so the replacement shell must launch it. pendingStartupCommand = startupPlan.launchCommand + if (!useLiveEntry && sleepingRecord) { + state.clearSleepingAgentSession(cacheKey) + } return true } const schedulePendingStartupCommandDelivery = (): void => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index 2a4a29743c5..561fa2fa503 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -7,8 +7,15 @@ const mocks = vi.hoisted(() => ({ importExternalPathsToRuntime: vi.fn(), storeState: { settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + repos: [ + { + id: 'repo1', + connectionId: null as string | null, + executionHostId: 'runtime:env-1' as string | null + } + ], worktreesByRepo: { - repo1: [{ id: 'wt-1', path: '/remote/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] } } })) @@ -38,8 +45,9 @@ describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'runtime:env-1' }] mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', path: '/remote/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] } }) @@ -88,7 +96,7 @@ describe('handleTerminalFileDrop', () => { it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', path: '//server/share/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '//server/share/repo' }] } mocks.importExternalPathsToRuntime.mockResolvedValue({ results: [ @@ -128,6 +136,74 @@ describe('handleTerminalFileDrop', () => { ) expect(sendInput).toHaveBeenCalledWith('\\\\server\\share\\repo\\.orca\\drops\\logo.png ') }) + + it('uploads to the worktree owner runtime instead of the focused runtime', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/spec.pdf', + status: 'imported', + destPath: '/remote/repo/.orca/drops/spec.pdf', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/spec.pdf'], + '/remote/repo/.orca/drops' + ) + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') + }) + + it('keeps explicit local worktree drops local while a runtime is focused', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() + expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(focus).toHaveBeenCalled() + }) }) describe('resolveTerminalDropTargetShell', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index 966d4e0e9c1..66315f4849e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' import { isWindowsUserAgent, shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' @@ -65,8 +66,9 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { if (!transport) { return } - const settings = useAppStore.getState().settings - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() + const state = useAppStore.getState() + const settings = state.settings + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) const worktreePath = resolveWorktreePath(worktreeId, cwd) if (!worktreePath) { toast.error( @@ -78,7 +80,7 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { return } - if (activeRuntimeEnvironmentId) { + if (runtimeEnvironmentId) { const targetShell = getTerminalTargetShellForWorktreePath(worktreePath) const destinationDir = joinRuntimeDropDir(worktreePath) const pending = toast.loading( @@ -91,7 +93,9 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { try { const { results } = await importExternalPathsToRuntime( { - settings, + // Why: drops into existing worktrees must follow the worktree owner, + // not the currently focused host in the sidebar. + settings: { ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }, worktreeId, worktreePath }, diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 6ae54286006..4740bd7f1b6 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -90,12 +90,36 @@ describe('createNewTerminalTab', () => { expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', + environmentId: 'web-runtime', command: 'pwsh', activate: true }) expect(createTab).not.toHaveBeenCalled() expect(setActiveTabType).not.toHaveBeenCalled() }) + + it('delegates terminal creation to the explicit owner runtime when another runtime is focused', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + const setActiveTabType = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', executionHostId: 'runtime:owner-runtime', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + createTab, + setActiveTabType + }) + + createNewTerminalTab('wt-1', 'pwsh') + + expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'owner-runtime', + command: 'pwsh', + activate: true + }) + expect(createTab).not.toHaveBeenCalled() + }) }) describe('closeTerminalTab', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index fc2f780c54d..0956b20b691 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -10,6 +10,7 @@ import { isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review']) @@ -100,13 +101,14 @@ export function createNewTerminalTab( return } const state = useAppStore.getState() - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: paired web clients receive host-owned terminal tabs through // session.tabs. Creating a local tab first races the host snapshot and can // leave stale remote handles in the web store. void createWebRuntimeSessionTerminal({ worktreeId: activeWorktreeId, + environmentId: runtimeEnvironmentId, command: shellOverride, activate: true }) @@ -146,7 +148,7 @@ export function closeTerminalTab(tabId: string): void { return } - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, owningWorktreeId) if (runtimeEnvironmentId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { const hostBackedTabId = resolveHostSessionTabIdForWebSessionTab(state, { @@ -212,7 +214,7 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | const state = useAppStore.getState() const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? [] state.setActiveTab(tabId) - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) for (const tab of currentTabs) { if (tab.id !== tabId) { @@ -242,7 +244,7 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string const state = useAppStore.getState() const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) const terminalIds = currentTerminalTabs.map((t) => t.id) const terminalIdSet = new Set(terminalIds) @@ -290,7 +292,7 @@ export function activateTerminalTab(tabId: string): void { Object.entries(s.tabsByWorktree).find(([, worktreeTabs]) => worktreeTabs.some((tab) => tab.id === tabId) )?.[0] ?? null - const runtimeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(s, owningWorktreeId) if (owningWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: activation needs to update the host's active tab as well as the // local optimistic state, otherwise the next host snapshot snaps back. diff --git a/src/renderer/src/components/ui/repo-multi-combobox.test.ts b/src/renderer/src/components/ui/repo-multi-combobox.test.ts new file mode 100644 index 00000000000..a0a4a747e31 --- /dev/null +++ b/src/renderer/src/components/ui/repo-multi-combobox.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getRepoMultiComboboxDetail } from './repo-multi-combobox' + +function repo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-1', + path: '/Users/jinwoo/orca', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1, + ...overrides + } +} + +describe('getRepoMultiComboboxDetail', () => { + it('shows host context before the path when available', () => { + expect(getRepoMultiComboboxDetail(repo(), 'Local Mac')).toBe('Local Mac · /Users/jinwoo/orca') + expect(getRepoMultiComboboxDetail(repo({ path: '/home/orca/orca' }), 'openclaw 2')).toBe( + 'openclaw 2 · /home/orca/orca' + ) + }) + + it('keeps the existing path-only detail when no host label is provided', () => { + expect(getRepoMultiComboboxDetail(repo(), null)).toBe('/Users/jinwoo/orca') + expect(getRepoMultiComboboxDetail(repo(), ' ')).toBe('/Users/jinwoo/orca') + }) +}) diff --git a/src/renderer/src/components/ui/repo-multi-combobox.tsx b/src/renderer/src/components/ui/repo-multi-combobox.tsx index 0b89a5bda10..6334f74179e 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.tsx +++ b/src/renderer/src/components/ui/repo-multi-combobox.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' +import { Check, ChevronsUpDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Command, @@ -28,6 +28,7 @@ type RepoMultiComboboxProps = { * signal, so the caller can persist `null` (sticky-all) rather than a * frozen snapshot that would exclude repos added later. */ onSelectAll: () => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined triggerClassName?: string } @@ -63,11 +64,17 @@ function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React ) } +export function getRepoMultiComboboxDetail(repo: Repo, hostLabel?: string | null): string { + const trimmedHostLabel = hostLabel?.trim() + return trimmedHostLabel ? `${trimmedHostLabel} · ${repo.path}` : repo.path +} + export default function RepoMultiCombobox({ repos, selected, onChange, onSelectAll, + getRepoHostLabel, triggerClassName }: RepoMultiComboboxProps): React.JSX.Element { const [open, setOpen] = useState(false) @@ -188,6 +195,7 @@ export default function RepoMultiCombobox({ {filteredRepos.map((repo) => { const isSelected = selected.has(repo.id) const isLastSelected = isSelected && selected.size <= 1 + const detail = getRepoMultiComboboxDetail(repo, getRepoHostLabel?.(repo)) return ( <CommandItem key={repo.id} @@ -209,14 +217,8 @@ export default function RepoMultiCombobox({ color={repo.badgeColor} className="max-w-full" /> - {repo.connectionId && ( - <span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground"> - <Server className="size-2.5" /> - {translate('auto.components.ui.repo.multi.combobox.286ce70256', 'SSH')} - </span> - )} </span> - <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{repo.path}</p> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> </div> </CommandItem> ) diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts new file mode 100644 index 00000000000..ac60d86d2eb --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8') + +function sourceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('WorktreeJumpPalette source-context boundaries', () => { + it('resolves typed GitHub issue/PR entries through the lookup repo source host', () => { + expect(source).toContain('buildTaskSourceContextFromRepo') + + const githubLinkSection = sourceBetween( + 'void lookupGitHubWorkItemByOwnerRepoForSource({', + '// Case 2: user typed a raw issue number.' + ) + expect(githubLinkSection).toContain('sourceContext') + + const rawNumberSection = sourceBetween( + 'void lookupGitHubWorkItemForSource({', + '.then((item) => {' + ) + expect(rawNumberSection).toContain('sourceContext') + }) +}) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index cbc17bbf783..2fc2eb3e744 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -11,6 +11,7 @@ import type { AutomationDispatchResult, AutomationPrecheckResult } from '../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' import { didAutomationPrecheckPass, formatAutomationPrecheckFailure @@ -57,7 +58,8 @@ export function useAutomationDispatchEvents(): void { activeTabId: state.activeTabId, activeTabType: state.activeTabType } - const repo = state.repos.find((entry) => entry.id === automation.projectId) + const runRepoId = getAutomationRunRepoId(automation) + const repo = state.repos.find((entry) => entry.id === runRepoId) const automationWorktree = automation.workspaceId ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) : null @@ -117,6 +119,25 @@ export function useAutomationDispatchEvents(): void { } } + if ( + automation.workspaceMode === 'existing' && + automationWorktree && + automation.runContext?.repoId && + automationWorktree.repoId !== automation.runContext.repoId + ) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return + } + if (automation.workspaceMode === 'existing' && !automationWorktree) { await markDispatchResult({ runId: run.id, @@ -156,7 +177,7 @@ export function useAutomationDispatchEvents(): void { await useAppStore .getState() .createWorktree( - automation.projectId, + runRepoId, buildAutomationWorkspaceName(run.title, run.scheduledFor), automation.baseBranch ?? undefined, 'inherit', diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts new file mode 100644 index 00000000000..75ce1b86d14 --- /dev/null +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveInitialWorkspaceRunSeed } from './useComposerState' + +const HOOK_SOURCE = readFileSync(join(__dirname, 'useComposerState.ts'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('useComposerState host-context boundaries', () => { + it('resolves GitHub PR bases against the selected run repo, not the source item repo', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleSmartGitHubItemSelect', + 'const handleSmartGitLabItemSelect' + ) + + expect(section).toContain('const runRepo = selectedRepo ??') + expect(section).toContain('repoId: runRepo.id') + expect(section).toContain('repo: runRepo.id') + expect(section).not.toContain('repoId: repoForItem.id') + expect(section).not.toContain('repo: repoForItem.id') + }) + + it('resolves GitLab MR bases against the selected run repo, not the source item repo', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleSmartGitLabItemSelect', + 'const handleSmartBranchSelect' + ) + + expect(section).toContain('const runRepo = selectedRepo ??') + expect(section).toContain('repoId: runRepo.id') + expect(section).not.toContain('repoId: repoForItem.id') + }) + + it('seeds initial workspace run target from the task source context', () => { + expect( + resolveInitialWorkspaceRunSeed({ + initialTaskSourceContext: { + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + } + }) + ).toEqual({ + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + }) + + expect( + resolveInitialWorkspaceRunSeed({ + draftProjectId: 'draft-project', + draftHostId: 'local', + draftProjectHostSetupId: 'setup-local', + initialTaskSourceContext: { + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + } + }) + ).toEqual({ + projectId: 'draft-project', + hostId: 'local', + projectHostSetupId: 'setup-local' + }) + + const section = sourceBetween(HOOK_SOURCE, 'const initialRunSeed', 'const [internalRepoId') + + expect(section).toContain('resolveInitialWorkspaceRunSeed') + expect(section).toContain('initialTaskSourceContext') + expect(section).toContain('projectId: initialRunSeed.projectId') + expect(section).toContain('hostId: initialRunSeed.hostId') + expect(section).toContain('projectHostSetupId: initialRunSeed.projectHostSetupId') + }) + + it('resolves typed GitHub issue/PR input through the selected repo source context', () => { + expect(HOOK_SOURCE).toContain('const selectedRepoGitHubSourceContext = useMemo') + + const directLookup = sourceBetween( + HOOK_SOURCE, + 'void window.api.gh', + 'const applyLinkedWorkItem = useCallback' + ) + expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') + + const submitLookup = sourceBetween( + HOOK_SOURCE, + 'const resolvePendingSmartGitHubSubmit', + 'const resolution = getSmartGitHubSubmitResolution(item)' + ) + expect(submitLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') + }) +}) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 5a364f0cd20..dd53da51fa5 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -25,6 +25,11 @@ import { import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' +import { + buildTaskSourceContextFromRepo, + type TaskSourceContext +} from '../../../shared/task-source-context' import type { GitHubWorkItem, GitHubPrStartPoint, @@ -78,14 +83,34 @@ import { getSmartGitHubSubmitResolution, type SmartGitHubSubmitResolution } from '@/lib/smart-github-submit' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' import { canUseRepoBackedComposerSources, getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' -import { getComposerEligibleRepos, resolveComposerRepoId } from '@/lib/new-workspace-composer-repo' +import { getComposerEligibleRepos } from '@/lib/new-workspace-composer-repo' +import { + resolveWorkspaceCreationRepoId, + resolveWorkspaceCreationTarget +} from '@/lib/project-host-workspace-target' +import { + buildProjectHostSetupOptions, + type ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { + buildNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' +import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { getForkPushWarning } from './fork-push-warning' @@ -116,6 +141,7 @@ export type UseComposerStateOptions = { initialName?: string initialPrompt?: string initialLinkedWorkItem?: LinkedWorkItemSummary | null + initialTaskSourceContext?: TaskSourceContext | null initialWorkspaceStatus?: WorkspaceStatus /** Seed the Start-from selection when the composer opens. Used by the * Create-from → Quick fallback path so a PR pick that needs a setup @@ -148,8 +174,14 @@ export type UseComposerStateOptions = { export type ComposerCardProps = { eligibleRepos: ReturnType<typeof useAppStore.getState>['repos'] repoId: string + projectOptions: NewWorkspaceProjectOption[] + selectedProjectId: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void + projectHostSetupOptions: ProjectHostSetupOption[] + selectedProjectHostSetupId: string | null + onProjectHostSetupChange: (setupId: string) => void name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -254,6 +286,34 @@ export type UseComposerStateResult = { createDisabled: boolean } +export type InitialWorkspaceRunSeedInput = { + draftProjectId?: string | null + draftHostId?: string | null + draftProjectHostSetupId?: string | null + initialTaskSourceContext?: Pick< + TaskSourceContext, + 'projectId' | 'hostId' | 'projectHostSetupId' + > | null +} + +export function resolveInitialWorkspaceRunSeed({ + draftProjectId, + draftHostId, + draftProjectHostSetupId, + initialTaskSourceContext +}: InitialWorkspaceRunSeedInput): { + projectId: string | null + hostId: ExecutionHostId | null + projectHostSetupId: string | null +} { + return { + projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null, + hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId), + projectHostSetupId: + draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null + } +} + // Why: both the full-page TaskPage composer and the Cmd+J modal can be // mounted simultaneously. Without instance scoping, a single native file // drop fires every subscriber and duplicates attachments/prompt edits across @@ -270,6 +330,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialName = '', initialPrompt = '', initialLinkedWorkItem = null, + initialTaskSourceContext = null, initialWorkspaceStatus, initialBaseBranch, persistDraft, @@ -315,6 +376,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } = actions const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) @@ -322,9 +385,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos]) const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null + const draftProjectId = persistDraft ? (newWorkspaceDraft?.projectId ?? null) : null + const draftHostId = persistDraft ? (newWorkspaceDraft?.hostId ?? null) : null + const draftProjectHostSetupId = persistDraft + ? (newWorkspaceDraft?.projectHostSetupId ?? null) + : null + // Why: Tasks can start work from Linear/Jira source contexts that are not + // repo-backed. Seed the run target from the logical project/source host so + // the modal does not silently fall back to the ambient active repo. + const initialRunSeed = resolveInitialWorkspaceRunSeed({ + draftProjectId, + draftHostId, + draftProjectHostSetupId, + initialTaskSourceContext + }) const resolvedInitialWorkspaceStatus = useMemo( () => initialWorkspaceStatus && isWorkspaceStatusId(initialWorkspaceStatus, workspaceStatuses) @@ -333,17 +414,90 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [initialWorkspaceStatus, workspaceStatuses] ) - const resolvedInitialRepoId = resolveComposerRepoId({ + const resolvedInitialRepoId = resolveWorkspaceCreationRepoId({ eligibleRepos, + projects, + projectHostSetups, draftRepoId, initialRepoId, - activeRepoId + activeRepoId, + projectId: initialRunSeed.projectId, + hostId: initialRunSeed.hostId, + projectHostSetupId: initialRunSeed.projectHostSetupId, + focusedHostScope: workspaceHostScope }) const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId) const [projectError, setProjectError] = useState<string | null>(null) const repoId = repoIdOverride ?? internalRepoId + const selectedWorkspaceTarget = useMemo( + () => + resolveWorkspaceCreationTarget({ + eligibleRepos, + projects, + projectHostSetups, + draftRepoId: repoId, + focusedHostScope: workspaceHostScope + }), + [eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] + ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + const selectedProjectId = + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null + const selectedProjectHostSetupId = + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectHostSetupId + : null + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const projectHostSetupOptions = useMemo( + () => + buildProjectHostSetupOptions({ + projectId: selectedProjectId, + projectHostSetups, + eligibleRepos, + hosts: hostOptions + }), + [eligibleRepos, hostOptions, projectHostSetups, selectedProjectId] + ) + const projectOptions = useMemo( + () => + buildNewWorkspaceProjectOptions({ + projects, + projectHostSetups, + eligibleRepos + }), + [eligibleRepos, projectHostSetups, projects] + ) + const selectedRepoSettings = useMemo(() => { + if (!settings) { + return settings + } + // Why: composer probes and attachment uploads inspect the selected repo, + // even though workspace creation defaults still follow host scope. + return getSettingsForRepoRuntimeOwner( + { repos: selectedRepo ? [selectedRepo] : [], settings }, + selectedRepo?.id ?? null + ) + }, [selectedRepo, settings]) const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const selectedRepoSshState = selectedRepoConnectionId @@ -382,6 +536,77 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem) : initialLinkedWorkItem ) + const taskSourceContext = useMemo(() => { + if ( + persistDraft && + newWorkspaceDraft?.taskSourceContext && + newWorkspaceDraft.linkedWorkItem?.url === linkedWorkItem?.url + ) { + return newWorkspaceDraft.taskSourceContext + } + if (initialTaskSourceContext && initialLinkedWorkItem?.url === linkedWorkItem?.url) { + return initialTaskSourceContext + } + if ( + !linkedWorkItem || + getLinkedWorkItemProvider(linkedWorkItem) !== 'github' || + !selectedRepo || + selectedWorkspaceTarget.status !== 'ready' + ) { + return null + } + const selectedProject = projects.find( + (project) => project.id === selectedWorkspaceTarget.target.projectId + ) + if (selectedProject?.providerIdentity?.provider !== 'github') { + return null + } + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedWorkspaceTarget.target.projectId, + repo: selectedRepo, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + providerIdentity: selectedProject.providerIdentity + }) + }, [ + initialLinkedWorkItem, + initialTaskSourceContext, + linkedWorkItem, + newWorkspaceDraft?.linkedWorkItem?.url, + newWorkspaceDraft?.taskSourceContext, + persistDraft, + projects, + selectedRepo, + selectedWorkspaceTarget + ]) + const selectedRepoGitHubSourceContext = useMemo(() => { + if (!selectedRepo || !selectedRepoIsGit) { + return null + } + if (taskSourceContext?.provider === 'github') { + return taskSourceContext + } + if (selectedWorkspaceTarget.status === 'ready') { + const selectedProject = projects.find( + (project) => project.id === selectedWorkspaceTarget.target.projectId + ) + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedWorkspaceTarget.target.projectId, + repo: selectedRepo, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + providerIdentity: + selectedProject?.providerIdentity?.provider === 'github' + ? selectedProject.providerIdentity + : null + }) + } + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedRepo.id, + repo: selectedRepo + }) + }, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext]) const [linkedIssue, setLinkedIssue] = useState<string>(() => { if (persistDraft && newWorkspaceDraft?.linkedIssue) { return newWorkspaceDraft.linkedIssue @@ -559,8 +784,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const selectedRepoPath = selectedRepo?.path const selectedRepoPathRef = useRef<string | undefined>(selectedRepoPath) selectedRepoPathRef.current = selectedRepoPath - const settingsRef = useRef(settings) - settingsRef.current = settings + const selectedRepoSettingsRef = useRef(selectedRepoSettings) + selectedRepoSettingsRef.current = selectedRepoSettings const cancelPromptCaretFrame = useCallback((): void => { if (promptCaretFrameRef.current === null) { @@ -586,12 +811,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS promise: Promise<HookCheckResult> } | null>(null) const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise<HookCheckResult> => { - const key = `${settingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}` + const key = `${selectedRepoSettingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}` const existing = hookCheckRef.current if (existing?.key === key) { return existing.promise } - const promise = checkRuntimeHooks(settingsRef.current, targetRepoId) + const promise = checkRuntimeHooks(selectedRepoSettingsRef.current, targetRepoId) hookCheckRef.current = { key, promise } return promise }, []) @@ -612,12 +837,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } let cancelled = false - void ( - window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{ - owner: string - repo: string - } | null> - ) + const target = getActiveRuntimeTarget(selectedRepoSettings) + const slugRequest = + target.kind === 'environment' + ? callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: repoId }, + { timeoutMs: 30_000 } + ) + : (window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{ + owner: string + repo: string + } | null>) + void slugRequest .then((result) => { if (cancelled) { return @@ -632,7 +865,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath]) + }, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath, selectedRepoSettings]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -806,11 +1039,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setNewWorkspaceDraft({ repoId: repoId || null, + projectId: + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectId + : null, + hostId: + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null, + projectHostSetupId: + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectHostSetupId + : null, name, prompt: agentPrompt, note, attachments: attachmentPaths, linkedWorkItem, + taskSourceContext, agent: tuiAgent, linkedIssue, linkedPR, @@ -831,7 +1075,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, name, repoId, + selectedWorkspaceTarget, setNewWorkspaceDraft, + taskSourceContext, tuiAgent ]) @@ -932,7 +1178,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } - void readRuntimeIssueCommand(settings, repoId) + void readRuntimeIssueCommand(selectedRepoSettings, repoId) .then((result) => { if (!cancelled) { setIssueCommandTemplate(result.effectiveContent ?? '') @@ -955,7 +1201,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS loadHookCheckForRepo, repoId, selectedRepoIsGit, - settings + selectedRepoSettings ]) const onConnectSelectedRepo = useCallback(async (): Promise<void> => { @@ -1121,12 +1367,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // resolving direct lookups against the selected repo instead of requiring a // text match in the recent-items list. const lookupRepoId = selectedRepo.id - void window.api.gh - .workItem({ - repoPath: selectedRepo.path, - repoId: selectedRepo.id, - number: normalizedLinkQuery.directNumber - }) + void lookupGitHubWorkItemForSource({ + repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, + number: normalizedLinkQuery.directNumber + }) .then((item) => { if (!cancelled) { setLinkDirectItem( @@ -1148,7 +1394,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, selectedRepoIsGit]) + }, [ + linkPopoverOpen, + normalizedLinkQuery.directNumber, + selectedRepo, + selectedRepoGitHubSourceContext, + selectedRepoIsGit + ]) const applyLinkedWorkItem = useCallback( (item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => { @@ -1199,10 +1451,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const item = await lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, intent, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) if (!item) { throw new Error('Could not resolve the GitHub item before creating the workspace.') @@ -1225,7 +1477,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoNameRef.current = '' setStartFromResetHint(null) return resolution - }, [linkedWorkItem, name, selectedRepo, selectedRepoIsGit]) + }, [linkedWorkItem, name, selectedRepo, selectedRepoGitHubSourceContext, selectedRepoIsGit]) // Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab // state slots only — the GitHub linkedIssue/linkedPR remain unchanged @@ -1400,7 +1652,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const uploadComposerPaths = useCallback( async ( sourcePaths: string[], - targetSettings = settings, + targetSettings = selectedRepoSettings, targetConnectionId = connectionId, targetRepoPath = selectedRepoPath ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { @@ -1411,7 +1663,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS toast.error( translate( 'auto.hooks.useComposerState.3db83fc58a', - 'No remote project path is available for attachments.' + 'No project path is available on this host for attachments.' ) ) return { filePaths: [], folderPaths: [] } @@ -1452,7 +1704,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } return { filePaths, folderPaths } }, - [connectionId, selectedRepoPath, settings] + [connectionId, selectedRepoPath, selectedRepoSettings] ) const handleAddAttachment = useCallback(async (): Promise<void> => { @@ -1530,7 +1782,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS void (async () => { const uploaded = await uploadComposerPathsRef.current( data.paths, - settingsRef.current, + selectedRepoSettingsRef.current, connectionIdRef.current, selectedRepoPathRef.current ) @@ -1552,7 +1804,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, []) const handleRepoChange = useCallback( - (value: string): void => { + (value: string, options: { preserveStartFrom?: boolean } = {}): void => { setProjectError(null) if (value === repoId) { setRepoId(value) @@ -1562,26 +1814,30 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // the field can render an inline reset (e.g. "was PR #8778") after the // repo changes and the selection is wiped. let hint: string | null = null - if (linkedWorkItem?.type === 'pr' && baseBranch) { - hint = `was PR #${linkedWorkItem.number}` - } else if (linkedWorkItem?.type === 'mr' && baseBranch) { - // Why: GitLab MR convention is `!N`, not `#N` — match the - // upstream UI so the reset hint is recognizable. - hint = `was MR !${linkedWorkItem.number}` - } else if (baseBranch) { - hint = `was ${baseBranch}` + if (!options.preserveStartFrom) { + if (linkedWorkItem?.type === 'pr' && baseBranch) { + hint = `was PR #${linkedWorkItem.number}` + } else if (linkedWorkItem?.type === 'mr' && baseBranch) { + // Why: GitLab MR convention is `!N`, not `#N` — match the + // upstream UI so the reset hint is recognizable. + hint = `was MR !${linkedWorkItem.number}` + } else if (baseBranch) { + hint = `was ${baseBranch}` + } } const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem) setRepoId(value) - setLinkedIssue('') - setLinkedPR(null) - setLinkedGitLabIssue(null) - setLinkedGitLabMR(null) - // Why: repo changes invalidate repo-scoped sources (GitHub/GitLab/branch), - // but a selected Linear issue is workspace-scoped source context and - // must survive choosing the implementation project. - if (!preserveLinearLinkedWorkItem) { - setLinkedWorkItem(null) + if (!options.preserveStartFrom) { + setLinkedIssue('') + setLinkedPR(null) + setLinkedGitLabIssue(null) + setLinkedGitLabMR(null) + // Why: repo changes invalidate repo-scoped sources (GitHub/GitLab/branch), + // but a selected Linear issue is workspace-scoped source context and + // must survive choosing the implementation project. + if (!preserveLinearLinkedWorkItem) { + setLinkedWorkItem(null) + } } setSparseEnabled(false) setSparseDirectories('') @@ -1591,21 +1847,60 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: the Start-from picker is repo-scoped, so any prior branch/PR // selection is meaningless in the new repo. Resetting to undefined // makes the field fall back to the new repo's effective base ref. - setBaseBranch(undefined) - setPushTarget(undefined) - setBranchNameOverride(undefined) - setForkPushWarning(null) - setStartFromResetHint(hint) + if (!options.preserveStartFrom) { + setBaseBranch(undefined) + setPushTarget(undefined) + setBranchNameOverride(undefined) + setForkPushWarning(null) + setStartFromResetHint(hint) + } }, [baseBranch, linkedWorkItem, repoId, setRepoId] ) - + const handleProjectHostSetupChange = useCallback( + (setupId: string): void => { + const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId) + if (!option || option.kind !== 'ready') { + return + } + // Why: switching the run host for the same logical project must not + // erase the task/PR source the user is starting from. + handleRepoChange(option.repoId, { preserveStartFrom: true }) + }, + [handleRepoChange, projectHostSetupOptions] + ) + const handleProjectChange = useCallback( + (projectId: string): void => { + const preferredHostId = + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null + const nextRepoId = resolveWorkspaceCreationRepoId({ + eligibleRepos, + projects, + projectHostSetups, + projectId, + hostId: preferredHostId, + focusedHostScope: workspaceHostScope + }) + if (!nextRepoId) { + return + } + handleRepoChange(nextRepoId) + }, + [ + eligibleRepos, + handleRepoChange, + projectHostSetups, + projects, + selectedWorkspaceTarget, + workspaceHostScope + ] + ) const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') requestAnimationFrame(() => { document .querySelector<HTMLElement>( - '[data-contextual-tour-target="workspace-creation-project"] [data-repo-combobox-root="true"][role="combobox"]' + '[data-contextual-tour-target="workspace-creation-project"] [data-project-combobox-root="true"][role="combobox"]' ) ?.focus() }) @@ -1694,18 +1989,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBranchNameOverride(undefined) setForkPushWarning(null) branchAutoNameRef.current = '' - const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo + // Why: provider items can come from a different source host than the + // selected run host. Resolve git refs against the run repo; keep item + // metadata/source context separate for provider identity. + const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) applyLinkedWorkItem(item) - if (item.type !== 'pr' || !repoForItem) { + if (item.type !== 'pr' || !runRepo) { setPushTarget(undefined) return } setPushTarget(undefined) - const target = getActiveRuntimeTarget(settings) + const itemRepoSettings = getSettingsForRepoRuntimeOwner( + { repos: [runRepo], settings }, + runRepo.id + ) + const target = getActiveRuntimeTarget(itemRepoSettings) const resolvePrBase = target.kind === 'local' ? window.api.worktrees.resolvePrBase({ - repoId: repoForItem.id, + repoId: runRepo.id, prNumber: item.number, ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -1716,7 +2018,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS target, 'worktree.resolvePrBase', { - repo: repoForItem.id, + repo: runRepo.id, prNumber: item.number, ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -1768,13 +2070,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBranchNameOverride(undefined) setForkPushWarning(null) branchAutoNameRef.current = '' - const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo - if (item.type !== 'mr' || !repoForItem) { + // Why: MR metadata can be sourced from one host/account while the + // workspace is created on another host for the same logical project. + const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) + if (item.type !== 'mr' || !runRepo) { return } void window.api.worktrees .resolveMrBase({ - repoId: repoForItem.id, + repoId: runRepo.id, mrIid: item.number, ...(item.branchName ? { sourceBranch: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -2311,6 +2615,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS workspaceName, preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits }) + const submitBaseBranch = + selectedRepoIsGit && !baseBranch + ? ((await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) + ?.defaultBaseRef ?? undefined) + : baseBranch const createDisplayName = smartGitHubResolution?.displayName ?? (nameIsAutoManaged ? submitTitleName?.displayName : undefined) @@ -2389,9 +2698,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : undefined const request: WorktreeCreationRequest = { repoId, + ...(taskSourceContext ? { taskSourceContext } : {}), + ...(selectedWorkspaceTarget.status === 'ready' + ? { + workspaceRunContext: { + kind: 'workspace-run', + projectId: selectedWorkspaceTarget.target.projectId, + hostId: selectedWorkspaceTarget.target.hostId, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + repoId: selectedWorkspaceTarget.target.repoId, + path: selectedWorkspaceTarget.target.repo.path + } + } + : {}), name: workspaceName, ...(createDisplayName ? { displayName: createDisplayName } : {}), - ...(selectedRepoIsGit && baseBranch ? { baseBranch } : {}), + ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), setupDecision: effectiveSetupDecision, ...(selectedRepoIsGit && sparseEnabled ? { @@ -2469,7 +2791,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedInitialWorkspaceStatus, selectedRepo, selectedRepoIsGit, + selectedRepoSettings, selectedRepoRequiresConnection, + selectedWorkspaceTarget, showProjectRequiredError, settings?.agentCmdOverrides, settings?.agentDefaultArgs, @@ -2481,6 +2805,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError, effectivePresetId, telemetrySource, + taskSourceContext, checkedHooksRepoId, commitHookCheckIfCurrent, loadHookCheckForRepo, @@ -2507,8 +2832,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const cardProps: ComposerCardProps = { eligibleRepos, repoId, + projectOptions, + selectedProjectId, selectedRepoIsGit, onRepoChange: handleRepoChange, + onProjectChange: handleProjectChange, + projectHostSetupOptions, + selectedProjectHostSetupId, + onProjectHostSetupChange: handleProjectHostSetupChange, name, onNameValueChange: handleNameValueChange, onSmartGitHubItemSelect: handleSmartGitHubItemSelect, diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 68cf34b8be8..7d0679fbacf 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -17,13 +17,15 @@ vi.mock('@/components/editor/editor-autosave', () => ({ describe('getEditorExternalWatchTargets', () => { const makeRepo = ( id: string, - connectionId: string | null = null + connectionId: string | null = null, + executionHostId?: EditorExternalWatchTargetState['repos'][number]['executionHostId'] ): EditorExternalWatchTargetState['repos'][number] => ({ id, path: `/${id}`, kind: 'git', - connectionId + connectionId, + executionHostId }) as EditorExternalWatchTargetState['repos'][number] const makeWorktree = ( diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 2606209a3be..10fa8423135 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -21,6 +21,7 @@ import type { FsChangedPayload } from '../../../shared/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent, subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' // Why: atomic-write patterns (Claude Code's Edit tool, editors like vim, // VSCode) land as a short burst of `update` events — or `delete + create` on @@ -158,7 +159,9 @@ export function getEditorExternalWatchTargets( owners = new Set() targetOwnersByWorktreeId.set(state.activeWorktreeId, owners) } - owners.add(runtimeEnvironmentId ?? null) + // Why: the Explorer is mounted for the selected worktree. Its watcher must + // follow that worktree's host owner, not the host currently focused in the UI. + owners.add(getRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)) } const nextTargets: WatchedTarget[] = [] diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx new file mode 100644 index 00000000000..5f0d7bac393 --- /dev/null +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearGitHubSlugMetadataCache, + useRepoAssigneesBySlug, + useRepoLabelsBySlug +} from './useGitHubSlugMetadata' + +const apiMocks = vi.hoisted(() => ({ + listLabelsBySlug: vi.fn(), + listAssignableUsersBySlug: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(), + getActiveRuntimeTarget: ( + settings?: { activeRuntimeEnvironmentId?: string | null } | null + ) => + settings?.activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +const roots: Root[] = [] + +function installWindowApi(): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + gh: { + listLabelsBySlug: apiMocks.listLabelsBySlug, + listAssignableUsersBySlug: apiMocks.listAssignableUsersBySlug + } + } + }) +} + +async function flushEffects(): Promise<void> { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function renderProbe(element: React.ReactNode): void { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render(element) + }) +} + +describe('useGitHubSlugMetadata', () => { + beforeEach(() => { + clearGitHubSlugMetadataCache() + apiMocks.listLabelsBySlug.mockReset() + apiMocks.listAssignableUsersBySlug.mockReset() + installWindowApi() + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it('does not loop when cached label metadata is read with a fresh settings object', async () => { + let renders = 0 + let labels: string[] = [] + apiMocks.listLabelsBySlug.mockResolvedValue({ ok: true, labels: ['bug'] }) + + function LabelsProbe(): null { + renders += 1 + const metadata = useRepoLabelsBySlug('stablyai', 'orca', { + activeRuntimeEnvironmentId: null + }) + labels = metadata.data + return null + } + + renderProbe(<LabelsProbe />) + await flushEffects() + + expect(labels).toEqual(['bug']) + expect(apiMocks.listLabelsBySlug).toHaveBeenCalledExactlyOnceWith({ + owner: 'stablyai', + repo: 'orca' + }) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not loop when cached assignee metadata is read with a fresh settings object', async () => { + let renders = 0 + let assigneeLogins: string[] = [] + apiMocks.listAssignableUsersBySlug.mockResolvedValue({ + ok: true, + users: [{ login: 'jinwoo', name: 'Jinwoo', avatarUrl: 'https://example.test/avatar.png' }] + }) + + function AssigneesProbe(): null { + renders += 1 + const metadata = useRepoAssigneesBySlug( + 'stablyai', + 'orca', + ['jinwoo'], + { activeRuntimeEnvironmentId: null } + ) + assigneeLogins = metadata.data.map((user) => user.login) + return null + } + + renderProbe(<AssigneesProbe />) + await flushEffects() + + expect(assigneeLogins).toEqual(['jinwoo']) + expect(apiMocks.listAssignableUsersBySlug).toHaveBeenCalledExactlyOnceWith({ + owner: 'stablyai', + repo: 'orca', + seedLogins: ['jinwoo'] + }) + expect(renders).toBeLessThanOrEqual(4) + }) +}) diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index bd4a400bf53..26022191fcc 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -57,15 +57,18 @@ export function useRepoLabelsBySlug( const cached = getFreshMetadata(slugLabelStore, key) if (cached) { - // Why: always seed state from cache. A remount with the same key - // resets local state to defaults but `activeKeyRef.current` from the - // new ref instance is null on first run — the previous gate that - // skipped setState when keys matched dropped cached data on remount. - setState({ data: cached.data, loading: false, error: null }) + // Why: parent selectors can pass a fresh settings object each render; + // only the first cached hit for this key should write React state. + if (activeKeyRef.current !== key) { + setState({ data: cached.data, loading: false, error: null }) + } activeKeyRef.current = key return } + if (activeKeyRef.current === key) { + return + } activeKeyRef.current = key const requestKey = key setState((s) => ({ @@ -141,14 +144,18 @@ export function useRepoAssigneesBySlug( const cached = getFreshMetadata(slugAssigneeStore, key) if (cached) { - // Why: see useRepoLabelsBySlug — always seed state from cache so a - // remount with the same key picks up cached data instead of staying - // at the empty default. - setState({ data: cached.data, loading: false, error: null }) + // Why: see useRepoLabelsBySlug — avoid cached no-op writes when only + // the settings object identity changed. + if (activeKeyRef.current !== key) { + setState({ data: cached.data, loading: false, error: null }) + } activeKeyRef.current = key return } + if (activeKeyRef.current === key) { + return + } activeKeyRef.current = key const requestKey = key setState((s) => ({ diff --git a/src/renderer/src/hooks/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts index 1439e401c69..fb4dac202b8 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.test.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { shouldUploadRemoteEditorFileDrop } from './useGlobalFileDrop' +import { + getEditorFileDropOperationContext, + getEditorFileDropSettingsForWorktree, + shouldUploadRemoteEditorFileDrop +} from './useGlobalFileDrop' describe('shouldUploadRemoteEditorFileDrop', () => { it('does not upload editor drops for local workspaces', () => { @@ -17,4 +21,70 @@ describe('shouldUploadRemoteEditorFileDrop', () => { true ) }) + + it('uses the worktree owner runtime instead of the focused runtime', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) + }) + + it('keeps explicit local worktree editor drops local while a runtime is focused', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) + + it('builds file operation context from the worktree owner instead of global focus', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/repos/repo-1', + undefined + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repos/repo-1', + connectionId: undefined + }) + }) + + it('preserves SSH ownership in editor drop operation context', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/home/orca/repo-1', + 'ssh-1' + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/home/orca/repo-1', + connectionId: 'ssh-1' + }) + }) }) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index 0d3039fbd44..c319e4dc301 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -5,6 +5,7 @@ import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-lin import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { joinPath } from '@/lib/path' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, isRemoteRuntimeFileOperation, @@ -13,6 +14,20 @@ import { } from '@/runtime/runtime-file-client' import type { GlobalSettings } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' + +export function getEditorFileDropSettingsForWorktree( + store: WorktreeRuntimeOwnerState, + worktreeId: string +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) + // Why: OS drops target the selected worktree. Use that worktree's host owner + // so a focused runtime cannot hijack local/SSH editor drops. + return { + ...store.settings, + activeRuntimeEnvironmentId: runtimeEnvironmentId + } +} export function shouldUploadRemoteEditorFileDrop( settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, @@ -21,6 +36,20 @@ export function shouldUploadRemoteEditorFileDrop( return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) } +export function getEditorFileDropOperationContext( + store: WorktreeRuntimeOwnerState, + worktreeId: string, + worktreePath: string | null | undefined, + connectionId: string | undefined +): RuntimeFileOperationArgs { + return { + settings: getEditorFileDropSettingsForWorktree(store, worktreeId), + worktreeId, + worktreePath, + connectionId + } +} + export function useGlobalFileDrop(): void { useEffect(() => { return window.api.ui.onFileDrop((data) => { @@ -37,8 +66,14 @@ export function useGlobalFileDrop(): void { const activeWorktree = store.getKnownWorktreeById(activeWorktreeId) const worktreePath = activeWorktree?.path const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const dropSettings = store.settings - const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId?.trim() || undefined + const fileContext = getEditorFileDropOperationContext( + store, + activeWorktreeId, + worktreePath, + connectionId + ) + const dropSettings = fileContext.settings + const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId ?? null if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { if (!worktreePath) { toast.error( @@ -55,12 +90,7 @@ export function useGlobalFileDrop(): void { // SSH editors must upload into the server worktree before opening. const destinationDir = joinPath(worktreePath, '.orca/drops') const { results } = await importExternalPathsToRuntime( - { - settings: dropSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, + fileContext, data.paths, destinationDir, { ensureDestinationDir: true } @@ -77,11 +107,11 @@ export function useGlobalFileDrop(): void { filePath: result.destPath, relativePath: maybeRelative ?? result.destPath, worktreeId: activeWorktreeId, - runtimeEnvironmentId, + runtimeEnvironmentId: runtimeEnvironmentId ?? undefined, language: detectLanguage(result.destPath), mode: 'edit' }, - { suppressActiveRuntimeFallback: runtimeEnvironmentId === undefined } + { suppressActiveRuntimeFallback: runtimeEnvironmentId === null } ) } if (results.some((result) => result.status !== 'imported')) { @@ -110,12 +140,6 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const fileContext: RuntimeFileOperationArgs = { - settings: store.settings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - } const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) // Why: remote paths don't need local auth — the relay/runtime is the security boundary. if (!connectionId && !isRemoteRuntimePath) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 249513de517..5861b8ba65b 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1534,6 +1534,9 @@ describe('useIpcEvents updater integration', () => { expect(createFloatingWorkspaceTerminalTab).not.toHaveBeenCalled() expect(createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ worktreeId: 'wt-1', + // Why: multi-host scopes the new terminal to the worktree's own runtime + // env (null here -> falls back to the active env inside the helper). + environmentId: null, activate: true }) expect(createTab).toHaveBeenCalledWith('wt-1') diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e7ca0ded98e..de7ac997edc 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -109,6 +109,7 @@ import { import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { titleHasAgentName } from '../../../shared/agent-detection' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { translate } from '@/i18n/i18n' function getShortcutPlatform(): NodeJS.Platform { @@ -684,6 +685,10 @@ function getActiveRuntimeEnvironmentId(): string | null { return useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null } +function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] @@ -1662,9 +1667,6 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { - if (isRuntimeEnvironmentActive()) { - return - } const store = useAppStore.getState() const sourcePage = Object.values(store.browserPagesByWorkspace) .flat() @@ -1672,6 +1674,9 @@ export function useIpcEvents(): void { if (!sourcePage) { return } + if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { + return + } // Why: the guest process can request "open this link in Orca", but it // does not own Orca's worktree/tab model. Resolve the source page's // worktree and create a new outer browser tab so the link opens as a @@ -1691,8 +1696,8 @@ export function useIpcEvents(): void { } const worktreeId = store.activeWorktreeId if (worktreeId) { - if (isRuntimeEnvironmentActive()) { - const environmentId = getActiveRuntimeEnvironmentId() + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + if (environmentId) { if (!isWebRuntimeSessionActive(environmentId)) { store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', { title: translate('auto.hooks.useIpcEvents.f6300deb8b', 'New Browser Tab'), @@ -1706,6 +1711,7 @@ export function useIpcEvents(): void { // the next host snapshot remains authoritative. await createWebRuntimeSessionBrowserTab({ worktreeId, + environmentId, url: store.browserDefaultUrl ?? 'about:blank' }) })() @@ -2034,6 +2040,7 @@ export function useIpcEvents(): void { if ( await createWebRuntimeSessionTerminal({ worktreeId, + environmentId: getWorktreeRuntimeEnvironmentId(worktreeId), activate: true }) ) { @@ -2083,8 +2090,8 @@ export function useIpcEvents(): void { ) { return } - if (isRuntimeEnvironmentActive() && store.activeWorktreeId) { - const environmentId = getActiveRuntimeEnvironmentId() + const environmentId = getWorktreeRuntimeEnvironmentId(store.activeWorktreeId) + if (environmentId && store.activeWorktreeId) { if (!isWebRuntimeSessionActive(environmentId)) { store.closeBrowserTab(store.activeBrowserTabId) return @@ -2092,7 +2099,8 @@ export function useIpcEvents(): void { void (async () => { await closeWebRuntimeSessionTab({ worktreeId: store.activeWorktreeId!, - tabId: store.activeBrowserTabId! + tabId: store.activeBrowserTabId!, + environmentId }) })() return diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index a61b0e71da2..ea1e306ca13 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -6,15 +6,16 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { linearTeamLabels, linearTeamMembers, - linearTeamStates + linearTeamStates, + type RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser, - GlobalSettings, LinearWorkflowState, LinearLabel, LinearMember } from '../../../shared/types' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' import { clearMetadataRequestStore, createMetadataRequestStore, @@ -30,6 +31,7 @@ type MetadataState<T> = { type GitHubMetadataOptions = { runtimeEnvironmentId?: string | null + activeRuntimeEnvironmentId?: string | null } // ─── GitHub ──────────────────────────────────────────────── @@ -53,7 +55,8 @@ export function useRepoLabels( if (!repoPath && !repoId) { return } - const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null const repoSelector = repoId ?? repoPath ?? '' // Why: SSH/runtime metadata must not reuse host-path cache entries; the same // repo id may resolve through a different credential/runtime boundary. @@ -106,7 +109,7 @@ export function useRepoLabels( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [repoPath, repoId, options?.runtimeEnvironmentId]) + }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) return state } @@ -127,7 +130,8 @@ export function useRepoAssignees( if (!repoPath && !repoId) { return } - const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null const repoSelector = repoId ?? repoPath ?? '' // Why: SSH/runtime metadata must not reuse host-path cache entries; the same // repo id may resolve through a different credential/runtime boundary. @@ -180,7 +184,7 @@ export function useRepoAssignees( error: err instanceof Error ? err.message : 'Failed to load assignees' })) }) - }, [repoPath, repoId, options?.runtimeEnvironmentId]) + }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) return state } @@ -193,10 +197,12 @@ const linearMemberStore = createMetadataRequestStore<LinearMember[]>() function linearMetadataCacheKey( teamId: string, - settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + settings: RuntimeLinearSettings, workspaceId?: string | null ): string { - const target = getActiveRuntimeTarget(settings) + const runtimeSettings = + settings && 'kind' in settings ? getTaskSourceRuntimeSettings(settings) : settings + const target = getActiveRuntimeTarget(runtimeSettings) const workspaceKey = workspaceId ?? 'selected' return target.kind === 'environment' ? `runtime:${target.environmentId}:${workspaceKey}:${teamId}` @@ -216,7 +222,7 @@ export function clearGitHubMetadataCache(): void { export function useTeamStates( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearWorkflowState[]> { const [state, setState] = useState<MetadataState<LinearWorkflowState[]>>({ @@ -278,7 +284,7 @@ export function useTeamStates( export function useTeamLabels( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearLabel[]> { const [state, setState] = useState<MetadataState<LinearLabel[]>>({ @@ -338,7 +344,7 @@ export function useTeamLabels( export function useTeamMembers( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearMember[]> { const [state, setState] = useState<MetadataState<LinearMember[]>>({ diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 33830ba845e..4459e9ed99d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -389,7 +389,7 @@ export function buildSettingsNavigationMetadata({ ), description: isWebClient ? 'Connect this browser to a saved Orca server.' - : 'Switch between local desktop mode and paired remote Orca runtimes.', + : 'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.', icon: Server, searchEntries: [runtimeEnvironmentsSearchEntry], group: 'remote', @@ -402,7 +402,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.94a5afe910', 'SSH Hosts'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.31e57d1c70', - 'Remote SSH hosts for files, terminals, and git.' + 'Use existing machines over SSH for files, terminals, Git, and workspaces.' ), icon: Cable, searchEntries: getSshPaneSearchEntries(), @@ -417,7 +417,7 @@ export function buildSettingsNavigationMetadata({ ), icon: Smartphone, searchEntries: getMobileSettingsPaneSearchEntries(), - group: 'remote' + group: 'mobile' } ] : []), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f7d9af0546b..a82f28fa748 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", - "sshToggleDescription": "Show the active SSH connection. Only visible once an SSH target is configured.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." } @@ -218,7 +218,7 @@ }, "repos": { "b7e14472ae": "Failed to add folder", - "e649269645": "Use a server path to add projects from a remote runtime.", + "e649269645": "Use Add Project to enter a path on the selected host.", "c6e022ddfc": "Failed to add project", "90d129b48b": "Folder added", "8bb3ad7935": "Project added", @@ -274,8 +274,7 @@ "760bc6883d": "Codex", "a5fc0cb622": "OpenClaude", "bf53f09bf8": "Claude Agent Teams", - "0708ed89f1": "Claude", - "fc80296033": "Devin" + "0708ed89f1": "Claude" }, "skill": { "cli": { @@ -464,35 +463,6 @@ "7d732521ec": "Comment" } } - }, - "folderWorkspacePathStatus": { - "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" - }, - "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." - }, - "createError": { - "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" - }, - "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." - } - } } }, "hooks": { @@ -505,7 +475,7 @@ "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", "b2ead86962": "Failed to resolve PR base.", "a9ff236145": "Some attachments could not be uploaded.", - "3db83fc58a": "No remote project path is available for attachments.", + "3db83fc58a": "No project path is available on this host for attachments.", "ba6cb77082": "Failed to connect to project." }, "useGlobalFileDrop": { @@ -540,7 +510,7 @@ "d91ae31fbd": "macOS Permissions", "95a1886d94": "Control terminals and agents from your phone.", "1cd25673df": "Mobile", - "31e57d1c70": "Remote SSH hosts for files, terminals, and git.", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSH Hosts", "40d80bad8a": "Beta", "de0c2907a1": "Remote Orca Servers", @@ -1030,15 +1000,21 @@ "f660aa1454": "Connecting", "7711ad5122": "Local setup command", "e5db1b0419": "Combined setup command", + "runOn": "Run on", "addProjectBeforeWorkspace": "Add a project before creating a workspace.", - "sshNotConnected": "SSH not connected", - "connectingSsh": "Connecting SSH...", - "sshAuthenticationFailed": "SSH authentication failed", - "preparingSshConnection": "Preparing SSH connection...", - "connected": "Connected", - "reconnectingSsh": "Reconnecting SSH...", - "sshReconnectionFailed": "SSH reconnection failed", - "notConnected": "Not connected" + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace." @@ -1552,9 +1528,7 @@ "a2a279b32a": "Save timed out or failed. Fix errors before closing.", "46e08bc5c8": "This file has unsaved changes.", "61ed600d29": "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", - "cdc9ac4b2d": "editor", - "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "cdc9ac4b2d": "editor" }, "TerminalSearch": { "db234b7519": "Close", @@ -1635,7 +1609,8 @@ "worktreesHeader": "Worktrees", "recentWorktreesHeader": "Recent Worktrees", "settingsBadge": "Settings", - "actionBadge": "Action" + "actionBadge": "Action", + "paletteHostBadge": "Host: {{value0}}" }, "github": { "pr": { @@ -1817,11 +1792,7 @@ "015b4e607d": "Cancel", "e3bd59143c": "Insert", "f24783f470": "https://...", - "ec6310b731": "Use an http:// or https:// image URL.", - "b7e4a1c902": "Paste, drop, or click to add files", - "8f1c2d4e6a": "Nothing to preview", - "c91f0a2b14": "Write", - "d82b1e3f05": "Preview" + "ec6310b731": "Use an http:// or https:// image URL." }, "IssueSourceSelector": { "d6aeb2012b": "Showing issues from", @@ -1895,47 +1866,6 @@ } } } - }, - "CloseReasonDropdown": { - "e1f2a3b4c5": "Choose close reason" - }, - "GitHubIssueCommentComposer": { - "082515176a": "Failed to add comment", - "9f88657c4e": "Issue closed", - "e9b7cb7d17": "Failed to close issue", - "bd3b4492a0": "Issue reopened", - "f2a8c1d903": "Failed to reopen issue", - "a1b2c3d4e5": "Add a comment", - "c5c117270e": "Add your comment here, be kind", - "f6a7b8c9d0": "Close issue", - "b1c2d3e4f5": "Reopen issue", - "0a73f59e85": "Send comment", - "bf43425540": "Comment" - }, - "GitHubWorkItemAssigneePopoverContent": { - "cddd9b04a7": "Loading assignees", - "a00830d3f7": "No users", - "4f8b6f2c1d": "Filter assignees..." - }, - "GitHubWorkItemLabelPopoverContent": { - "2aa9acdf34": "Edit labels on GitHub", - "cddd9b04a7": "Loading labels", - "de26e2eb06": "No labels", - "8b0d52ee3a": "Filter labels..." - }, - "githubIssueCloseReasons": { - "completed": { - "label": "Close as completed", - "description": "Done, closed, fixed, resolved" - }, - "notPlanned": { - "label": "Close as not planned", - "description": "Won't fix, can't repro, stale" - }, - "duplicate": { - "label": "Close as duplicate", - "description": "Duplicate of another issue" - } } }, "linear": { @@ -2211,8 +2141,7 @@ "c44659e09f": "Mobile driving", "7cffad954c": "Collapse", "3eed73394f": "Your keyboard is paused", - "faa367dc74": "This terminal is sized for your mobile app", - "54f7d6f69d": "Resize all terminals" + "faa367dc74": "This terminal is sized for your mobile app" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "Copy context", @@ -2448,9 +2377,7 @@ "77ac113df0": "Start {{value0}}: {{value1}}", "7b1c9d6ae1": "Run", "c781f992e4": "destructive", - "be8f0ff166": "Delete", - "f3a8c2d1e7": "Search quick commands...", - "b4e7f9a2c1": "No commands match" + "be8f0ff166": "Delete" }, "shell": { "icons": { @@ -2589,10 +2516,10 @@ "e9a5d3c2b1f0": "Kill {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "Manage SSH…", - "6e8a9a4242": "SSH Connections", - "d09ec41831": "SSH", - "fdc57e9970": "SSH connection status", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "Disconnect", "63f36455cc": "Connect", "bf07aee59e": "Disconnect failed", @@ -2602,12 +2529,16 @@ "fd9a3c600e": "error", "fbb3f9f05e": "conflict", "95e4ff5b4b": "pushing", - "63a2b965f6": "pulling" + "63a2b965f6": "pulling", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "Ports", "d1e1a7a6bf": "Resource Manager", - "24ac89df1a": "SSH Status", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi Usage", "8c86cd77b0": "OpenCode Go Usage", "c1df0d67ec": "Gemini Usage", @@ -2840,11 +2771,7 @@ "4f8368c272": "Orca worktrees only", "cfe2282ffa": "Unknown", "7765a4c3e1": "n/a", - "2d41fd45c6": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "2d41fd45c6": " • Last scan error: {{value0}}" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Reasoning", @@ -2893,11 +2820,7 @@ "bf6cf2d4dd": "Unknown", "ae255c3dba": "n/a", "247c93ca92": "• inferred pricing", - "8a6655f7a2": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "8a6655f7a2": " • Last scan error: {{value0}}" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2936,11 +2859,7 @@ "e04c58327c": "Orca worktrees only", "362231082f": "Unknown", "8095a63426": "n/a", - "6cc7782458": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "6cc7782458": " • Last scan error: {{value0}}" }, "ShareUsageButton": { "7d6b25323d": "Share on X", @@ -3059,22 +2978,6 @@ "d9a4b3e2f1c5": "events" } } - }, - "UsageBreakdownSection": { - "7765a4c3e1": "n/a", - "247c93ca92": "• inferred pricing" - }, - "UsageSessionsTable": { - "1afc25eb06": "Turns", - "0f03975d59": "Events", - "21ea00bfa8": "Cache", - "e0b988599d": "Total", - "01476891c7": "Last active", - "c17bed0416": "Project", - "f6a2c8d019": "Model", - "faf3444859": "Input", - "a8b7487ff7": "Output", - "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3159,7 +3062,7 @@ "7d1f51678c": "Add Project", "7726a16374": "Cancel", "046751dbfb": "Add this folder as a separate Orca project.", - "e643b30398": "Remote project added" + "e643b30398": "Project added on SSH host" }, "AddRepoCreateStep": { "0ae45b8238": "my-project", @@ -3172,22 +3075,21 @@ "685b5eefe1": "{{kind}} in {{parent}}", "2a762f3b19": "Checking Git on this host...", "fe1e616c5b": "Git isn't installed, so a plain folder is the default.", - "c234df77f7": "Choose or enter a server parent folder before creating.", + "c234df77f7": "Choose or enter a host parent folder before creating.", "3a13f6e88b": "location not selected", - "6ed14c0281": "server folder not selected", + "6ed14c0281": "host folder not selected", "5e97f0c4b9": "Project created", "2c12db1511": "Project already added", - "875dda0995": "Enter a server parent path.", + "875dda0995": "Enter a host parent path.", + "ssh_parent_manual": "Enter an SSH parent path.", "45b7c26034": "Create project", - "85085d74d2": "Creating…", - "createProjectTitle": "Create project", - "createProjectDescription": "Create a local Git repo and first workspace.", - "projectNameLabel": "Project name", - "createsGitRepoHelp": "Git repo:", - "parentFolderLabel": "Parent folder", - "browseParentFolder": "Browse", - "gitRequiredError": "Git is required to create a project.", - "createAction": "Create" + "85085d74d2": "Creating…" + }, + "AddRepoHostSelector": { + "host": "Host", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" }, "AddRepoNestedImportStep": { "496f68cf8c": "Scanning repositories. Click to stop.", @@ -3195,6 +3097,7 @@ "2f8298f3c3": "Stop scan", "c157f31a95": "Import as group", "40199ef7b3": "Group name", + "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", "787412361a": "What is a group name?", "5f857ba8e6": "in", "4df0d08cc5": "Found", @@ -3203,10 +3106,11 @@ "cf9d382ca1": "Import", "220dd32d83": "Scanning...", "fb33359f69": "Is this a monorepo?", - "d75170194e": "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder.", - "39d51212cc": "Group name", + "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", + "39d51212cc": "Monorepo name", + "e907ec8935": "What is a monorepo name?", "aa0247680d": "No, import separately", - "a0bc4d1f8e": "Import as group", + "a0bc4d1f8e": "Yes, import as monorepo", "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", @@ -3215,35 +3119,36 @@ "AddRepoRemoteStep": { "5b205b5281": "Stop scan", "6680289908": "/home/user/project", - "ef410aa881": "Remote path", + "ef410aa881": "Host path", "0416bde073": "Add in Settings", "df6fbcf880": "No SSH targets configured.", "44637f43bd": "SSH target", "80557be85a": "Choose a connected SSH target and enter the path to a Git repository.", - "91b93a90a4": "Open remote project", + "91b93a90a4": "Open project on SSH host", "007651bdf9": "Navigate to a directory and click Select to choose it.", "dd3ff65486": "Browse remote filesystem", - "36d427bb66": "Add remote project", - "35831a7312": "Adding..." + "36d427bb66": "Add project on SSH host", + "35831a7312": "Adding...", + "lockedDescription": "Enter the path to a Git repository on {{value0}}." }, "AddRepoServerStartStep": { "ae990c86a0": "Back to add options", "e1710bf831": "Open as Folder", "8da4d1a5be": "Add Git Project", - "ac66a3ed2d": "Browse server filesystem", + "ac66a3ed2d": "Browse host filesystem", "92d25420a0": "/home/user/project", - "867692f505": "Server path", - "423b5d3d31": "Add a Git repository or folder that already exists on the selected runtime server.", - "3d0c035483": "Open server project", - "438493f214": "Or enter a server path manually", + "867692f505": "Host path", + "423b5d3d31": "Add a Git repository or folder that already exists on the selected host.", + "3d0c035483": "Open host project", + "438493f214": "Or enter a host path manually", "6b9958492a": "Want to import many repos at once? Browse to the parent folder.", "d40d751517": "New repo or folder", - "a81ffa0a99": "Create on server", + "a81ffa0a99": "Create on host", "a2ea37d549": "Remote Git repository", "47759c9491": "Clone from URL", "516187414c": "Existing project or folder", - "0adf083af7": "Browse server", - "8efa930eb5": "Add another project from the selected runtime server.", + "0adf083af7": "Browse host", + "8efa930eb5": "Add another project from the selected host.", "39bd249b3a": "Add a project", "0f8aba944c": "Navigate to a directory and click Select to choose it." }, @@ -3260,7 +3165,7 @@ }, "AddRepoSteps": { "569326d9cc": "Choose folder", - "a93ef169b5": "Browse server filesystem", + "a93ef169b5": "Browse host filesystem", "2ce3f6edf8": "/path/to/destination", "04a4c4e84a": "Clone location", "b698a4a29d": "https://github.com/user/repo.git", @@ -3268,10 +3173,12 @@ "5b2ea674b1": "Enter the Git URL and choose where to clone it.", "c05f88a31f": "Clone from URL", "fe8e629fe3": "Navigate to a directory and click Select to choose it.", - "df8b0e6c22": "Remote project added", + "df8b0e6c22": "Project added on SSH host", "3e64e8a70d": "Connection failed", "32a7256d85": "Clone", - "69f5b5380d": "Cloning..." + "69f5b5380d": "Cloning...", + "cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.", + "cloneParentFolder": "Parent folder" }, "AutoRenameFailedDialog": { "aed1623b1e": "Close", @@ -3286,7 +3193,7 @@ "95548e33bf": "Choose parent folder...", "632b456b1b": "Change", "afaf54f245": "Change parent folder", - "f520f83a97": "Browse server filesystem", + "f520f83a97": "Browse host filesystem", "2a20a603a3": "/home/user/projects", "134e37f711": "Location", "b589b77997": "Navigate to a directory and click Select to choose it." @@ -3334,7 +3241,7 @@ "e52454b7f6": "Open as Folder", "05b33a17a9": "Cancel", "8fba4b8cbb": "This folder isn't a Git repository. You'll have the editor, terminal, and search, but Git-based features won't be available.", - "c49fb13492": "Failed to add remote folder" + "c49fb13492": "Failed to add folder on this host" }, "OrcaYamlTrustDialog": { "f3e2b868fb": "Run hooks", @@ -3380,7 +3287,7 @@ "9e060f5815": "Select folder", "f8b1deb1a4": "Cancel", "51001182e3": "Empty directory", - "971d85cc84": "Opens as a remote project · {{value0}}", + "971d85cc84": "Opens as a project on this host · {{value0}}", "00c4235c10": "No matches for '{{value0}}'" }, "RemoveFolderDialog": { @@ -3523,7 +3430,21 @@ "ed1611b65b": "Hide sleeping", "82594419ba": "Filters" }, + "sidebarHostOptions": { + "3e102f111c": "All hosts", + "visibleHostsCount": "{{value0}} hosts" + }, + "SidebarHostScopeStrip": { + "scopedTo": "{{value0}} visible", + "backToAll": "All hosts" + }, "SidebarWorkspaceOptionsMenu": { + "hosts": "Hosts", + "allHostsDetail": "Show every host", + "configuredSshHost": "Configured SSH", + "projectSshHost": "Project SSH", + "activeRuntimeHost": "Active server", + "projectRuntimeHost": "Project server", "95c9754653": "Agent activity layout", "3d4b9c4997": "Hover", "ba87080fb7": "Show properties", @@ -3531,6 +3452,7 @@ "09faabd875": "Project order", "7bada3b1ab": "Sort by", "dc0bb670bc": "Group by", + "631b97eea9": "Host scope", "9919ae1082": "Workspace options", "bc96dbd041": "Workspace options ({{value0}})", "af9249c505": "Most recent workspace activity", @@ -3564,11 +3486,7 @@ "376bed88e5": "The connection to the remote host encountered an error.", "4afcca1d24": "Reconnect", "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting...", - "disconnected": "This remote repository is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." + "cb5938ae79": "Reconnecting..." }, "SshTargetRow": { "4677394048": "Connecting…", @@ -3627,7 +3545,7 @@ "01f45d3d8a": "sidebar", "93aebe4529": "Folder", "0d224eff10": "Primary worktree", - "ca74db7550": "Remote project via SSH", + "ca74db7550": "Project on SSH host", "021538e1d1": "SSH disconnected" }, "WorktreeCardAgents": { @@ -3698,6 +3616,9 @@ "250de158fd": "Remove Workspace" }, "WorktreeList": { + "7a8b9c0d1e": "Update required", + "hostAuthNeeded": "Authentication needed", + "hostDisconnected": "Disconnected", "d880ea0744": "Create a group and move this project into it.", "bc1460beb3": "Update the group name shown in the sidebar.", "13757c053c": "New Project Group", @@ -3790,11 +3711,12 @@ "5f9ffac036": "Clone a remote Git repository", "7edb8ebe24": "Clone from URL", "a6c20dca96": "Open a project from an SSH target", - "3d162cc76f": "Remote project", + "sshCreateUnavailable": "Not available for SSH hosts yet", + "3d162cc76f": "Project on SSH host", "fb4fc5380e": "Local project, Git repo, or folder with many repos", "2281fdc8c7": "Browse folder", - "createProjectTitle": "Create project", - "createGitProjectDescription": "Create a local Git repository" + "sshBrowseTitle": "Open project on SSH host", + "sshBrowseDescription": "Existing Git repository or folder on this SSH host" } } } @@ -3845,7 +3767,7 @@ "drop": { "669e12dd97": "Local folders and Git repositories", "ffc769ca29": "Drop folder to add project", - "740e8d0d46": "Use Add Project for server paths", + "740e8d0d46": "Use Add Project for host paths", "e344666fb8": "Server runtime active", "d0f8943f8b": "Preparing the project add flow", "18d3cf40e9": "Checking folder" @@ -3862,10 +3784,10 @@ }, "useAddRepoCloneFlow": { "4d0013cc93": "Repository cloned", - "0dc4d1b657": "Enter a server path for the clone destination." + "0dc4d1b657": "Enter a host path for the clone destination." }, "useAddRepoLocalFolderFlow": { - "7ab10e4974": "Use a server path to add projects from a remote runtime." + "7ab10e4974": "Use a host path to add projects from a remote host." }, "useAddRepoNestedImportFlow": { "680cac2c82": "{{value0}} failed", @@ -3875,7 +3797,7 @@ "useSidebarProjectDrop": { "f34a286c0d": "Could not add dropped folder.", "451a4638db": "Drop a folder to add it as a project.", - "5ccb56c7be": "Use Add Project to enter a server path.", + "5ccb56c7be": "Use Add Project to enter a host path.", "849ef13dc0": "Local folder drops are unavailable for server runtimes.", "c0315153d1": "Drop one folder at a time." }, @@ -3940,6 +3862,39 @@ "index": { "b826a98b6f": "busy" }, + "HostRemoveDialog": { + "1a2b3c4d5e": "Removed {{value0}}", + "2b3c4d5e6f": "Failed to remove host", + "3c4d5e6f7a": "Remove {{value0}}?", + "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", + "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", + "6f7a8b9c0d": "Cancel", + "7a8b9c0d1e": "Open settings", + "8b9c0d1e2f": "Remove host" + }, + "HostRenameDialog": { + "1a2b3c4d5e": "Rename host", + "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", + "3c4d5e6f7a": "Display name", + "4d5e6f7a8b": "Reset to default", + "5e6f7a8b9c": "Cancel", + "6f7a8b9c0d": "Save" + }, + "HostSectionHeaderMenu": { + "5b8b4b6a01": "Update server required", + "9b3c1d2e44": "Update client required", + "2c29e2de68": "Connection failed", + "bf07aee59e": "Disconnect failed", + "7f1a2b3c4d": "{{value0}} is reachable", + "4f2c8a9b10": "Host actions for {{value0}}", + "6b7c8d9e10": "Host actions", + "8d1e2f3a4b": "Rename…", + "63f36455cc": "Reconnect", + "59b553e2aa": "Disconnect", + "2d3e4f5a6b": "Check connection", + "3c4d5e6f7a": "Manage host…", + "6e7f8a9b0c": "Remove host…" + }, "LinearAgentSkillSetupPrompt": { "missingCliAndSkill": "Orca CLI and Linear agent skill are missing.", "modalTitle": "Enable Linear ticket access", @@ -3982,15 +3937,10 @@ "connectFailed": "Failed to connect to project.", "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", "title": "Create Folder Workspace", - "create": "Create workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" - }, - "ProjectOrderManualDefaultNotice": { - "a1f4c2d8e0": "Manual project order is now the default", - "822ff300ad": "Dismiss", - "b7e3a91c4f": "Drag project headers to reorder, or switch to", - "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -4222,19 +4172,7 @@ "7d26ccabe8": "Dark", "fb0e0b4453": "System", "932ff1fbff": "Theme", - "0f28e7b30c": "Choose how Orca looks in the app window.", - "leftSidebarAppearance": { - "title": "Left Sidebar Appearance", - "description": "Make the left sidebar match your terminal, stay default, or use a tint.", - "rowDescription": "Make the left sidebar match your terminal, stay default, or use a tint.", - "default": "Default", - "matchTerminal": "Match Terminal", - "tinted": "Tinted", - "tintColor": "Sidebar Tint", - "tintColorDescription": "The color mixed into the left sidebar surface.", - "tintOpacity": "Tint Strength", - "tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar." - } + "0f28e7b30c": "Choose how Orca looks in the app window." }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -4252,7 +4190,7 @@ "e784ea62dc": "Advanced", "d9b65054ef": ") to a short name summarizing the task. Only branches Orca named itself are renamed, and never after they have been pushed.", "12ea4a408d": "When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.", - "ef787db0e3": "Auto-rename branch & worktree", + "ef787db0e3": "Auto-Rename Branch", "6a051586d2": "Rename the auto-generated branch based on the work once an agent starts.", "ec3e0c388e": "Save", "cfd82406dd": "Saving...", @@ -4309,6 +4247,10 @@ "612f7f6861": "Failed to create profile.", "8f22b7580d": "Profile \"{{value0}}\" created.", "8481ee0331": "New Browser Profile", + "c0f85056d9": "Browser profiles on this Orca server.", + "86b7c83fee": "This computer", + "6480776a03": "Browser profiles for the selected host.", + "5e19a692f7": "Host", "6f2584b39e": "Add Profile", "e4aaf8051b": "toolbar menu.", "cd47bc9622": "Select a default profile for new browser tabs. Import cookies and switch profiles per-tab via the", @@ -4391,8 +4333,6 @@ "8671e406f0": "Cancel", "a4aafe46e3": "Target path:", "e8012c03a1": "Enables agents to use Orca workspace, terminal, and progress commands.", - "cliSkillTerminalTitle": "CLI skill setup", - "cliSkillTerminalAria": "CLI skill install terminal", "6053cf736c": "CLI skill", "36a6f919ba": "Give agents Orca-aware workspace, terminal, and progress workflows.", "04873eea3e": "Agent skills", @@ -5081,8 +5021,7 @@ "9bedd2a6e5": "Enables agents to hand off context and coordinate work through Orca.", "07641b9768": "Orchestration skill", "2aacdb0517": "Coordinate coding agents across handoffs, worktree handovers, and child-agent work.", - "191ac34567": "Agent Orchestration", - "thisDevice": "This device" + "191ac34567": "Agent Orchestration" }, "OrchestrationSetupCard": { "e7d2a5146c": "Enables agents to hand off context and coordinate work through Orca.", @@ -5293,7 +5232,38 @@ "0909e5d650": "Remove Project", "ee5a290616": "Opened as folder. Git features are unavailable for this workspace.", "323debba71": "Type:", - "499a437335": "Identity" + "availableHosts": "Available Hosts", + "availableHostsDescription": "Hosts where this project is set up.", + "availableHostsHelp": "Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.", + "viewingHost": "Viewing host", + "currentSetup": "Current", + "hostSetupStateReady": "Ready", + "hostSetupStateNotSetUp": "Not set up", + "hostSetupStateSettingUp": "Setting up", + "hostSetupStateError": "Error", + "hostSetupStateUnsupported": "Unsupported", + "setupPathPending": "Path pending", + "openSetup": "Open", + "removeSetup": "Remove", + "hostSetupBlockedVersion": "Orca server version is incompatible", + "hostSetupMissingCapability": "Update Orca on this host to set up projects", + "setupProjectOnHost": "Set up on another host", + "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", + "setupExistingFolder": "Import existing folder", + "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", + "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", + "cloneUrlPlaceholder": "Repository URL", + "cloneDestinationPlaceholder": "/destination/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "settingUpHost": "Importing...", + "setupHost": "Import", + "cloningHost": "Cloning...", + "cloneHost": "Clone", + "creatingPendingSetup": "Creating...", + "createPendingSetup": "Track setup", + "499a437335": "Identity", + "hostSetupCheckingCapability": "Checking host capabilities" }, "RepositorySourceControlAiActionRows": { "548a6e1281": "Command template", @@ -5357,8 +5327,15 @@ "bb90dd6487": "Remove Server", "d2e00809e4": "Switch", "05e0fc3ebf": "Switch to", - "b2290ed203": "Orca will close remote terminals and browser tabs from the current server before loading projects from the next server.", + "b2290ed203": "Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.", "d570c35a99": "Switch Server", + "f3a3d6d834": "{{value0}} capabilities", + "0ef838094a": "Protocol {{value0}}", + "9a91c4a0eb": "Compatible", + "86ed75bec8": "Update server", + "62ac182a27": "Update client", + "c8791efc45": "Status unavailable", + "5120beaac6": "Checking…", "84b9b2be05": "Create a revocable access grant so a browser or another Orca client can connect.", "6e1280ca55": "Share this Orca server", "9a3758d983": "No saved servers.", @@ -5385,8 +5362,8 @@ "e6410d72c3": "Failed to load runtime environments.", "6ef71985da": "No endpoint", "ed3e3f069d": "This removes the saved server from Orca. It does not change the active server.", - "b2fda48c39": "Removing the active server disconnects this browser and closes remote terminals and browser tabs for that server.", - "9f7665a01b": "Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.", + "b2fda48c39": "Removing the active server disconnects this browser from that host. Existing host sessions are left alone.", + "9f7665a01b": "Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.", "3595fd1948": "New Link", "54dee18f5c": "Hide Form", "8cf8790697": "Saved servers route this browser through a paired Orca runtime.", @@ -5437,9 +5414,9 @@ "65660d4548": "macOS Permissions", "c6c01ac209": "Control terminals and agents from your phone.", "c40dadaac8": "Mobile", - "c2ee313198": "Remote SSH hosts for files, terminals, and git.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH Hosts", - "b5ee17826b": "Switch between local desktop mode and paired remote Orca runtimes.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Connect this browser to a saved Orca server.", "bd0181eeca": "Remote Orca Servers", "8acf3f22e0": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", @@ -5489,14 +5466,14 @@ "43b68e10f0": "You have unsaved Git AI Author changes. Leaving will discard them.", "17bdee4ff1": "Discard unsaved Git AI Author changes?", "084d8fac5b": "Privacy & Security", - "23931df7e8": "Remote Access", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "Interface", "e1578cd4bc": "Workflows", "9abb9be3bc": "Set Up", "23c6874fdf": "AI Capabilities", "2309068a6f": "destructive", - "65358016ea": "Discard", - "thisDevice": "This device" + "65358016ea": "Discard" }, "SettingsFormControls": { "42a4d15a30": "No matching fonts.", @@ -6247,8 +6224,8 @@ "f4997e0f8a": "connection", "a278406ed5": "remote", "6ecad74eb3": "ssh", - "f17d66d0d2": "Show the active SSH connection status in the status bar.", - "57fb424c56": "SSH Status", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "moonshot", "de586def95": "subscription", "00a028f25f": "usage", @@ -6277,10 +6254,6 @@ "locale": "locale", "i18n": "i18n", "translation": "translation" - }, - "leftSidebarAppearance": { - "title": "Left Sidebar Appearance", - "description": "Make the left sidebar match your terminal, stay default, or use a tint." } } }, @@ -6305,7 +6278,7 @@ "55a1860e47": "rename", "9319bd9827": "branch", "ea94b9da8a": "Rename the auto-generated branch based on the work once an agent starts.", - "427f2cd1eb": "Auto-rename branch & worktree" + "427f2cd1eb": "Auto-Rename Branch" } } } @@ -7124,6 +7097,12 @@ "a47f51127e": "source control", "6cc5c65e64": "Project-specific git generation overrides.", "eec3995dc6": "Git AI Author", + "availableHosts": "Available Hosts", + "availableHostsDescription": "Hosts where this project is set up.", + "host": "host", + "ssh": "ssh", + "remote": "remote", + "vm": "vm", "cc876ca5f2": "repository", "6469de5368": "project", "3067595d82": "delete", @@ -7535,6 +7514,13 @@ } } }, + "WorkspaceDirectorySetting": { + "1a2b3c4d5e": "Client default", + "2b3c4d5e6f": "Apply to", + "3c4d5e6f7a": "Overrides client default", + "4d5e6f7a8b": "Inherits the client default", + "5e6f7a8b9c": "Reset" + }, "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", @@ -7598,16 +7584,7 @@ "eae4a9f16b": "Add Linear access to browse and link issues.", "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", - "disconnect_all": "Disconnect all", - "linearSkillTitle": "Linear agent skill", - "linearSkillDescription": "Install the host agent skill that agents use for richer linked Linear task handoffs.", - "linearSkillTerminalTitle": "Install Linear agent skill", - "linearSkillTerminalAria": "Linear agent skill installer terminal", - "linearSkillInstall": "Install CLI & Skill", - "linearSkillOptionalHint": "Optional next step: install the Linear agent skill for ticket-aware agent handoffs.", - "linearSkillDismissHint": "Dismiss optional Linear agent skill setup note", - "linearSkillWslDescription": "Install the WSL agent skill that agents use for richer linked Linear task handoffs.", - "linearSkillWslLabel": "WSL default" + "disconnect_all": "Disconnect" } } } @@ -7653,19 +7630,6 @@ } } } - }, - "computerUseSummary": { - "permissionsRequired": "{{value0}} permission{{value1}} required before agents can operate app windows.", - "checkingTitle": "Checking Computer Use access.", - "checkingDescription": "Orca is checking macOS privacy permissions for the Computer Use helper.", - "unavailableTitle": "Computer Use is unavailable.", - "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", - "readyTitle": "Computer Use is ready.", - "readyDescription": "Agents can inspect and operate app windows when you ask.", - "permissionsTitle": "Finish setup to use local apps." - }, - "computerUseSkillRuntime": { - "thisDevice": "This device" } }, "right": { @@ -7713,9 +7677,7 @@ "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", - "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", - "b4f3ec62a1": "More {{value0}} actions", - "a9d7c128e4": "unlink {{value0}}" + "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -8078,8 +8040,7 @@ "31f6d46278": "Unresolved", "2c417432b7": "Resolved locally", "f3a8b2c1d0e5": "Enter a {{value0}} title.", - "e2b7a1c0d9f4": "Failed to create {{value0}}", - "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI." + "e2b7a1c0d9f4": "Failed to create {{value0}}" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", @@ -8159,7 +8120,7 @@ "74c6885b8a": "More comment actions", "cbcc4ab3db": "Showing first 100 checks", "0dca6bfab5": "Open check details", - "991f50c7e4": "No checks reported yet", + "991f50c7e4": "No checks configured", "9ad98f2a17": "pending", "5e52f4ef7f": "failing", "02ca4f9074": "passing", @@ -8207,7 +8168,7 @@ "ae8a04ef17": "Conflict file details are unavailable", "73d0675356": "Refreshing conflict details…", "5dc3af25c0": "Select comment", - "d7a2f9c401": "Send all unresolved", + "d7a2f9c401": "Send unresolved {{value0}} comments", "d91f2a6c39": "Send {{value0}} queued comments", "a6de3e5a20": "Clear queued comments", "49ea0937e4": "Add comment to resolve list", @@ -8579,10 +8540,7 @@ "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", "1ecfb490ac": "Computer Use", "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." + "ea85d9e628": "Agent Browser Use" }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", @@ -8666,9 +8624,9 @@ "RepoStep": { "e8fdb36338": "Scanning repositories. Click to stop.", "b7c4da0504": "SSH? Set hosts up in Settings", - "c33b190ca3": "Server paths only", + "c33b190ca3": "Host paths only", "7b679207e4": "Workspace", - "24c7c8696c": "Clone into server path", + "24c7c8696c": "Clone into host path", "7932e95f68": "Clone", "955134915e": "git@github.com:org/repo.git", "288d8444b7": "Paste an HTTPS or SSH URL.", @@ -8679,14 +8637,14 @@ "e8214aa632": "Open as Folder", "3863747c56": "Add Git Project", "2ebbc26343": "/home/user/project", - "466108ab89": "Enter a path that exists on the runtime server.", - "8cab104e3c": "Open a server project", + "466108ab89": "Enter a path that exists on the selected host.", + "8cab104e3c": "Open a project on this host", "2d20200346": "Import repositories", "27ca610db1": "Back", "cecd6593fa": "Scanned folder:", "c7af322fc3": "Stop scanning", "c3d9d44ca2": "Stop scan", - "cf23006ba7": "Runtime server", + "cf23006ba7": "Selected host", "7ec3f48820": "/home/user", "2e6438dd34": "{{value0}}Found {{value1}} {{value2}} in this folder." }, @@ -8724,7 +8682,7 @@ } }, "AgentFeatureSetupStep": { - "97dcdc010f": "Install CLI & Skills" + "97dcdc010f": "Enable capabilities" } }, "new": { @@ -8760,6 +8718,10 @@ "3e8bb1176a": "Connect Linear in Settings to search issues.", "69ce292138": "linear", "9c004911c3": "gitlab" + }, + "ProjectHostSetupCombobox": { + "empty": "No hosts are ready for this project.", + "placeholder": "Choose host" } } }, @@ -8997,9 +8959,7 @@ "3a59452a67": "Skill command copied and inserted below for review.", "c605f51f2b": "Capability setup ready", "1aa657d8f4": "Some capability setup needs attention", - "c89534cbe9": "Install CLI & Skills", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Recommended when agents work from linked Linear tasks." + "c89534cbe9": "Install CLI & Skills" }, "AiCommitPrSettingsCard": { "8d4152701a": "e.g. ollama run llama3.1 {{value0}}", @@ -9105,11 +9065,7 @@ "3c4adfd821": "fix login race condition", "56a0271428": "Isolated workspaces", "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." + "ac51c061e2": "codex" }, "FeatureWallBody": { "25ec5356d6": "Setup" @@ -9182,9 +9138,7 @@ "6e3f5223c5": "Explorer", "ab2901bce6": "Checks", "d7f80060ca": "Source Control", - "8e715588e4": "Search", - "a6c8b9e32f": "Checks passed", - "f4d5e1a7b2": "3 checks" + "8e715588e4": "Search" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Create PR", @@ -10608,44 +10562,6 @@ "8388bdea2b": "Connect Jira site" } } - }, - "link": { - "routing": { - "preference": { - "dialog": { - "badge": "Terminal link", - "preview": "Preview", - "title": "Open terminal links in Orca's browser?", - "description": "Use Orca's browser for terminal links, or keep your system browser.", - "orca": { - "button": "Open in Orca", - "note": "Orca can use imported cookies for logged-in sites." - }, - "settings": { - "note": "Change this later in Settings → Browser." - }, - "system": { - "button": "Use system browser" - }, - "link": { - "label": "Link" - }, - "shortcut": { - "note": { - "prefix": "When links open in Orca,", - "suffix": "click opens system browser once." - } - }, - "keep": { - "title": "Keep terminal links in Orca's browser?", - "description": "Or use your system browser by default.", - "orca": { - "button": "Keep Orca" - } - } - } - } - } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a68d254f968..70061fcf189 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "Muestra el token de Gemini y el uso de costos para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el token de OpenCode Go y el uso de costos para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", - "sshToggleDescription": "Muestra la conexión SSH activa. Solo es visible una vez que se configura un destino SSH.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haga clic en él para ver la CPU, la memoria, las sesiones, los controles del demonio y los análisis del disco del espacio de trabajo.", "portsToggleDescription": "Mostrar puertos del espacio de trabajo en vivo. Haga clic en él para puertos con ámbito de espacio de trabajo y oyentes externos." } @@ -540,7 +540,7 @@ "d91ae31fbd": "Permisos de macOS", "95a1886d94": "Controla terminales y agents desde tu teléfono.", "1cd25673df": "Móvil", - "31e57d1c70": "Hosts SSH remotos para archivos, terminales y git.", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "Anfitriones SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "¿Matar a {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "Administrar SSH…", - "6e8a9a4242": "Conexiones SSH", - "d09ec41831": "SSH", - "fdc57e9970": "Estado de la conexión SSH", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "Desconectar", "63f36455cc": "Conectar", "bf07aee59e": "Falló la desconexión", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "error", "fbb3f9f05e": "conflicto", "95e4ff5b4b": "emprendedor", - "63a2b965f6": "tracción" + "63a2b965f6": "tracción", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "Puertos", "d1e1a7a6bf": "Administrador de recursos", - "24ac89df1a": "Estado SSH", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Uso de Kimi", "8c86cd77b0": "Uso de OpenCode Go", "c1df0d67ec": "Uso de Gemini", @@ -5400,9 +5404,9 @@ "65660d4548": "Permisos de macOS", "c6c01ac209": "Controla terminales y agents desde tu teléfono.", "c40dadaac8": "Móvil", - "c2ee313198": "Hosts SSH remotos para archivos, terminales y git.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "Anfitriones SSH", - "b5ee17826b": "Cambie entre el modo de escritorio local y los tiempos de ejecución remotos de Orca emparejados.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Conecte este navegador a un servidor Orca guardado.", "bd0181eeca": "Servidores remotos de Orca", "8acf3f22e0": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", @@ -5452,7 +5456,8 @@ "43b68e10f0": "No has guardado los cambios de autor de Git AI. Irse los descartará.", "17bdee4ff1": "¿Descartar los cambios de autor de Git AI no guardados?", "084d8fac5b": "Privacidad y seguridad", - "23931df7e8": "Acceso remoto", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "Interfaz", "e1578cd4bc": "Flujos de trabajo", "9abb9be3bc": "Configuración", @@ -6210,8 +6215,8 @@ "f4997e0f8a": "conexión", "a278406ed5": "remoto", "6ecad74eb3": "ssh", - "f17d66d0d2": "Muestra el estado de la conexión SSH activa en la barra de estado.", - "57fb424c56": "Estado SSH", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "disparo a la luna", "de586def95": "suscripción", "00a028f25f": "uso", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 86f92eae24d..0d57c8941c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", - "sshToggleDescription": "アクティブな SSH 接続を表示します。 SSH ターゲットが設定された場合にのみ表示されます。", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS のアクセス許可", "95a1886d94": "スマートフォンから terminals と agents を操作", "1cd25673df": "モバイル", - "31e57d1c70": "ファイル、terminals、git 用のリモート SSH ホスト。", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSHホスト", "40d80bad8a": "ベータ", "de0c2907a1": "リモート Orca サーバー", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "{{value0}} を終了しますか?" }, "SshStatusSegment": { - "3ad70e0365": "SSHを管理…", - "6e8a9a4242": "SSH接続", - "d09ec41831": "SSH", - "fdc57e9970": "SSH接続状態", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "切断", "63f36455cc": "接続", "bf07aee59e": "切断に失敗しました", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "エラー", "fbb3f9f05e": "競合", "95e4ff5b4b": "押す", - "63a2b965f6": "引っ張る" + "63a2b965f6": "引っ張る", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "ポート", "d1e1a7a6bf": "リソースマネージャー", - "24ac89df1a": "SSHステータス", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go の使用法", "c1df0d67ec": "Gemini 使用量", @@ -5422,9 +5426,9 @@ "65660d4548": "macOS のアクセス許可", "c6c01ac209": "スマートフォンから terminals と agents を操作", "c40dadaac8": "モバイル", - "c2ee313198": "ファイル、terminals、git 用のリモート SSH ホスト。", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSHホスト", - "b5ee17826b": "ローカル デスクトップ モードとペアリングされたリモート Orca ランタイムを切り替えます。", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "このブラウザを保存された Orca サーバーに接続します。", "bd0181eeca": "リモート Orca サーバー", "8acf3f22e0": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", @@ -5474,7 +5478,8 @@ "43b68e10f0": "Git AI Author の変更が保存されていません。離れるとそれらは破棄されます。", "17bdee4ff1": "保存されていない Git AI Author の変更を破棄しますか?", "084d8fac5b": "プライバシーとセキュリティ", - "23931df7e8": "リモートアクセス", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "インターフェース", "e1578cd4bc": "ワークフロー", "9abb9be3bc": "セットアップ", @@ -6232,8 +6237,8 @@ "f4997e0f8a": "接続", "a278406ed5": "リモート", "6ecad74eb3": "ssh", - "f17d66d0d2": "アクティブな SSH 接続ステータスをステータス バーに表示します。", - "57fb424c56": "SSHステータス", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "ムーンショット", "de586def95": "サブスクリプション", "00a028f25f": "使用法", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7d93e37aac6..aa975a24897 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", - "sshToggleDescription": "활성 SSH 연결을 표시합니다. SSH 대상이 구성된 후에만 표시됩니다.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. CPU, 메모리, 세션, 데몬 제어 및 워크스페이스 디스크 검색을 위해 클릭하세요.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS 권한", "95a1886d94": "휴대폰에서 terminals과 agents를 제어하세요.", "1cd25673df": "모바일", - "31e57d1c70": "파일, terminals, Git을 위한 원격 SSH 호스트입니다.", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSH 호스트", "40d80bad8a": "베타", "de0c2907a1": "원격 Orca 서버", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "{{value0}}을(를) 종료할까요?" }, "SshStatusSegment": { - "3ad70e0365": "SSH 관리…", - "6e8a9a4242": "SSH 연결", - "d09ec41831": "SSH", - "fdc57e9970": "SSH 연결 상태", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "연결 해제", "63f36455cc": "연결", "bf07aee59e": "연결 해제 실패", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "오류", "fbb3f9f05e": "충돌", "95e4ff5b4b": "푸시 중", - "63a2b965f6": "가져오는 중" + "63a2b965f6": "가져오는 중", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "포트", "d1e1a7a6bf": "리소스 관리자", - "24ac89df1a": "SSH 상태", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 사용량", "8c86cd77b0": "OpenCode Go 사용량", "c1df0d67ec": "Gemini 사용량", @@ -5385,9 +5389,9 @@ "65660d4548": "macOS 권한", "c6c01ac209": "휴대폰에서 terminals과 agents를 제어하세요.", "c40dadaac8": "모바일", - "c2ee313198": "파일, terminals, Git을 위한 원격 SSH 호스트입니다.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH 호스트", - "b5ee17826b": "로컬 데스크톱 모드와 페어링된 원격 Orca 런타임 간에 전환합니다.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "이 브라우저를 저장된 Orca 서버에 연결하세요.", "bd0181eeca": "원격 Orca 서버", "8acf3f22e0": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", @@ -5437,7 +5441,8 @@ "43b68e10f0": "저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.", "17bdee4ff1": "저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?", "084d8fac5b": "개인 정보 보호 및 보안", - "23931df7e8": "원격 액세스", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "인터페이스", "e1578cd4bc": "워크플로", "9abb9be3bc": "설정 시작", @@ -6195,8 +6200,8 @@ "f4997e0f8a": "연결", "a278406ed5": "원격", "6ecad74eb3": "SSH", - "f17d66d0d2": "상태 표시줄에 활성 SSH 연결 상태를 표시합니다.", - "57fb424c56": "SSH 상태", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "문샷", "de586def95": "신청", "00a028f25f": "용법", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 44e389dcc12..e53778f8188 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "显示Gemini Token 和成本使用情况。", "opencodeGoToggleDescription": "显示OpenCode Go Token 和成本使用情况。", "kimiToggleDescription": "Kimi 订阅", - "sshToggleDescription": "显示SSH 连接。仅在配置 SSH 目标后才可见。", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS 权限", "95a1886d94": "通过手机控制 terminal 和 Agent。", "1cd25673df": "手机端", - "31e57d1c70": "文件、terminals 和 git 的远程 SSH 主机。", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "终止 {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "管理 SSH...", - "6e8a9a4242": "SSH 连接", - "d09ec41831": "SSH", - "fdc57e9970": "SSH 连接状态", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "断开连接", "63f36455cc": "连接", "bf07aee59e": "断开连接失败", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "错误", "fbb3f9f05e": "冲突", "95e4ff5b4b": "推动", - "63a2b965f6": "拉" + "63a2b965f6": "拉", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "端口", "d1e1a7a6bf": "资源管理器", - "24ac89df1a": "SSH 状态", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go 使用量", "c1df0d67ec": "Gemini 使用情况", @@ -5385,9 +5389,9 @@ "65660d4548": "macOS 权限", "c6c01ac209": "通过手机控制 terminal 和 Agent。", "c40dadaac8": "手机端", - "c2ee313198": "文件、terminals 和 git 的远程 SSH 主机。", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH 主机", - "b5ee17826b": "在本地桌面模式和配对的远程 Orca 运行时之间切换。", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", "bd0181eeca": "远程 Orca 服务器", "8acf3f22e0": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", @@ -5437,7 +5441,8 @@ "43b68e10f0": "您有未保存的 Git AI Author 更改。离开将丢弃它们。", "17bdee4ff1": "放弃未保存的 Git AI Author 更改?", "084d8fac5b": "隐私与安全", - "23931df7e8": "远程访问", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "界面", "e1578cd4bc": "工作流程", "9abb9be3bc": "初始设置", @@ -6195,8 +6200,8 @@ "f4997e0f8a": "联系", "a278406ed5": "偏僻的", "6ecad74eb3": "SSH", - "f17d66d0d2": "在状态栏中显示活动的 SSH 连接状态。", - "57fb424c56": "SSH 状态", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "登月计划", "de586def95": "订阅", "00a028f25f": "用法", diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts index 65d6a702db0..bbbc0827ab8 100644 --- a/src/renderer/src/i18n/no-top-level-translate.test.ts +++ b/src/renderer/src/i18n/no-top-level-translate.test.ts @@ -78,5 +78,5 @@ describe('i18n import-time safety', () => { } expect(violations).toEqual([]) - }) + }, 15_000) }) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index dd30bc579ce..3307812055e 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,6 +1,7 @@ import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' export { @@ -47,7 +48,11 @@ export async function sendNotesToActiveAgentSession({ return { status: 'no-active-terminal' } } - const runtimeTarget = getActiveRuntimeTarget(state.settings) + // Route by the worktree's owner host so the agent terminal is found and driven + // on the host that actually runs it, not on the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(state, worktreeId) + ) const terminal = await findActiveRuntimeTerminal( runtimeTarget, worktreeId, diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 77a5e63dffd..f1b36ae3078 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -7,6 +7,10 @@ import { import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getSettingsForWorktreeRuntimeOwner, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../shared/types' @@ -47,7 +51,7 @@ export type ActiveTerminalNoteTargetState = { runtimePaneTitlesByTabId?: Record<string, Record<number, string> | undefined> agentStatusByPaneKey?: Record<string, AgentStatusEntry | undefined> settings: Parameters<typeof getActiveRuntimeTarget>[0] -} +} & Pick<WorktreeRuntimeOwnerState, 'repos' | 'worktreesByRepo'> type ActiveAgentRuntimeProbeDescriptor = { key: string @@ -159,7 +163,11 @@ export function getActiveAgentRuntimeProbeDescriptor( if (!activePtyId) { return null } - const runtimeTarget = getActiveRuntimeTarget(state.settings) + // Route by the worktree's owner host so the probe targets the host that runs + // this worktree's agent terminal, not the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(state, worktreeId) + ) const runtimeKey = runtimeTarget.kind === 'environment' ? `env:${runtimeTarget.environmentId}` : 'local' return { diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 5b950aae0c5..c9c5f56b98d 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -1,12 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { pasteDraftWhenAgentReady, sendBracketedPasteToRunningAgent } from './agent-paste-draft' +import { + getSettingsForAgentTabRuntimeOwner, + pasteDraftWhenAgentReady, + sendBracketedPasteToRunningAgent +} from './agent-paste-draft' const testState = vi.hoisted(() => ({ appState: { settings: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, runtimePaneTitlesByTabId: {}, - tabsByWorktree: {} + tabsByWorktree: {} as Record<string, { id: string; title?: string }[]>, + repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], + worktreesByRepo: {} as Record<string, { id: string; repoId: string }[]> }, ptyObserver: null as ((data: string) => void) | null, unsubscribe: vi.fn(), @@ -53,6 +59,8 @@ describe('pasteDraftWhenAgentReady', () => { testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } testState.appState.runtimePaneTitlesByTabId = {} testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} testState.ptyObserver = null testState.unsubscribe.mockReset() testState.subscribeToPtyData.mockReset() @@ -261,6 +269,70 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('routes tab-owned paste writes through the worktree runtime owner', async () => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex' + }) + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + + await expect(promise).resolves.toBe(true) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'owner-runtime' }, + 'pty-1', + PASTED_ISSUE_URL + ) + }) + + it('routes legacy remote PTY readiness subscription through the tab owner', async () => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.ptyIdsByTabId = { 'tab-1': ['remote:terminal-handle'] } + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + testState.isRemoteRuntimePtyId.mockReturnValue(true) + testState.subscribeToRuntimeTerminalData.mockImplementation( + async ( + _settings: unknown, + _ptyId: string, + _clientId: string, + observer: (data: string) => void + ) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex' + }) + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + + await expect(promise).resolves.toBe(true) + expect(testState.subscribeToRuntimeTerminalData).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'owner-runtime' }, + 'remote:terminal-handle', + 'desktop:paste-ready:remote:terminal-handle', + expect.any(Function) + ) + }) + it('submits to an already running agent without waiting for readiness signals', async () => { const promise = sendBracketedPasteToRunningAgent({ ptyId: 'pty-1', @@ -286,6 +358,33 @@ describe('pasteDraftWhenAgentReady', () => { }) }) +describe('getSettingsForAgentTabRuntimeOwner', () => { + beforeEach(() => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} + }) + + it('falls back to focused settings when the tab is not mapped to a worktree', () => { + expect(getSettingsForAgentTabRuntimeOwner('missing-tab')).toEqual({ + activeRuntimeEnvironmentId: 'focused-runtime' + }) + }) + + it('uses the tab worktree owner when mapped', () => { + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + + expect(getSettingsForAgentTabRuntimeOwner('tab-1')).toEqual({ + activeRuntimeEnvironmentId: 'owner-runtime' + }) + }) +}) + async function flushMicrotasks(): Promise<void> { await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 6515144a38a..15419c2b18b 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -8,6 +8,8 @@ import { } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' import { waitForAgentReady } from './agent-ready-wait' +import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' +import type { GlobalSettings } from '../../../shared/types' // Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi / // OpenCode / Gemini / cursor-agent / copilot) treat the inserted text as a @@ -44,6 +46,20 @@ const BRACKETED_PASTE_QUIET_MS = 1500 // stuck launch doesn't pin a Promise forever. const READINESS_TIMEOUT_MS = 8000 +export function getSettingsForAgentTabRuntimeOwner( + tabId: string +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined { + const store = useAppStore.getState() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree ?? {})) { + if (tabs?.some((tab) => tab.id === tabId)) { + // Why: legacy remote PTY ids may not embed their runtime owner. The tab's + // worktree still identifies which host should receive readiness/send RPCs. + return getSettingsForWorktreeRuntimeOwner(store, worktreeId) + } + } + return store.settings +} + /** * Wait until the agent on `tabId` has rendered its input-accepting TUI, * then bracketed-paste `content` into its input buffer. By default the @@ -90,7 +106,8 @@ export async function pasteDraftWhenAgentReady(args: { return false } - const ready = await waitForInputBoxReady(ptyId, budget, readySignal) + const settings = getSettingsForAgentTabRuntimeOwner(tabId) + const ready = await waitForInputBoxReady(ptyId, budget, readySignal, settings) if (!ready) { // Why: fast-starting TUIs can emit the paste-ready escape sequence before // this sidecar subscription attaches. If process/title inspection says the @@ -106,6 +123,7 @@ export async function pasteDraftWhenAgentReady(args: { } return await sendBracketedPasteToAgent({ + settings, ptyId, content, submit: submit === true @@ -122,7 +140,12 @@ export async function submitPromptToAgentTab(args: { if (!ptyId) { return false } - return await sendBracketedPasteToAgent({ ptyId, content, submit: true }) + return await sendBracketedPasteToAgent({ + settings: getSettingsForAgentTabRuntimeOwner(tabId), + ptyId, + content, + submit: true + }) } export async function sendBracketedPasteToRunningAgent(args: { @@ -133,12 +156,12 @@ export async function sendBracketedPasteToRunningAgent(args: { } async function sendBracketedPasteToAgent(args: { + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null ptyId: string content: string submit: boolean }): Promise<boolean> { - const { ptyId, content, submit } = args - const settings = useAppStore.getState().settings + const { settings = useAppStore.getState().settings, ptyId, content, submit } = args const pastePayload = `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}` try { const pasted = await sendRuntimePtyInputVerified(settings, ptyId, pastePayload) @@ -176,7 +199,8 @@ async function sendBracketedPasteToAgent(args: { function waitForInputBoxReady( ptyId: string, timeoutMs: number, - readySignal: DraftPasteReadySignal + readySignal: DraftPasteReadySignal, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined ): Promise<boolean> { return new Promise<boolean>((resolve) => { let settled = false @@ -257,7 +281,7 @@ function waitForInputBoxReady( if (isRemoteRuntimePtyId(ptyId)) { void subscribeToRuntimeTerminalData( - useAppStore.getState().settings, + settings, ptyId, `desktop:paste-ready:${ptyId}`, observeData diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 8f584416e37..b484519feb6 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -3,9 +3,16 @@ import type { AppState } from '@/store/types' import type { PersistedTrustedOrcaHooks } from '../../../shared/types' import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed' import { hashOrcaHookScript } from './orca-hook-trust' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const hooksCheckMock = vi.fn() const readIssueCommandMock = vi.fn() +const runtimeEnvironmentCallMock = vi.fn() +const runtimeEnvironmentTransportCallMock = vi.fn() function installHooksApiMock(): void { vi.stubGlobal('window', { @@ -13,6 +20,9 @@ function installHooksApiMock(): void { hooks: { check: hooksCheckMock, readIssueCommand: readIssueCommandMock + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCallMock } } }) @@ -45,6 +55,16 @@ describe('ensureHooksConfirmed', () => { beforeEach(() => { hooksCheckMock.mockReset() readIssueCommandMock.mockReset() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + ) + } + ) + clearRuntimeCompatibilityCacheForTests() installHooksApiMock() __resetTrustPromptChainForTests() }) @@ -147,6 +167,65 @@ describe('ensureHooksConfirmed', () => { expect(pending).toHaveLength(0) }) + it('checks SSH repo hooks through local IPC even when a runtime is focused', async () => { + const { state, pending } = createTestState({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + repos: [ + { + id: 'repo-1', + displayName: 'Repo One', + connectionId: 'ssh-1' + } + ] + } as unknown as Partial<AppState>) + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') + + expect(decision).toBe('run') + expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(pending).toHaveLength(0) + }) + + it('checks runtime-owned repo hooks through the repo owner runtime', async () => { + const { state, pending } = createTestState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { + id: 'repo-1', + displayName: 'Repo One', + executionHostId: 'runtime:owner-env' + } + ] + } as unknown as Partial<AppState>) + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-hooks', + ok: true, + result: { + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }, + _meta: { runtimeId: 'runtime-owner' } + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') + + expect(decision).toBe('run') + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'owner-env', + method: 'repo.hooksCheck', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(hooksCheckMock).not.toHaveBeenCalled() + expect(pending).toHaveLength(0) + }) + it('does not prompt for orca.yaml when the repo uses local commands only', async () => { const { state, pending } = createTestState({ repos: [ diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 1c9e0d4b64e..4b37be5f9c5 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -3,6 +3,7 @@ import type { OrcaHooks } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' export type HookScriptKind = OrcaHookScriptKind @@ -33,6 +34,15 @@ function getSetupTrustContent(yamlHooks: OrcaHooks | null): string { return [yamlHooks?.scripts?.setup?.trim(), ...defaultTabCommands].filter(Boolean).join('\n\n') } +function settingsForHookRepoOwner(state: AppState, repoId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForRepo(state, repoId) + // Why: hook inspection must follow the repo owner. SSH/local repos execute + // through desktop IPC, while runtime repos may differ from the focused host. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + export async function ensureHooksConfirmed( state: AppState, repoId: string, @@ -47,7 +57,10 @@ export async function ensureHooksConfirmed( try { if (scriptKind === 'issueCommand') { // Local overrides are user-owned; only shared orca.yaml commands need repo trust. - const result = await readRuntimeIssueCommand(state.settings, repoId) + const result = await readRuntimeIssueCommand( + settingsForHookRepoOwner(state, repoId), + repoId + ) if (result.source === 'local') { return 'run' } @@ -70,7 +83,7 @@ export async function ensureHooksConfirmed( if (sourcePolicy === 'local-only') { return 'run' } - const result = await checkRuntimeHooks(state.settings, repoId) + const result = await checkRuntimeHooks(settingsForHookRepoOwner(state, repoId), repoId) if (result.status === 'error') { return 'skip' } diff --git a/src/renderer/src/lib/github-work-item-source-lookup.ts b/src/renderer/src/lib/github-work-item-source-lookup.ts new file mode 100644 index 00000000000..cfba24b672c --- /dev/null +++ b/src/renderer/src/lib/github-work-item-source-lookup.ts @@ -0,0 +1,76 @@ +import type { GitHubWorkItem } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' + +type GitHubWorkItemLookupArgs = { + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' +} + +type GitHubWorkItemByOwnerRepoLookupArgs = GitHubWorkItemLookupArgs & { + owner: string + repo: string + type: 'issue' | 'pr' +} + +function runtimeRepoId(args: Pick<GitHubWorkItemLookupArgs, 'repoId' | 'sourceContext'>): string { + return args.sourceContext?.repoId ?? args.repoId +} + +export async function lookupGitHubWorkItemForSource( + args: GitHubWorkItemLookupArgs +): Promise<GitHubWorkItem | null> { + const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) + const item = + target.kind === 'environment' + ? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>( + target, + 'github.workItem', + { + repo: runtimeRepoId(args), + number: args.number, + type: args.type + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.workItem({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + type: args.type + }) + return item ? ({ ...item, repoId: args.repoId } as GitHubWorkItem) : null +} + +export async function lookupGitHubWorkItemByOwnerRepoForSource( + args: GitHubWorkItemByOwnerRepoLookupArgs +): Promise<GitHubWorkItem | null> { + const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) + const item = + target.kind === 'environment' + ? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>( + target, + 'github.workItemByOwnerRepo', + { + repo: runtimeRepoId(args), + owner: args.owner, + ownerRepo: args.repo, + number: args.number, + type: args.type + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.workItemByOwnerRepo({ + repoPath: args.repoPath, + repoId: args.repoId, + owner: args.owner, + repo: args.repo, + number: args.number, + type: args.type + }) + return item ? ({ ...item, repoId: args.repoId } as GitHubWorkItem) : null +} diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index eafd5d3289b..1e798fc6f58 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -18,6 +18,7 @@ import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { @@ -153,7 +154,11 @@ export async function launchAgentBackgroundSession( window.api.pty.write(ptyId, submittedCommand) }, 50) } - const runtimeTarget = getActiveRuntimeTarget(store.settings) + // Route by the worktree's owner host: the agent terminal must spawn on the host + // that owns this worktree, not on the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(store, worktreeId) + ) let ptyId: string try { if (runtimeTarget.kind === 'environment') { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index fa94d3fb872..5687b23a004 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -10,6 +10,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, @@ -229,7 +230,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { // Why: paired web tabs are host-owned and return tabId: null on success. // Local-only agent tabs cannot be closed because close routes through diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index c31bb23aed8..e103e83103a 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -1,18 +1,22 @@ -import { useAppStore, type AppState } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSetupConfig } from '@/lib/new-workspace' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { GitHubPrStartPoint, + GlobalSettings, OrcaHooks, RepoHookSettings, SetupDecision } from '../../../shared/types' +// Why: preflight routes by the repo's owner host, which `getSettingsForRepoRuntimeOwner` +// hands back as a narrow runtime-scope pick rather than the full GlobalSettings. +type PreflightSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + export async function resolveDirectPrStartPoint( repoId: string, prNumber: number, - settings: AppState['settings'] + settings: PreflightSettings ): Promise<GitHubPrStartPoint> { const target = getActiveRuntimeTarget(settings) const result = @@ -32,11 +36,14 @@ export async function resolveDirectPrStartPoint( export async function resolveDirectSetupDecision( repoId: string, - repo: { hookSettings?: RepoHookSettings } + repo: { hookSettings?: RepoHookSettings }, + settings: PreflightSettings ): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> { let yamlHooks: OrcaHooks | null = null try { - const result = await checkRuntimeHooks(useAppStore.getState().settings, repoId) + // Why: route the hooks probe by the repo's owner host (passed in) so preflight + // and the subsequent owner-routed createWorktree hit the same host. + const result = await checkRuntimeHooks(settings, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index a3a5b113769..5baab88f5eb 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -39,6 +39,11 @@ import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' + +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". async function getDirectDraftContent( item: LaunchableWorkItem, @@ -81,6 +86,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } const settings = store.settings + // Why: preflight (PR base + hooks probe) must run on the repo's owner host so it + // matches the owner-routed createWorktree below, not the focused runtime. + const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId) const promptDelivery = args.promptDelivery ?? 'draft' const repoConnectionId = repo.connectionId?.trim() || null const preflightLaunchPlatform = @@ -107,7 +115,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ? store.ensureRemoteDetectedAgents(repoConnectionId) : store.ensureDetectedAgents() - const setupResolution = await resolveDirectSetupDecision(repoId, repo) + const setupResolution = await resolveDirectSetupDecision(repoId, repo, repoOwnerSettings) if (setupResolution.kind === 'needs-modal') { openModalFallback() return false @@ -139,7 +147,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom try { // Why: direct "Use PR" launches bypass the Start-from picker, so they // must still resolve the PR head before `git worktree add`. - const result = await resolveDirectPrStartPoint(repoId, item.number, settings) + const result = await resolveDirectPrStartPoint(repoId, item.number, repoOwnerSettings) resolvedBaseBranch = result.baseBranch resolvedPushTarget = result.pushTarget resolvedBranchNameOverride = result.branchNameOverride diff --git a/src/renderer/src/lib/new-workspace-composer-repo.test.ts b/src/renderer/src/lib/new-workspace-composer-repo.test.ts index 48a49a0bd01..3f1fea13ea1 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.test.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.test.ts @@ -54,4 +54,46 @@ describe('new-workspace-composer-repo', () => { getComposerEligibleRepos([makeRepo('missing-path', { path: '' }), makeRepo('repo')]) ).toEqual([expect.objectContaining({ id: 'repo' })]) }) + + it('defaults to a repo on the focused host when no explicit repo is chosen', () => { + const eligibleRepos = [ + makeRepo('local-repo'), + makeRepo('ssh-repo', { connectionId: 'win-vm' }), + makeRepo('runtime-repo', { executionHostId: 'runtime:env-1' }) + ] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:win-vm' })).toBe( + 'ssh-repo' + ) + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'runtime:env-1' })).toBe( + 'runtime-repo' + ) + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'local' })).toBe('local-repo') + }) + + it('lets explicit draft/initial/active choices win over the focused host', () => { + const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })] + + expect( + resolveComposerRepoId({ + eligibleRepos, + activeRepoId: 'local-repo', + focusedHostScope: 'ssh:win-vm' + }) + ).toBe('local-repo') + }) + + it('ignores host scope "all" and falls back to the first eligible repo', () => { + const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'all' })).toBe('local-repo') + }) + + it('falls back to the first eligible repo when the focused host has no repos', () => { + const eligibleRepos = [makeRepo('local-repo')] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:gone' })).toBe( + 'local-repo' + ) + }) }) diff --git a/src/renderer/src/lib/new-workspace-composer-repo.ts b/src/renderer/src/lib/new-workspace-composer-repo.ts index 23869a646f5..1c4dd896e8b 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.ts @@ -1,3 +1,8 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' import { isGitRepoKind } from '../../../shared/repo-kind' import type { Repo } from '../../../shared/types' @@ -9,17 +14,28 @@ export function resolveComposerRepoId({ eligibleRepos, draftRepoId, initialRepoId, - activeRepoId + activeRepoId, + focusedHostScope }: { eligibleRepos: readonly Repo[] draftRepoId?: string | null initialRepoId?: string | null activeRepoId?: string | null + focusedHostScope?: ExecutionHostScope | null }): string { + // Why: explicit choices (draft/initial/active) win, but the generic fallback + // must honor the focused host scope so "new workspace defaults to the + // focused host" holds for Landing/Cmd+J entry points (multi-host plan). + const focusedHostRepo = + focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE + ? eligibleRepos.find((repo) => getRepoExecutionHostId(repo) === focusedHostScope) + : undefined + const resolvedRepo = (draftRepoId && eligibleRepos.find((repo) => repo.id === draftRepoId)) || (initialRepoId && eligibleRepos.find((repo) => repo.id === initialRepoId)) || (activeRepoId && eligibleRepos.find((repo) => repo.id === activeRepoId)) || + focusedHostRepo || eligibleRepos[0] return resolvedRepo?.id ?? '' @@ -30,6 +46,7 @@ export function resolveComposerGitRepoId(args: { draftRepoId?: string | null initialRepoId?: string | null activeRepoId?: string | null + focusedHostScope?: ExecutionHostScope | null }): string | null { const repoId = resolveComposerRepoId(args) const repo = repoId ? args.eligibleRepos.find((entry) => entry.id === repoId) : null diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts new file mode 100644 index 00000000000..fc2990b0879 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { buildNewWorkspaceProjectOptions } from './new-workspace-project-options' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +function repo(id: string, overrides: Partial<Repo> = {}): Repo { + return { + id, + path: `/tmp/${id}`, + displayName: id, + badgeColor: '#111111', + addedAt: 1, + upstream: { owner: 'stablyai', repo: 'orca' }, + ...overrides + } +} + +function project(overrides: Partial<Project> = {}): Project { + return { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + sourceRepoIds: ['local-repo', 'ssh-repo'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function setup(overrides: Partial<ProjectHostSetup>): ProjectHostSetup { + return { + id: overrides.id ?? 'local-setup', + projectId: overrides.projectId ?? 'github:stablyai/orca', + hostId: overrides.hostId ?? 'local', + repoId: overrides.repoId ?? 'local-repo', + path: overrides.path ?? '/tmp/orca', + displayName: overrides.displayName ?? 'orca', + setupState: overrides.setupState ?? 'ready', + setupMethod: overrides.setupMethod ?? 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildNewWorkspaceProjectOptions', () => { + it('deduplicates a logical project across local and SSH setups', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'local-setup', hostId: 'local', repoId: 'local-repo' }), + setup({ id: 'ssh-setup', hostId: 'ssh:builder', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('local-repo'), repo('ssh-repo', { connectionId: 'ssh:builder' })] + }) + + expect(options).toEqual([ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + } + ]) + }) + + it('excludes projects that do not have a ready eligible setup', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project(), project({ id: 'repo:other', displayName: 'other' })], + projectHostSetups: [ + setup({ id: 'local-setup', repoId: 'local-repo' }), + setup({ + id: 'other-setup', + projectId: 'repo:other', + repoId: 'other-repo', + setupState: 'not-set-up' + }) + ], + eligibleRepos: [repo('local-repo'), repo('other-repo')] + }) + + expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts new file mode 100644 index 00000000000..ebc3240d204 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -0,0 +1,85 @@ +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +export type NewWorkspaceProjectOption = { + id: string + displayName: string + badgeColor: string + detail: string +} + +type BuildNewWorkspaceProjectOptionsInput = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] +} + +function getProjectModel({ + projects, + projectHostSetups, + eligibleRepos +}: BuildNewWorkspaceProjectOptionsInput): { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} { + if (projects.length > 0 || projectHostSetups.length > 0) { + return { projects, projectHostSetups } + } + const projection = projectHostSetupProjectionFromRepos(eligibleRepos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +function getProjectDetail(project: Project, readySetupCount: number): string { + if (project.providerIdentity) { + return `${project.providerIdentity.owner}/${project.providerIdentity.repo}` + } + if (readySetupCount > 1) { + return `${readySetupCount} hosts configured` + } + return 'Project' +} + +export function buildNewWorkspaceProjectOptions( + input: BuildNewWorkspaceProjectOptionsInput +): NewWorkspaceProjectOption[] { + const { eligibleRepos } = input + const { projects, projectHostSetups } = getProjectModel(input) + const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const readySetupCountsByProjectId = new Map<string, number>() + + for (const setup of projectHostSetups) { + if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { + continue + } + readySetupCountsByProjectId.set( + setup.projectId, + (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 + ) + } + + return projects + .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) + .map((project) => ({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) +} + +export function searchNewWorkspaceProjectOptions( + options: readonly NewWorkspaceProjectOption[], + rawQuery: string +): NewWorkspaceProjectOption[] { + const query = rawQuery.trim().toLowerCase() + if (!query) { + return [...options] + } + return options.filter((option) => + [option.displayName, option.detail].some((value) => value.toLowerCase().includes(query)) + ) +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index e4eaecb2f6c..dade5dcc28d 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -9,6 +9,7 @@ import type { } from '../../../shared/types' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AgentStartedTelemetry } from '@/lib/worktree-activation' +import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' /** Two-phase status reported by the main process while a worktree is created. * `fetching` covers the base-ref git fetch; `creating` covers `git worktree @@ -25,6 +26,13 @@ export type WorktreeCreationPhase = 'fetching' | 'creating' */ export type WorktreeCreationRequest = { repoId: string + /** Source host/account that produced the linked task. Kept separate from the + * run context so Retry does not infer provider ownership from the run host. */ + taskSourceContext?: TaskSourceContext | null + /** Host/setup where the new workspace should run. Duplicates repoId by design: + * repoId keeps old create APIs working, while this records the project-first + * host intent for retry, diagnostics, and future metadata writes. */ + workspaceRunContext?: WorkspaceRunContext | null name: string displayName?: string baseBranch?: string diff --git a/src/renderer/src/lib/project-host-clone-url.test.ts b/src/renderer/src/lib/project-host-clone-url.test.ts new file mode 100644 index 00000000000..26a0c9f1395 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Project } from '../../../shared/types' +import { getProjectHostCloneUrl } from './project-host-clone-url' + +function createProject(overrides: Partial<Project> = {}): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectHostCloneUrl', () => { + it('builds a GitHub HTTPS clone URL from provider identity', () => { + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: ' stablyai ', + repo: ' orca ' + } + }) + ) + ).toBe('https://github.com/stablyai/orca.git') + }) + + it('returns null when provider identity is missing or incomplete', () => { + expect(getProjectHostCloneUrl(createProject())).toBeNull() + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: '', + repo: 'orca' + } + }) + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/project-host-clone-url.ts b/src/renderer/src/lib/project-host-clone-url.ts new file mode 100644 index 00000000000..ae1937ca1a3 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.ts @@ -0,0 +1,14 @@ +import type { Project } from '../../../shared/types' + +export function getProjectHostCloneUrl(project: Project | null | undefined): string | null { + const identity = project?.providerIdentity + if (!identity || identity.provider !== 'github') { + return null + } + const owner = identity.owner.trim() + const repo = identity.repo.trim() + if (!owner || !repo) { + return null + } + return `https://github.com/${owner}/${repo}.git` +} diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts new file mode 100644 index 00000000000..328aebb5fb5 --- /dev/null +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { ProjectHostSetup, Repo } from '../../../shared/types' +import { buildProjectHostSetupOptions } from './project-host-setup-options' + +const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +] + +function repo(id: string): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 1 + } +} + +function setup( + id: string, + projectId: string, + hostId: ExecutionHostId, + repoId: string, + overrides: Partial<ProjectHostSetup> = {} +): ProjectHostSetup { + return { + id, + projectId, + hostId, + repoId, + path: `/repos/${repoId}`, + displayName: repoId, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function host( + id: ExecutionHostId, + overrides: Partial<ExecutionHostRegistryEntry> = {} +): ExecutionHostRegistryEntry { + return { + id, + kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime', + label: id === 'local' ? 'Local Mac' : id.replace(/^ssh:|^runtime:/, ''), + detail: id === 'local' ? 'This computer' : 'Host', + health: id === 'local' ? 'local' : 'available', + ...overrides + } +} + +describe('buildProjectHostSetupOptions', () => { + it('returns ready setup choices for one project sorted with local first', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo'), repo('remote-repo')], + projectHostSetups: [ + setup('remote', 'project-1', 'ssh:builder', 'remote-repo'), + setup('local', 'project-1', 'local', 'local-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['local', 'remote']) + expect(options[0]).toMatchObject({ label: 'Local Mac', repoId: 'local-repo' }) + expect(options[1]).toMatchObject({ label: 'builder', repoId: 'remote-repo' }) + }) + + it('uses saved host labels for ready runtime setup choices', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('runtime-repo')], + hosts: [ + host('runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', { + label: 'dev box', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + setup( + 'runtime', + 'project-1', + 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + 'runtime-repo' + ) + ] + }) + + expect(options).toEqual([ + expect.objectContaining({ + id: 'runtime', + kind: 'ready', + label: 'dev box', + repoId: 'runtime-repo' + }) + ]) + }) + + it('omits setups that are not ready or cannot create through an eligible repo', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('ready-repo')], + projectHostSetups: [ + setup('ready', 'project-1', 'local', 'ready-repo'), + setup('setting-up', 'project-1', 'ssh:builder', 'missing-repo', { + setupState: 'setting-up' + }), + setup('other-project', 'project-2', 'local', 'ready-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['ready']) + }) + + it('includes known hosts that still need project setup', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('ssh:builder', { label: 'Builder' })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:ssh:builder', + kind: 'needs-setup', + label: 'Builder', + detail: 'Project not set up on this host', + isAvailable: true + }) + ]) + }) + + it('shows pending setup status for known hosts with non-ready setup metadata', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + setup('local', 'project-1', 'local', 'local-repo'), + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + label: 'GPU VM', + detail: 'Project setup is in progress', + isAvailable: true + }) + ]) + }) + + it('uses specific pending details for not-set-up, error, and unsupported setup metadata', () => { + const base = { + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + } + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project tracked on this host but not set up' }) + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'error', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project setup needs attention' }) + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'unsupported', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project is unsupported on this host' }) + }) + + it('marks incompatible runtime hosts as visible but unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + health: 'blocked', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + label: 'GPU VM', + detail: 'Orca server version is incompatible', + isAvailable: false + }) + ]) + }) + + it('marks runtime hosts without project setup capability as unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM', capabilities: [] })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Update Orca on this host to set up projects', + isAvailable: false + }) + }) + + it('marks runtime hosts without workspace run-context capability as unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Update Orca on this host to set up projects', + isAvailable: false + }) + }) + + it('marks runtime hosts with unknown capabilities as unavailable while checking', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM' })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Checking host capabilities', + isAvailable: false + }) + }) +}) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts new file mode 100644 index 00000000000..74654ecfb68 --- /dev/null +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -0,0 +1,218 @@ +import { + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { ProjectHostSetup, Repo } from '../../../shared/types' + +export type ProjectHostSetupOption = + | { + id: string + kind: 'ready' + projectId: string + hostId: ExecutionHostId + repoId: string + label: string + detail: string + path: string + } + | { + id: string + kind: 'needs-setup' + projectId: string + hostId: ExecutionHostId + label: string + detail: string + isAvailable: boolean + } + +export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind: 'ready' }> + +export type NeedsSetupProjectHostOption = Extract<ProjectHostSetupOption, { kind: 'needs-setup' }> + +type BuildReadySetupOptionsInput = { + projectId: string + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] + hosts: readonly ExecutionHostRegistryEntry[] +} + +type BuildNeedsSetupOptionsInput = { + projectId: string + hosts: readonly ExecutionHostRegistryEntry[] + readySetupByHost: ReadonlyMap<ExecutionHostId, ReadyProjectHostSetupOption> + pendingSetupByHost: ReadonlyMap<ExecutionHostId, ProjectHostSetup> +} + +type BuildProjectHostSetupOptionsInput = { + projectId: string | null + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] + hosts?: readonly ExecutionHostRegistryEntry[] +} + +export function buildProjectHostSetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts = [] +}: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] { + if (!projectId) { + return [] + } + const readyOptions = buildReadySetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts + }) + const readySetupByHost = new Map(readyOptions.map((option) => [option.hostId, option])) + const pendingSetupByHost = getPendingSetupByHost(projectId, projectHostSetups) + return [ + ...readyOptions, + ...buildNeedsSetupOptions({ + projectId, + hosts, + readySetupByHost, + pendingSetupByHost + }) + ].sort((a, b) => compareProjectHostSetupOptions(a, b)) +} + +function getPendingSetupByHost( + projectId: string, + projectHostSetups: readonly ProjectHostSetup[] +): Map<ExecutionHostId, ProjectHostSetup> { + const setups = new Map<ExecutionHostId, ProjectHostSetup>() + for (const setup of projectHostSetups) { + if (setup.projectId !== projectId || setup.setupState === 'ready') { + continue + } + if (!setups.has(setup.hostId)) { + setups.set(setup.hostId, setup) + } + } + return setups +} + +function buildReadySetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts +}: BuildReadySetupOptionsInput): ReadyProjectHostSetupOption[] { + const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const hostLabelById = new Map(hosts.map((host) => [host.id, host.label])) + return projectHostSetups + .filter( + (setup) => + setup.projectId === projectId && + setup.setupState === 'ready' && + eligibleRepoIds.has(setup.repoId) + ) + .map((setup) => ({ + id: setup.id, + kind: 'ready' as const, + projectId: setup.projectId, + hostId: setup.hostId, + repoId: setup.repoId, + label: hostLabelById.get(setup.hostId) || getExecutionHostLabel(setup.hostId), + detail: setup.displayName, + path: setup.path + })) +} + +function buildNeedsSetupOptions({ + projectId, + hosts, + readySetupByHost, + pendingSetupByHost +}: BuildNeedsSetupOptionsInput): NeedsSetupProjectHostOption[] { + return hosts + .filter((host) => !readySetupByHost.has(host.id)) + .map((host) => { + const pendingSetup = pendingSetupByHost.get(host.id) + const availability = getHostSetupAvailability(host) + return { + id: `needs-setup:${host.id}`, + kind: 'needs-setup' as const, + projectId, + hostId: host.id, + label: host.label || getExecutionHostLabel(host.id), + detail: availability.isAvailable + ? pendingSetup + ? getPendingSetupDetail(pendingSetup) + : 'Project not set up on this host' + : availability.detail, + isAvailable: availability.isAvailable + } + }) +} + +function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { + isAvailable: boolean + detail: string +} { + if (host.health === 'blocked') { + return { + isAvailable: false, + detail: 'Orca server version is incompatible' + } + } + if (host.kind === 'runtime') { + if (!host.capabilities) { + return { + isAvailable: false, + detail: 'Checking host capabilities' + } + } + if ( + !host.capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) || + !host.capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY) + ) { + return { + isAvailable: false, + detail: 'Update Orca on this host to set up projects' + } + } + } + return { + isAvailable: true, + detail: '' + } +} + +function getPendingSetupDetail(setup: ProjectHostSetup): string { + switch (setup.setupState) { + case 'not-set-up': + return 'Project tracked on this host but not set up' + case 'setting-up': + return 'Project setup is in progress' + case 'error': + return 'Project setup needs attention' + case 'unsupported': + return 'Project is unsupported on this host' + case 'ready': + return setup.path + } +} + +function compareProjectHostSetupOptions( + a: ProjectHostSetupOption, + b: ProjectHostSetupOption +): number { + if (a.hostId === LOCAL_EXECUTION_HOST_ID && b.hostId !== LOCAL_EXECUTION_HOST_ID) { + return -1 + } + if (b.hostId === LOCAL_EXECUTION_HOST_ID && a.hostId !== LOCAL_EXECUTION_HOST_ID) { + return 1 + } + const aDetail = a.kind === 'ready' ? a.path : a.detail + const bDetail = b.kind === 'ready' ? b.path : b.detail + return a.label.localeCompare(b.label) || aDetail.localeCompare(bDetail) +} diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts new file mode 100644 index 00000000000..a6fd417de25 --- /dev/null +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' +import { + resolveWorkspaceCreationRepoId, + resolveWorkspaceCreationTarget +} from './project-host-workspace-target' + +function makeRepo(id: string, overrides: Partial<Repo> = {}): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 1, + ...overrides + } +} + +function makeProject( + id: string, + sourceRepoIds: string[], + overrides: Partial<Project> = {} +): Project { + return { + id, + displayName: id, + badgeColor: '#000000', + sourceRepoIds, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSetup( + id: string, + projectId: string, + hostId: ExecutionHostId, + repoId: string, + overrides: Partial<ProjectHostSetup> = {} +): ProjectHostSetup { + return { + id, + projectId, + hostId, + repoId, + path: `/repos/${repoId}`, + displayName: repoId, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('project-host workspace target resolution', () => { + it('falls back to a local setup for a local-only repo', () => { + const repo = makeRepo('orca') + + const resolution = resolveWorkspaceCreationTarget({ eligibleRepos: [repo] }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { + projectId: 'repo:orca', + hostId: 'local', + projectHostSetupId: 'orca', + repoId: 'orca' + } + }) + }) + + it('chooses the focused host setup when one project exists on multiple hosts', () => { + const repos = [makeRepo('orca-local'), makeRepo('orca-ssh', { connectionId: 'openclaw-2' })] + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-ssh'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local'), + makeSetup('orca-ssh', 'github:stablyai/orca', 'ssh:openclaw-2', 'orca-ssh') + ] + + expect( + resolveWorkspaceCreationRepoId({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + focusedHostScope: 'ssh:openclaw-2' + }) + ).toBe('orca-ssh') + }) + + it('resolves an explicit project and host to the matching setup', () => { + const repos = [ + makeRepo('orca-local'), + makeRepo('orca-runtime', { executionHostId: 'runtime:gpu-1' }) + ] + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-runtime'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local'), + makeSetup('orca-runtime', 'github:stablyai/orca', 'runtime:gpu-1', 'orca-runtime') + ] + + const resolution = resolveWorkspaceCreationTarget({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu-1' + }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu-1', + projectHostSetupId: 'orca-runtime', + repoId: 'orca-runtime' + } + }) + }) + + it('does not merge same-name repos without shared project identity', () => { + const repos = [ + makeRepo('personal-orca', { displayName: 'orca' }), + makeRepo('work-orca', { displayName: 'orca', connectionId: 'work-linux' }) + ] + + expect( + resolveWorkspaceCreationRepoId({ + eligibleRepos: repos, + projectId: 'repo:personal-orca', + focusedHostScope: 'ssh:work-linux' + }) + ).toBe('personal-orca') + }) + + it('reports unavailable when the project is not set up on the selected host', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [makeSetup('orca', 'github:stablyai/orca', 'local', 'orca')] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2' + }) + ).toEqual({ + status: 'unavailable', + reason: 'project-not-set-up-on-host' + }) + }) + + it('reports setup-not-ready when the selected host has pending setup metadata', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [ + makeSetup('orca', 'github:stablyai/orca', 'local', 'orca'), + makeSetup('gpu-pending', 'github:stablyai/orca', 'runtime:gpu', '', { + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu' + }) + ).toEqual({ + status: 'unavailable', + reason: 'setup-not-ready' + }) + }) + + it('reports unavailable when an explicit setup is not ready', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [ + makeSetup('orca', 'github:stablyai/orca', 'local', 'orca', { setupState: 'setting-up' }) + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectHostSetupId: 'orca' + }) + ).toEqual({ + status: 'unavailable', + reason: 'setup-not-ready' + }) + }) +}) diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts new file mode 100644 index 00000000000..13e9271bac0 --- /dev/null +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -0,0 +1,208 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + type ExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' +import { resolveComposerRepoId } from './new-workspace-composer-repo' + +export type WorkspaceCreationTarget = { + projectId: string + hostId: ExecutionHostId + projectHostSetupId: string + repoId: string + repo: Repo + setup: ProjectHostSetup +} + +export type WorkspaceCreationTargetResolution = + | { status: 'ready'; target: WorkspaceCreationTarget } + | { + status: 'unavailable' + reason: + | 'no-eligible-repo' + | 'project-not-found' + | 'project-not-set-up-on-host' + | 'project-has-no-ready-setup' + | 'setup-not-found' + | 'setup-not-ready' + } + +type ProjectHostWorkspaceTargetInput = { + eligibleRepos: readonly Repo[] + projects?: readonly Project[] + projectHostSetups?: readonly ProjectHostSetup[] + draftRepoId?: string | null + initialRepoId?: string | null + activeRepoId?: string | null + projectId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null + focusedHostScope?: ExecutionHostScope | null +} + +type ProjectSetupModel = { + projects: readonly Project[] + setups: readonly ProjectHostSetup[] +} + +function getProjectSetupModel({ + eligibleRepos, + projects, + projectHostSetups +}: Pick< + ProjectHostWorkspaceTargetInput, + 'eligibleRepos' | 'projects' | 'projectHostSetups' +>): ProjectSetupModel | null { + if (projects?.length || projectHostSetups?.length) { + return { + projects: projects ?? [], + setups: projectHostSetups ?? [] + } + } + if (eligibleRepos.length === 0) { + return null + } + const projection = projectHostSetupProjectionFromRepos(eligibleRepos) + return { + projects: projection.projects, + setups: projection.setups + } +} + +function isReadySetup(setup: ProjectHostSetup): boolean { + return setup.setupState === 'ready' +} + +function createTarget( + setup: ProjectHostSetup, + repoById: ReadonlyMap<string, Repo> +): WorkspaceCreationTarget | null { + const repo = repoById.get(setup.repoId) + if (!repo) { + return null + } + return { + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + repo, + setup + } +} + +function findReadySetupTarget( + setups: readonly ProjectHostSetup[], + repoById: ReadonlyMap<string, Repo>, + predicate: (setup: ProjectHostSetup) => boolean +): WorkspaceCreationTarget | null { + for (const setup of setups) { + if (!isReadySetup(setup) || !predicate(setup)) { + continue + } + const target = createTarget(setup, repoById) + if (target) { + return target + } + } + return null +} + +export function resolveWorkspaceCreationTarget( + input: ProjectHostWorkspaceTargetInput +): WorkspaceCreationTargetResolution { + const { eligibleRepos, focusedHostScope, hostId, projectHostSetupId, projectId } = input + if (eligibleRepos.length === 0) { + return { status: 'unavailable', reason: 'no-eligible-repo' } + } + + const model = getProjectSetupModel(input) + const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo])) + const setups = model?.setups ?? [] + + if (projectHostSetupId) { + const setup = setups.find((entry) => entry.id === projectHostSetupId) + if (!setup) { + return { status: 'unavailable', reason: 'setup-not-found' } + } + if (!isReadySetup(setup)) { + return { status: 'unavailable', reason: 'setup-not-ready' } + } + const target = createTarget(setup, repoById) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'setup-not-found' } + } + + if (projectId && !model?.projects.some((project) => project.id === projectId)) { + return { status: 'unavailable', reason: 'project-not-found' } + } + + if (projectId && hostId) { + const hostSetup = setups.find( + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + if (hostSetup && !isReadySetup(hostSetup)) { + return { status: 'unavailable', reason: 'setup-not-ready' } + } + const target = findReadySetupTarget( + setups, + repoById, + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'project-not-set-up-on-host' } + } + + if (projectId) { + const focusedHostId = + focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE ? focusedHostScope : null + const focusedTarget = focusedHostId + ? findReadySetupTarget( + setups, + repoById, + (setup) => setup.projectId === projectId && setup.hostId === focusedHostId + ) + : null + if (focusedTarget) { + return { status: 'ready', target: focusedTarget } + } + const target = findReadySetupTarget(setups, repoById, (setup) => setup.projectId === projectId) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'project-has-no-ready-setup' } + } + + if (hostId) { + const target = findReadySetupTarget(setups, repoById, (setup) => setup.hostId === hostId) + if (target) { + return { status: 'ready', target } + } + } + + const repoId = resolveComposerRepoId(input) + const legacyRepo = repoId ? repoById.get(repoId) : null + if (!legacyRepo) { + return { status: 'unavailable', reason: 'no-eligible-repo' } + } + + const legacySetup = + setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ?? + projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] + const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null + if (!legacyTarget) { + return { status: 'unavailable', reason: 'setup-not-found' } + } + return { status: 'ready', target: legacyTarget } +} + +export function resolveWorkspaceCreationRepoId(input: ProjectHostWorkspaceTargetInput): string { + const resolution = resolveWorkspaceCreationTarget(input) + return resolution.status === 'ready' ? resolution.target.repoId : '' +} diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts new file mode 100644 index 00000000000..c564ff74dfe --- /dev/null +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../../shared/types' +import { + getRepoOwnerRoutedSettings, + getRuntimeEnvironmentIdForRepo, + getSettingsForRepoRuntimeOwner +} from './repo-runtime-owner' + +describe('getRuntimeEnvironmentIdForRepo', () => { + it('uses an explicit runtime repo owner instead of the focused runtime', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('keeps explicit local repos local while a runtime is focused', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }, + 'repo-1' + ) + ).toBeNull() + }) + + it('falls back to the focused runtime for legacy repos without an owner', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }] + }, + 'repo-1' + ) + ).toBe('focused-runtime') + }) + + it('returns settings scoped to an explicit local repo owner', () => { + expect( + getSettingsForRepoRuntimeOwner( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }, + 'repo-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) +}) + +describe('getRepoOwnerRoutedSettings', () => { + // Why: SourceControl builds its git/file mutation contexts from this value, + // so it must rebind activeRuntimeEnvironmentId to the repo OWNER even while a + // different host is focused — otherwise stage/commit/push hit the wrong host. + it('routes a git mutation context for a runtime-owned active repo to the owner, not the focused runtime', () => { + const settings = { + activeRuntimeEnvironmentId: 'focused-runtime', + sourceControlViewMode: 'list' + } as unknown as GlobalSettings + + const routed = getRepoOwnerRoutedSettings(settings, { + id: 'repo-1', + connectionId: null, + executionHostId: 'runtime:owner-runtime' + }) + + expect(routed?.activeRuntimeEnvironmentId).toBe('owner-runtime') + // Non-routing (display) fields must survive the rebind untouched. + expect((routed as { sourceControlViewMode?: string }).sourceControlViewMode).toBe('list') + }) + + it('falls back to the focused runtime for a legacy repo without an explicit owner', () => { + const settings = { activeRuntimeEnvironmentId: 'focused-runtime' } as unknown as GlobalSettings + const routed = getRepoOwnerRoutedSettings(settings, { + id: 'repo-1', + connectionId: null, + executionHostId: null + }) + expect(routed?.activeRuntimeEnvironmentId).toBe('focused-runtime') + }) + + it('passes null settings through unchanged', () => { + expect( + getRepoOwnerRoutedSettings(null, { + id: 'repo-1', + connectionId: null, + executionHostId: null + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts new file mode 100644 index 00000000000..81905e9d1b3 --- /dev/null +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -0,0 +1,50 @@ +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import type { GlobalSettings, Repo } from '../../../shared/types' + +export type RepoRuntimeOwnerState = { + repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null +} + +export function getRuntimeEnvironmentIdForRepo( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): string | null { + if (!repoId) { + return null + } + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null + } + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getSettingsForRepoRuntimeOwner( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return { + ...state.settings, + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForRepo(state, repoId) + } +} + +// Why: git/file/terminal mutations must route by the OWNER host of the repo, +// not the currently focused runtime. This rebinds activeRuntimeEnvironmentId to +// the repo owner while preserving every other (display/AI) settings field. +export function getRepoOwnerRoutedSettings<T extends GlobalSettings | null>( + settings: T, + repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> | null | undefined +): T { + if (!settings) { + return settings + } + const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForRepo( + { repos: repo ? [repo] : [], settings }, + repo?.id ?? null + ) + return { ...settings, activeRuntimeEnvironmentId } +} diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts new file mode 100644 index 00000000000..193d871eae1 --- /dev/null +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -0,0 +1,51 @@ +// Why: the slug → Repo cache and its synchronous lookup live here (separate from +// repo-slug-index.ts) so store slices can import the sync lookup without pulling +// in repo-slug-index's `@/store` dependency, which would form an import cycle. +import type { GlobalSettings, Repo } from '../../../shared/types' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' + +/** Lowercased `owner/repo` → Repo[]. */ +export type SlugIndex = Map<string, Repo[]> + +/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already + * failed resolution is recorded as `null` so it is not retried on re-mount. */ +export const slugByRepoId = new Map<string, string | null>() + +export function slugCacheKey( + repoId: string, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` +} + +export function settingsForRepoOwner( + repo: Repo, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return getSettingsForRepoRuntimeOwner({ repos: [repo], settings }, repo.id) +} + +/** Synchronous slug → Repo lookup against the already-resolved module cache. + * Used by store slices (which can't run the async hook-based index) to route + * project-row mutations to the matched repo's owner host; callers fall back to + * focused settings when nothing matches. */ +export function lookupReposBySlugFromCache( + repos: readonly Repo[], + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + slug: string | null | undefined +): Repo[] { + const target = slug?.toLowerCase() + if (!target) { + return [] + } + const matched: Repo[] = [] + for (const repo of repos) { + const cacheKey = slugCacheKey(repo.id, settingsForRepoOwner(repo, settings)) + if (slugByRepoId.get(cacheKey)?.toLowerCase() === target) { + matched.push(repo) + } + } + return matched +} diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index dde60964f9e..8a4832b04fa 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -18,26 +18,9 @@ import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' import type { GlobalSettings } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { settingsForRepoOwner, slugByRepoId, slugCacheKey, type SlugIndex } from './repo-slug-cache' -/** Lowercased `owner/repo` → Repo[]. Case folded because GitHub treats slugs - * case-insensitively but displays the canonical casing; the lookup side - * uses the row's `content.repository` which may or may not match the - * canonical casing depending on when the project item was indexed. */ -type SlugIndex = Map<string, Repo[]> - -/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already failed - * resolution is not retried on re-mount; the value in the map is `null` - * to record the negative result so we don't keep poking `git remote` for - * repos that will never match. */ -const slugByRepoId = new Map<string, string | null>() - -function slugCacheKey( - repoId: string, - settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) - return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` -} +export { lookupReposBySlugFromCache } from './repo-slug-cache' /** Drop a repo's cached slug result. Call when a repo is removed or its * remote URL is known to have changed (e.g. after `git remote set-url`), @@ -97,7 +80,7 @@ async function buildIndex( // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settings))) + const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settingsForRepoOwner(r, settings)))) for (const key of slugByRepoId.keys()) { if (!liveKeys.has(key)) { slugByRepoId.delete(key) @@ -105,7 +88,12 @@ async function buildIndex( } const next: SlugIndex = new Map() const results = await Promise.all( - repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r, settings) })) + repos.map(async (r) => ({ + repo: r, + // Why: the project slug index spans repos from multiple hosts; each + // repo's remote metadata must be read from its owner. + slug: await resolveRepoSlug(r, settingsForRepoOwner(r, settings)) + })) ) for (const { repo, slug } of results) { if (slug) { @@ -125,11 +113,7 @@ export type RepoSlugIndexState = { * deep trees can treat it as referentially equal inside a single render cycle. */ export function useRepoSlugIndex(): RepoSlugIndexState { const repos = useAppStore((s) => s.repos) - const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) - const runtimeSettings = useMemo( - () => (activeRuntimeEnvironmentId ? { activeRuntimeEnvironmentId } : null), - [activeRuntimeEnvironmentId] - ) + const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState<SlugIndex>(() => new Map()) const [ready, setReady] = useState(false) // Why: track the current repos snapshot so the effect can ignore stale @@ -139,14 +123,14 @@ export function useRepoSlugIndex(): RepoSlugIndexState { useEffect(() => { const gen = ++generationRef.current setReady(false) - void buildIndex(repos, runtimeSettings).then((next) => { + void buildIndex(repos, settings).then((next) => { if (gen !== generationRef.current) { return } setIndex(next) setReady(true) }) - }, [repos, runtimeSettings]) + }, [repos, settings]) return useMemo( () => ({ diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index 572053f1fde..dc683db8537 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -1,4 +1,6 @@ import type { GitHubWorkItem } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../shared/task-source-context' import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' import type { LinkedWorkItemSummary } from './new-workspace' import { parseGitHubIssueOrPRLink } from './github-links' @@ -27,15 +29,18 @@ export type SmartGitHubSubmitResolution = { export type SmartGitHubSubmitLookup = { repoId: string repoPath: string + sourceContext?: TaskSourceContext | null intent: SmartGitHubSubmitIntent workItem: (args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null number: number }) => Promise<GitHubWorkItem | null> workItemByOwnerRepo: (args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null owner: string repo: string number: number @@ -109,13 +114,16 @@ function parseGitHubIssueOrPRLinkFromText( function getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, + sourceContext, intent }: { repoId: string repoPath: string + sourceContext?: TaskSourceContext | null intent: SmartGitHubSubmitIntent }): string { - const repoScope = `${repoId}:${repoPath}` + const sourceScope = sourceContext ? getTaskSourceCacheScope(sourceContext) : 'default' + const repoScope = `${sourceScope}:${repoId}:${repoPath}` if (intent.kind === 'hash-number') { return `${repoScope}:hash:${intent.number}` } @@ -127,11 +135,12 @@ function getSmartGitHubSubmitLookupCacheKey({ export function lookupSmartGitHubSubmitItem({ repoId, repoPath, + sourceContext, intent, workItem, workItemByOwnerRepo }: SmartGitHubSubmitLookup): Promise<GitHubWorkItem | null> { - const key = getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, intent }) + const key = getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, sourceContext, intent }) const now = Date.now() pruneSmartGitHubSubmitLookupCache(now) const cached = smartGitHubSubmitLookupCache.get(key) @@ -144,6 +153,7 @@ export function lookupSmartGitHubSubmitItem({ ? workItemByOwnerRepo({ repoPath, repoId, + sourceContext, owner: intent.owner, repo: intent.repo, number: intent.number, @@ -152,6 +162,7 @@ export function lookupSmartGitHubSubmitItem({ : workItem({ repoPath, repoId, + sourceContext, number: intent.number }) const stampedPromise = promise.then((item) => (item ? { ...item, repoId } : null)) diff --git a/src/renderer/src/lib/tab-number-shortcuts.test.ts b/src/renderer/src/lib/tab-number-shortcuts.test.ts index ef22db29d6f..4840e6aafd0 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.test.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.test.ts @@ -32,7 +32,10 @@ function state(overrides: { | 'activeView' | 'activeWorktreeId' | 'groupsByWorktree' + | 'repos' + | 'settings' | 'unifiedTabsByWorktree' + | 'worktreesByRepo' > { const worktreeId = overrides.activeWorktreeId ?? 'wt-1' return { @@ -41,6 +44,13 @@ function state(overrides: { activeGroupIdByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.activeGroupId ?? 'group-a' }, groupsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.groups ?? [] }, + repos: + worktreeId === null + ? [] + : ([{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] as never), + settings: { activeRuntimeEnvironmentId: null } as never, + worktreesByRepo: + worktreeId === null ? {} : { 'repo-1': [{ id: worktreeId, repoId: 'repo-1' }] as never }, unifiedTabsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.tabs ?? [] } } } diff --git a/src/renderer/src/lib/tab-number-shortcuts.ts b/src/renderer/src/lib/tab-number-shortcuts.ts index 38afae6f657..27a65675ce8 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.ts @@ -1,6 +1,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { dedupeTabOrder } from '@/store/slices/tab-group-state' import type { Tab } from '../../../shared/types' import { @@ -14,7 +15,10 @@ type TabNumberShortcutState = Pick< | 'activeView' | 'activeWorktreeId' | 'groupsByWorktree' + | 'repos' + | 'settings' | 'unifiedTabsByWorktree' + | 'worktreesByRepo' > export function resolveTabNumberShortcutTarget( @@ -57,8 +61,8 @@ export function activateTabNumberShortcut(index: number): boolean { return false } - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() const worktreeId = target.worktreeId + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) store.focusGroup(worktreeId, target.groupId) store.activateTab(target.id) diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index 844fd4fe3b7..8e40333e7b8 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -6,6 +6,7 @@ import { type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { WorkspacePort, WorkspacePortKillResult, @@ -28,6 +29,9 @@ type RemoteBrowserPageHandleSetter = ReturnType< typeof useAppStore.getState >['setRemoteBrowserPageHandle'] type WorkspacePortScanSetter = ReturnType<typeof useAppStore.getState>['setWorkspacePortScan'] +type WorkspacePortScanByKeySetter = ReturnType< + typeof useAppStore.getState +>['setWorkspacePortScanForKey'] type WorkspacePortScanRefreshingSetter = ReturnType< typeof useAppStore.getState >['setWorkspacePortScanRefreshing'] @@ -111,7 +115,7 @@ export async function refreshWorkspacePortScanState(args: { try { const scan = await scanWorkspacePortsForTarget(args.runtimeTarget) args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, + key: workspacePortScanKeyForTarget(args.runtimeTarget), result: scan }) return scan @@ -123,8 +127,20 @@ export async function refreshWorkspacePortScanState(args: { export async function refreshWorkspacePortScanAfterStop(args: { runtimeTarget: RuntimeClientTarget setWorkspacePortScan: WorkspacePortScanSetter + setWorkspacePortScanForKey?: WorkspacePortScanByKeySetter setWorkspacePortScanRefreshing: WorkspacePortScanRefreshingSetter + getWorkspacePortScansByKey?: () => Record<string, WorkspacePortScanResult> }): Promise<{ ok: true } | { ok: false; reason: string }> { + const scanKey = workspacePortScanKeyForTarget(args.runtimeTarget) + const publishScan = (scan: WorkspacePortScanResult): void => { + args.setWorkspacePortScanForKey?.(scanKey, scan) + const currentScans = args.getWorkspacePortScansByKey?.() ?? {} + const merged = mergeWorkspacePortScans({ ...currentScans, [scanKey]: scan }) + args.setWorkspacePortScan({ + key: merged && Object.keys(currentScans).length > 0 ? 'all-hosts:all' : scanKey, + result: merged ?? scan + }) + } args.setWorkspacePortScanRefreshing(true) try { let firstScan: WorkspacePortScanResult @@ -134,10 +150,7 @@ export async function refreshWorkspacePortScanAfterStop(args: { const message = error instanceof Error ? error.message : String(error) return { ok: false, reason: message || 'Workspace port scan failed.' } } - args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, - result: firstScan - }) + publishScan(firstScan) // Why: stopping sends SIGTERM, and the listener can remain visible for a // short window. A settled re-scan keeps worktree cards from showing a stale @@ -147,10 +160,7 @@ export async function refreshWorkspacePortScanAfterStop(args: { await delay(WORKSPACE_PORT_STOP_SETTLE_MS) try { const settledScan = await scanWorkspacePortsForTarget(args.runtimeTarget) - args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, - result: settledScan - }) + publishScan(settledScan) } catch { // Intentionally ignored: first scan already updated the UI. } @@ -164,6 +174,56 @@ export function workspacePortRuntimeTargetKey(target: RuntimeClientTarget): stri return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } +export function runtimeTargetForExecutionHostId( + hostId: ExecutionHostId +): RuntimeClientTarget | null { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'local') { + return { kind: 'local' } + } + if (parsed?.kind === 'runtime') { + return { kind: 'environment', environmentId: parsed.environmentId } + } + return null +} + +export function workspacePortScanKeyForTarget(target: RuntimeClientTarget): string { + return `${workspacePortRuntimeTargetKey(target)}:all` +} + +export function mergeWorkspacePortScans( + scansByKey: Record<string, WorkspacePortScanResult> +): WorkspacePortScanResult | null { + const entries = Object.entries(scansByKey) + .filter(([, scan]) => scan) + .sort(([a], [b]) => a.localeCompare(b)) + if (entries.length === 0) { + return null + } + if (entries.length === 1) { + return entries[0][1] + } + const ports = entries.flatMap(([key, scan]) => + scan.ports.map((port) => ({ + ...port, + // Why: local and runtime scanners can both report simple ids like + // `tcp:3000`; aggregate All-hosts views need stable unique row keys. + id: `${key}:${port.id}` + })) + ) + const unavailable = entries + .map(([key, scan]) => (scan.unavailableReason ? `${key}: ${scan.unavailableReason}` : null)) + .filter((entry): entry is string => entry !== null) + return { + platform: 'unknown', + scannedAt: Math.max(...entries.map(([, scan]) => scan.scannedAt)), + ports, + ...(unavailable.length === entries.length && unavailable.length > 0 + ? { unavailableReason: unavailable.join('; ') } + : {}) + } +} + const inFlightWorkspacePortScans = new Map<string, Promise<WorkspacePortScanResult>>() function workspacePortScanRequestKey(target: RuntimeClientTarget, repoId?: string): string { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts new file mode 100644 index 00000000000..80d56c23636 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -0,0 +1,138 @@ +import type { + Repo, + Worktree, + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { + mergeWorkspaceSessionsFromHosts, + splitWorkspaceSessionByHost, + type HostSessionSlices, + type HostIdByWorktreeId +} from './workspace-session-host-split' + +export type HostPersistenceState = { + repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]> +} + +type SessionApi = { + get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState> + patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise<void> + setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void +} + +/** Map a worktree to the host partition it persists under. + * + * Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay + * in the 'local' partition because the SSH flow already persists them there (in + * the unified blob) and separately mirrors them to each target's remote + * snapshot — partitioning them too would double-own that data. */ +export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId { + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const repoIdByWorktreeId = new Map<string, string>() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + repoIdByWorktreeId.set(worktree.id, worktree.repoId) + } + } + + return (worktreeId: string): ExecutionHostId => { + const repoId = repoIdByWorktreeId.get(worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = repoId ? repoById.get(repoId) : undefined + if (!repo) { + return LOCAL_EXECUTION_HOST_ID + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID + } +} + +function nonLocalEntries(slices: HostSessionSlices): [ExecutionHostId, WorkspaceSessionState][] { + return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter( + ([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined + ) +} + +/** Patch path of the debounced session writer: split the partial patch by owner + * host and patch each partition. Returns the promise for the local write so + * App.tsx can keep chaining the SSH remote-workspace upload off it. */ +export function patchWorkspaceSessionByHost( + api: SessionApi, + patch: WorkspaceSessionPatch, + state: HostPersistenceState +): Promise<void> { + const slices = splitWorkspaceSessionByHost( + patch as WorkspaceSessionState, + buildHostIdByWorktreeId(state) + ) + const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch + const localWrite = api.patch(local) + for (const [hostId, slice] of nonLocalEntries(slices)) { + // Why: a failed runtime-partition write must not reject the local chain. + void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + }) + } + return localWrite +} + +/** Synchronous full-session split for the beforeunload / quit paths. */ +export function persistWorkspaceSessionByHostSync( + api: SessionApi, + payload: WorkspaceSessionState, + state: HostPersistenceState +): void { + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + api.setSync(slices[LOCAL_EXECUTION_HOST_ID] ?? payload) + for (const [hostId, slice] of nonLocalEntries(slices)) { + api.setSync(slice, hostId) + } +} + +/** Collect the distinct runtime hosts owning any persisted repo. */ +export function listKnownRuntimeHostIds( + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] +): ExecutionHostId[] { + const hostIds = new Set<ExecutionHostId>() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + hostIds.add(parsed.id) + } + } + return [...hostIds] +} + +/** Boot-time hydration: fetch the local partition plus one partition per known + * runtime host (repos are already loaded before session hydration in App.tsx) + * and merge them into the unified session the hydrators expect. + * + * Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with + * the rest. Corrupt partitions never reach here; persistence zod-validates + * each one and falls back to defaults on the main side. */ +export async function fetchWorkspaceSessionFromHosts( + api: Pick<SessionApi, 'get'>, + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] +): Promise<WorkspaceSessionState> { + const slices: HostSessionSlices = { + [LOCAL_EXECUTION_HOST_ID]: await api.get() + } + await Promise.all( + listKnownRuntimeHostIds(repos).map(async (hostId) => { + try { + slices[hostId] = await api.get(hostId) + } catch (err) { + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) + } + }) + ) + return mergeWorkspaceSessionsFromHosts(slices) +} diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts new file mode 100644 index 00000000000..a2cff0a2eab --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from 'vitest' +import { + splitWorkspaceSessionByHost, + mergeWorkspaceSessionsFromHosts, + type HostIdByWorktreeId +} from './workspace-session-host-split' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import type { + BrowserPage, + Tab, + TerminalLayoutSnapshot, + TerminalTab, + WorkspaceSessionState +} from '../../../shared/types' + +const RUNTIME_A: ExecutionHostId = 'runtime:env-a' +const RUNTIME_B: ExecutionHostId = 'runtime:env-b' + +function makeTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeUnifiedTab(id: string, worktreeId: string): Tab { + return { + id, + entityId: id, + groupId: `group-${worktreeId}`, + worktreeId, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(): TerminalLayoutSnapshot { + return { root: { type: 'leaf', leafId: 'leaf-1' }, activeLeafId: 'leaf-1', expandedLeafId: null } +} + +function makeBrowserPage(id: string, workspaceId: string, worktreeId: string): BrowserPage { + return { + id, + workspaceId, + worktreeId, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} + +/** worktree id convention in these tests: `<host>-wt-...`, except local ones. */ +function ownerByPrefix(): HostIdByWorktreeId { + return (worktreeId: string) => { + if (worktreeId.startsWith('a-')) { + return RUNTIME_A + } + if (worktreeId.startsWith('b-')) { + return RUNTIME_B + } + return LOCAL_EXECUTION_HOST_ID + } +} + +describe('splitWorkspaceSessionByHost', () => { + it('keeps global fields only on the local slice', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'local-wt', + activeTabId: 'tab-1', + browserUrlHistory: [ + { url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 } + ], + activeConnectionIdsAtShutdown: ['ssh-target'] + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeRepoId).toBe('repo-1') + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeConnectionIdsAtShutdown).toEqual(['ssh-target']) + // No runtime slice is created when nothing is worktree-owned by it. + expect(slices[RUNTIME_A]).toBeUndefined() + }) + + it('routes worktree-keyed maps to their owner host', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + 'local-wt': [makeTab('t-local', 'local-wt')], + 'a-wt': [makeTab('t-a', 'a-wt')], + 'b-wt': [makeTab('t-b', 'b-wt')] + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(Object.keys(slices[LOCAL_EXECUTION_HOST_ID]?.tabsByWorktree ?? {})).toEqual(['local-wt']) + expect(Object.keys(slices[RUNTIME_A]?.tabsByWorktree ?? {})).toEqual(['a-wt']) + expect(Object.keys(slices[RUNTIME_B]?.tabsByWorktree ?? {})).toEqual(['b-wt']) + }) + + it('routes tab-keyed maps via the owning tab worktree (legacy + unified)', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] }, + unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] }, + terminalLayoutsByTabId: { 't-a': makeLayout(), 't-b': makeLayout() }, + remoteSessionIdsByTabId: { 't-a': 'sess-a', 't-b': 'sess-b' } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a') + expect(slices[RUNTIME_B]?.terminalLayoutsByTabId).toHaveProperty('t-b') + expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' }) + expect(slices[RUNTIME_B]?.remoteSessionIdsByTabId).toEqual({ 't-b': 'sess-b' }) + }) + + it('keeps orphan tab layouts (unknown worktree) in the local slice', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { orphan: makeLayout() } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan') + expect(slices[RUNTIME_A]).toBeUndefined() + }) + + it('routes browser pages via their record worktreeId', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + browserPagesByWorkspace: { + 'ws-a': [makeBrowserPage('p-a', 'ws-a', 'a-wt')], + 'ws-local': [makeBrowserPage('p-local', 'ws-local', 'local-wt')] + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.browserPagesByWorkspace).toHaveProperty('ws-a') + expect(slices[LOCAL_EXECUTION_HOST_ID]?.browserPagesByWorkspace).toHaveProperty('ws-local') + }) + + it('routes markdown frontmatter visibility via the open file worktree', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + 'a-wt': [ + { + filePath: '/a/file.md', + relativePath: 'file.md', + worktreeId: 'a-wt', + language: 'markdown' + } + ] + }, + markdownFrontmatterVisible: { '/a/file.md': true, '/unknown.md': true } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.markdownFrontmatterVisible).toEqual({ '/a/file.md': true }) + // Unknown file id has no owner → stays local. + expect(slices[LOCAL_EXECUTION_HOST_ID]?.markdownFrontmatterVisible).toEqual({ + '/unknown.md': true + }) + }) + + it('partitions activeWorktreeIdsOnShutdown by owner', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt', 'local-wt'] + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.activeWorktreeIdsOnShutdown).toEqual(['a-wt']) + expect(slices[RUNTIME_B]?.activeWorktreeIdsOnShutdown).toEqual(['b-wt']) + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeWorktreeIdsOnShutdown).toEqual(['local-wt']) + }) + + it('routes sleeping agent records via their worktreeId', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'pane-a': { + paneKey: 'pane-a', + worktreeId: 'a-wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 'x' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 2 + } + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.sleepingAgentSessionsByPaneKey).toHaveProperty('pane-a') + }) +}) + +describe('mergeWorkspaceSessionsFromHosts', () => { + it('takes global fields from the local slice', () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: { + ...getDefaultWorkspaceSession(), + activeRepoId: 'local-repo', + activeWorktreeId: 'local-wt' + }, + [RUNTIME_A]: { + ...getDefaultWorkspaceSession(), + // A non-local slice's global fields must lose to local. + activeRepoId: 'runtime-repo', + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] } + } + }) + + expect(merged.activeRepoId).toBe('local-repo') + expect(merged.tabsByWorktree).toHaveProperty('a-wt') + }) + + it('falls back to a non-local slice for globals when local is absent', () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [RUNTIME_A]: { + ...getDefaultWorkspaceSession(), + activeRepoId: 'runtime-repo' + } + }) + expect(merged.activeRepoId).toBe('runtime-repo') + }) + + it('tolerates missing and empty slices', () => { + const merged = mergeWorkspaceSessionsFromHosts({}) + expect(merged.tabsByWorktree).toBeUndefined() + expect(() => mergeWorkspaceSessionsFromHosts({ [RUNTIME_A]: undefined })).not.toThrow() + }) +}) + +describe('split → merge round trip', () => { + function roundTrip(state: WorkspaceSessionState): WorkspaceSessionState { + return mergeWorkspaceSessionsFromHosts(splitWorkspaceSessionByHost(state, ownerByPrefix())) + } + + it('preserves a representative multi-host state', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'a-wt', + activeTabId: 't-a', + tabsByWorktree: { + 'local-wt': [makeTab('t-local', 'local-wt')], + 'a-wt': [makeTab('t-a', 'a-wt')], + 'b-wt': [makeTab('t-b', 'b-wt')] + }, + unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] }, + terminalLayoutsByTabId: { 't-local': makeLayout(), 't-a': makeLayout(), 't-b': makeLayout() }, + remoteSessionIdsByTabId: { 't-a': 'sess-a' }, + activeTabIdByWorktree: { 'local-wt': 't-local', 'a-wt': 't-a' }, + activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt'], + lastVisitedAtByWorktreeId: { 'a-wt': 10, 'local-wt': 5 }, + defaultTerminalTabsAppliedByWorktreeId: { 'a-wt': true }, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + browserUrlHistory: [ + { url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 } + ] + } + + expect(roundTrip(state)).toEqual(state) + }) + + it('preserves the default (empty) session', () => { + const state = getDefaultWorkspaceSession() + expect(roundTrip(state)).toEqual(state) + }) + + it('keeps orphan-owned entries in local and preserves them', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { orphan: makeLayout() }, + remoteSessionIdsByTabId: { orphan: 'sess' } + } + const result = roundTrip(state) + expect(result.terminalLayoutsByTabId).toEqual(state.terminalLayoutsByTabId) + expect(result.remoteSessionIdsByTabId).toEqual(state.remoteSessionIdsByTabId) + }) + + it('handles a host with only runtime-owned worktrees (empty local maps)', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] }, + terminalLayoutsByTabId: { 't-a': makeLayout() } + } + expect(roundTrip(state)).toEqual(state) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts new file mode 100644 index 00000000000..2be516f48cc --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -0,0 +1,379 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' + +/** + * Split / merge the unified WorkspaceSessionState across per-host partitions. + * + * Persistence stores one session slice per execution host (see + * src/main/persistence.ts host-keyed getWorkspaceSession/setWorkspaceSession). + * The renderer holds a single unified session, so before writing it must + * partition each worktree-scoped slice to its owning host, and on hydration it + * must merge the per-host slices back into one. + * + * Field classification lives in FIELD_OWNERSHIP below and is checked for + * exhaustiveness at compile time, mirroring SESSION_RELEVANT_FIELDS in + * workspace-session.ts. The remote-workspace SSH projection + * (src/shared/remote-workspace-session-projection.ts) enumerates the same + * worktree/tab-scoped fields by worktree-path; the two surfaces are kept + * deliberately aligned — when a new worktree-scoped field is added there it + * must be classified here too. + */ + +export type HostSessionSlices = Partial<Record<ExecutionHostId, WorkspaceSessionState>> + +export type HostIdByWorktreeId = (worktreeId: string) => ExecutionHostId + +/** How a WorkspaceSessionState field is partitioned across hosts. + * - global: client-wide; always stays in the 'local' slice. + * - worktreeKeyed: Record keyed by worktree id; each entry goes to its owner. + * - worktreeArray: array of worktree ids; each id goes to its owner. + * - tabKeyed: Record keyed by tab id; follows the owning tab's worktree. + * - browserWorkspaceKeyed: Record keyed by browser-workspace id; follows the + * page record's own worktreeId. + * - fileKeyed: Record keyed by editor file id; follows the open file's worktree. + * - sleepingAgentKeyed: Record keyed by pane key; follows the record's worktreeId. */ +type FieldOwnership = + | 'global' + | 'worktreeKeyed' + | 'worktreeArray' + | 'tabKeyed' + | 'browserWorkspaceKeyed' + | 'fileKeyed' + | 'sleepingAgentKeyed' + +const FIELD_OWNERSHIP = { + activeRepoId: 'global', + activeWorktreeId: 'global', + activeTabId: 'global', + browserUrlHistory: 'global', + // Why: SSH-connection ids, not worktrees. SSH stays in the local blob today + // (the runtime split intentionally leaves SSH ownership unchanged), so this + // reconnect list rides along in 'local'. + activeConnectionIdsAtShutdown: 'global', + tabsByWorktree: 'worktreeKeyed', + openFilesByWorktree: 'worktreeKeyed', + activeFileIdByWorktree: 'worktreeKeyed', + activeBrowserTabIdByWorktree: 'worktreeKeyed', + activeTabTypeByWorktree: 'worktreeKeyed', + activeTabIdByWorktree: 'worktreeKeyed', + browserTabsByWorktree: 'worktreeKeyed', + unifiedTabs: 'worktreeKeyed', + tabGroups: 'worktreeKeyed', + tabGroupLayouts: 'worktreeKeyed', + activeGroupIdByWorktree: 'worktreeKeyed', + lastVisitedAtByWorktreeId: 'worktreeKeyed', + defaultTerminalTabsAppliedByWorktreeId: 'worktreeKeyed', + activeWorkspaceKey: 'global', + activeWorktreeIdsOnShutdown: 'worktreeArray', + terminalLayoutsByTabId: 'tabKeyed', + remoteSessionIdsByTabId: 'tabKeyed', + browserPagesByWorkspace: 'browserWorkspaceKeyed', + markdownFrontmatterVisible: 'fileKeyed', + sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed' +} as const satisfies Record<keyof WorkspaceSessionState, FieldOwnership> + +// Why: a new WorkspaceSessionState field must be classified above or the split +// would silently drop it from every non-local host. This fails compilation +// until the table is updated, mirroring the _exhaustive guard in +// workspace-session.ts. +type _MissingOwnership = Exclude<keyof WorkspaceSessionState, keyof typeof FIELD_OWNERSHIP> +const _exhaustive: [_MissingOwnership] extends [never] ? true : never = true +void _exhaustive + +const GLOBAL_FIELDS = (Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]).filter( + (field) => FIELD_OWNERSHIP[field] === 'global' +) + +type AnyRecord = Record<string, unknown> + +function isPlainRecord(value: unknown): value is AnyRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** Build tabId → worktreeId from both the legacy and unified tab models so + * tab-keyed maps (terminal layouts, remote session ids) follow their tab. */ +function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map<string, string> { + const byTab = new Map<string, string>() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { + for (const tab of tabs) { + byTab.set(tab.id, worktreeId) + } + } + // Why: unified tabs carry their own worktreeId; index it too so layouts for a + // tab that exists only in the unified model still resolve to an owner. + for (const tabs of Object.values(state.unifiedTabs ?? {})) { + for (const tab of tabs) { + if (!byTab.has(tab.id)) { + byTab.set(tab.id, tab.worktreeId) + } + } + } + return byTab +} + +/** Build editor-file id → worktreeId so markdownFrontmatterVisible (keyed by + * file id) follows the file's worktree. */ +function buildWorktreeIdByFileId(state: WorkspaceSessionState): Map<string, string> { + const byFile = new Map<string, string>() + for (const files of Object.values(state.openFilesByWorktree ?? {})) { + for (const file of files) { + // PersistedOpenFile.filePath is the editor tab/file id used elsewhere. + byFile.set(file.filePath, file.worktreeId) + } + } + return byFile +} + +type SplitContext = { + hostIdByWorktreeId: HostIdByWorktreeId + worktreeIdByTabId: Map<string, string> + worktreeIdByFileId: Map<string, string> +} + +function ensureSlice( + slices: HostSessionSlices, + hostId: ExecutionHostId, + template: WorkspaceSessionState +): WorkspaceSessionState { + let slice = slices[hostId] + if (!slice) { + // Why: clone the global fields onto every slice so a partition read in + // isolation still carries the active pointers; merge later prefers 'local'. + slice = { ...template } + slices[hostId] = slice + } + return slice +} + +function hostForWorktree( + ctx: SplitContext, + worktreeId: string | undefined +): ExecutionHostId | null { + if (!worktreeId) { + return null + } + return ctx.hostIdByWorktreeId(worktreeId) +} + +function assignWorktreeKeyed( + slices: HostSessionSlices, + template: WorkspaceSessionState, + field: keyof WorkspaceSessionState, + value: unknown, + ctx: SplitContext +): void { + if (!isPlainRecord(value)) { + return + } + for (const [worktreeId, entry] of Object.entries(value)) { + const host = ctx.hostIdByWorktreeId(worktreeId) + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= {}) as AnyRecord + target[worktreeId] = entry + } +} + +function assignKeyedByResolvedWorktree( + slices: HostSessionSlices, + template: WorkspaceSessionState, + field: keyof WorkspaceSessionState, + value: unknown, + resolveWorktreeId: (key: string, entry: unknown) => string | undefined, + ctx: SplitContext +): void { + if (!isPlainRecord(value)) { + return + } + for (const [key, entry] of Object.entries(value)) { + const worktreeId = resolveWorktreeId(key, entry) + const host = hostForWorktree(ctx, worktreeId) ?? LOCAL_EXECUTION_HOST_ID + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= {}) as AnyRecord + target[key] = entry + } +} + +/** Partition a unified session into per-host slices keyed by ExecutionHostId. + * Global fields are copied to the 'local' slice; worktree-scoped data is routed + * to its owner host. Entries whose owning worktree is unknown (orphan tabs, + * files, pages) stay in 'local' so they are never silently dropped. */ +export function splitWorkspaceSessionByHost( + state: WorkspaceSessionState, + hostIdByWorktreeId: HostIdByWorktreeId +): HostSessionSlices { + // Template carries only the global fields; per-field assigners add the rest. + // Why: copy only own-keys so a partial patch (where most globals are absent) + // does not inject `undefined` values that would clobber persisted state when + // the slice is applied as a patch. Intentional `undefined` keys are preserved. + const template = {} as WorkspaceSessionState + for (const field of GLOBAL_FIELDS) { + if (Object.hasOwn(state, field)) { + ;(template as AnyRecord)[field] = state[field] + } + } + + const slices: HostSessionSlices = {} + // Why: 'local' must always exist — it owns the global fields and is the + // hydration anchor even when every worktree belongs to a runtime host. + ensureSlice(slices, LOCAL_EXECUTION_HOST_ID, template) + + const ctx: SplitContext = { + hostIdByWorktreeId, + worktreeIdByTabId: buildWorktreeIdByTabId(state), + worktreeIdByFileId: buildWorktreeIdByFileId(state) + } + + const localSlice = slices[LOCAL_EXECUTION_HOST_ID] as AnyRecord + + for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { + const ownership = FIELD_OWNERSHIP[field] + const value = state[field] + if (value === undefined) { + continue + } + // Why: a present-but-empty container ({} / []) must survive the round trip. + // Seed it on 'local' so merge reproduces the field instead of dropping it. + if (ownership !== 'global') { + localSlice[field] ??= Array.isArray(value) ? [] : {} + } + switch (ownership) { + case 'global': + // Already on the template / local slice. + break + case 'worktreeKeyed': + assignWorktreeKeyed(slices, template, field, value, ctx) + break + case 'worktreeArray': { + if (!Array.isArray(value)) { + break + } + for (const worktreeId of value as string[]) { + const host = ctx.hostIdByWorktreeId(worktreeId) + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= []) as string[] + target.push(worktreeId) + } + break + } + case 'tabKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (tabId) => ctx.worktreeIdByTabId.get(tabId), + ctx + ) + break + case 'fileKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (fileId) => ctx.worktreeIdByFileId.get(fileId), + ctx + ) + break + case 'browserWorkspaceKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (_workspaceId, pages) => { + const first = Array.isArray(pages) + ? (pages[0] as { worktreeId?: string } | undefined) + : undefined + return first?.worktreeId + }, + ctx + ) + break + case 'sleepingAgentKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (_paneKey, record) => + isPlainRecord(record) && typeof record.worktreeId === 'string' + ? record.worktreeId + : undefined, + ctx + ) + break + } + } + + return slices +} + +function mergeRecordField( + out: AnyRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!isPlainRecord(value)) { + return + } + const target = (out[field] ??= {}) as AnyRecord + Object.assign(target, value) +} + +function mergeArrayField( + out: AnyRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!Array.isArray(value)) { + return + } + const target = (out[field] ??= []) as unknown[] + target.push(...value) +} + +/** Inverse of split: combine per-host slices into one unified session. Global + * fields are taken from the 'local' slice (it owns them); worktree/tab-scoped + * maps are unioned across all hosts. Tolerates missing or partial slices. */ +export function mergeWorkspaceSessionsFromHosts(slices: HostSessionSlices): WorkspaceSessionState { + const out = {} as WorkspaceSessionState + const local = slices[LOCAL_EXECUTION_HOST_ID] + + // Global fields: 'local' wins. Fall back to any slice that has them so a + // standalone non-local slice still yields sane active pointers. + for (const field of GLOBAL_FIELDS) { + const fromLocal = local?.[field] + if (fromLocal !== undefined) { + ;(out as AnyRecord)[field] = fromLocal + continue + } + for (const slice of Object.values(slices)) { + if (slice && slice[field] !== undefined) { + ;(out as AnyRecord)[field] = slice[field] + break + } + } + } + + for (const slice of Object.values(slices)) { + if (!slice) { + continue + } + for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { + const ownership = FIELD_OWNERSHIP[field] + if (ownership === 'global') { + continue + } + if (ownership === 'worktreeArray') { + mergeArrayField(out as AnyRecord, field, slice) + } else { + mergeRecordField(out as AnyRecord, field, slice) + } + } + } + + return out +} diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index ab671c9c346..72a77c8b123 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -415,6 +415,76 @@ describe('activateAndRevealWorktree created agent reopen', () => { }) }) + it('activates the explicit owner runtime when another runtime is focused', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: true, + result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + activeRepoId: 'repo-1', + activeView: 'terminal', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'focused-runtime', + setupScriptLaunchMode: 'new-tab' + } as unknown as ReturnType<typeof useAppStore.getState>['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + }) + + const result = activateAndRevealWorktree(worktree.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toEqual({ primaryTabId: null }) + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'worktree.activate' + }) + ) + expect(callRuntimeEnvironment).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'worktree.activate' + }) + ) + }) + it('does not echo host-originated runtime activation events back to the host', async () => { const worktree = makeWorktree() const callRuntimeEnvironment = vi.fn().mockResolvedValue({ @@ -634,4 +704,74 @@ describe('activateAndRevealWorktree created agent reopen', () => { }) ) }) + + it('respawns wake terminals on the explicit owner runtime when focus changed', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: true, + result: { tabId: 'host-tab-1', terminal: 'term_host' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + tabsByWorktree: { + [worktree.id]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: worktree.id, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': [] }, + settings: { + ...getDefaultSettings('/workspace/.orca-workspaces'), + activeRuntimeEnvironmentId: 'focused-runtime' + }, + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: 'tab-1' + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'session.tabs.createTerminal' + }) + ) + expect(callRuntimeEnvironment).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'session.tabs.createTerminal' + }) + ) + }) }) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 04bec5792aa..7321641de5c 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -176,6 +176,27 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.setActiveTab).not.toHaveBeenCalled() }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + const store = createMockStore({ + settings: { activeRuntimeEnvironmentId: 'web-runtime-1' }, + repos: [{ id: 'repo-1', executionHostId: 'local', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1') + + expect(result).toBe('tab-1') + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + }) + it('does not create or queue anything when the worktree already has renderable content', () => { const store = createMockStore({ reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b4cf018069c..b4cee8005a8 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -38,6 +38,10 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../../shared/tui-agent-config' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { folderWorkspaceActivationBlocked, @@ -68,7 +72,7 @@ export type IssueCommandLaunch = | WorktreeSetupLaunch | { command: string; env?: Record<string, string> } -type WorktreeActivationStore = { +type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & { tabsByWorktree: Record<string, { id: string }[]> defaultTerminalTabsAppliedByWorktreeId: Record<string, true> createTab: ( @@ -264,14 +268,16 @@ export function activateAndRevealWorktree( // 3. Core activation: sets activeWorktreeId, restores per-worktree state, // clears unread, bumps dead PTY generations, triggers GitHub refresh state.setActiveWorktree(worktreeId) - if ( - opts?.notifyHostRuntime !== false && - isWebRuntimeSessionActive(useAppStore.getState().settings?.activeRuntimeEnvironmentId) - ) { + const postActivationState = useAppStore.getState() + const ownerRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(postActivationState, wt.id) + if (opts?.notifyHostRuntime !== false && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)) { // Why: paired web clients own only local selection state. The desktop host // must also activate the worktree so hidden renderer-owned terminal panes // mount and publish session surfaces back to the web client. - void activateWebRuntimeSessionWorktree({ worktreeId }) + void activateWebRuntimeSessionWorktree({ + worktreeId, + environmentId: ownerRuntimeEnvironmentId + }) } // Why: record focus recency for Cmd+J's empty-query ordering BEFORE any @@ -330,7 +336,11 @@ export function activateAndRevealWorktree( export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { const state = useAppStore.getState() - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const worktree = state.getKnownWorktreeById(worktreeId) + if (!worktree) { + return + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id) if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { return } @@ -394,9 +404,11 @@ export function ensureWorktreeHasInitialTerminal( } // Why: remote web clients mirror the runtime server's session tabs. A local // activation fallback can spawn a second host terminal before the mirror lands. - if ( - isWebRuntimeSessionActive(useAppStore.getState().settings?.activeRuntimeEnvironmentId ?? null) - ) { + const ownerState = + store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined + ? store + : useAppStore.getState() + if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { return null } @@ -570,8 +582,14 @@ setWorktreeNavViewActivator((entry) => { taskPageData: { ...state.taskPageData, openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) return @@ -584,8 +602,55 @@ setWorktreeNavViewActivator((entry) => { taskSource: 'github', preselectedRepoId: entry.workItem.repoId, openGitHubWorkItem: entry.workItem, + openGitHubSourceContext: entry.sourceContext, openGitHubInitialTab: entry.initialTab, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined + } + })) + return + } + if (entry.source === 'gitlab') { + useAppStore.setState((state) => ({ + activeView: 'tasks', + githubTaskDrawerWorkItem: null, + taskPageData: { + ...state.taskPageData, + taskSource: 'gitlab', + preselectedRepoId: entry.workItem.repoId, + openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, + openGitHubInitialTab: undefined, + openGitLabWorkItem: entry.workItem, + openGitLabSourceContext: entry.sourceContext, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined + } + })) + return + } + if (entry.source === 'jira') { + useAppStore.setState((state) => ({ + activeView: 'tasks', + githubTaskDrawerWorkItem: null, + taskPageData: { + ...state.taskPageData, + taskSource: 'jira', + openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, + openGitHubInitialTab: undefined, + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: entry.issue, + openJiraSourceContext: entry.sourceContext } })) return @@ -597,8 +662,14 @@ setWorktreeNavViewActivator((entry) => { ...state.taskPageData, taskSource: 'linear', openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: entry.issue + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: entry.issue, + openLinearSourceContext: entry.sourceContext, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) }) diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index 876f3850344..3ccbc59ff41 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -1,4 +1,5 @@ import { branchName } from '@/lib/git-utils' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import type { Repo, Worktree } from '../../../shared/types' export type MatchRange = { start: number; end: number } @@ -301,7 +302,16 @@ export function searchWorktrees( continue } - const issueKey = repo ? `${repo.path}::${worktree.linkedIssue}` : '' + const issueKey = repo + ? getIssueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + undefined, + repo.connectionId, + repo.executionHostId + ) + : '' const issue = issueKey && issueCache ? issueCache[issueKey]?.data : undefined if (!issue?.title) { continue diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts new file mode 100644 index 00000000000..6c10f729577 --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + getSettingsForWorktreeRuntimeOwner, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +describe('getSettingsForWorktreeRuntimeOwner', () => { + it('routes to the runtime owner of the worktree', () => { + expect(getSettingsForWorktreeRuntimeOwner(state, 'runtime-repo::wt-b')).toEqual({ + activeRuntimeEnvironmentId: 'owner-env' + }) + }) + + it('keeps explicit-local worktrees local even while a runtime is focused', () => { + expect(getSettingsForWorktreeRuntimeOwner(state, 'local-repo::wt-a')).toEqual({ + activeRuntimeEnvironmentId: null + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts new file mode 100644 index 00000000000..e004bca2cfa --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -0,0 +1,69 @@ +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { GlobalSettings, Repo, Worktree } from '../../../shared/types' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' + +export type WorktreeRuntimeOwnerState = { + repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null + worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]> +} + +function findWorktreeRepoId( + worktreesByRepo: WorktreeRuntimeOwnerState['worktreesByRepo'], + worktreeId: string +): string | null { + for (const worktrees of Object.values(worktreesByRepo ?? {})) { + const match = worktrees.find((worktree) => worktree.id === worktreeId) + if (match) { + return match.repoId + } + } + return null +} + +export function getRuntimeEnvironmentIdForWorktree( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + const repoId = + findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null + } + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getExecutionHostIdForWorktree( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): ExecutionHostId { + if (!worktreeId) { + return 'local' + } + const repoId = + findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + return getRepoExecutionHostId(repo) + } + const environmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local' +} + +export function getSettingsForWorktreeRuntimeOwner( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return { + ...state.settings, + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) + } +} diff --git a/src/renderer/src/lib/worktree-sort-order-host-split.test.ts b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts new file mode 100644 index 00000000000..5bc20ca4f0c --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split' + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +describe('splitWorktreeSortOrderByHost', () => { + it('groups worktree ids by owner host, preserving relative order', () => { + const groups = splitWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + expect(groups).toEqual([ + { hostId: 'runtime:env-1', orderedIds: ['runtime-repo::wt-b'] }, + { hostId: 'local', orderedIds: ['local-repo::wt-a'] } + ]) + }) + + it('routes legacy worktrees without an explicit owner to the focused host', () => { + const groups = splitWorktreeSortOrderByHost( + { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [{ id: 'legacy', connectionId: null, executionHostId: null }], + worktreesByRepo: { legacy: [{ id: 'legacy::wt', repoId: 'legacy' }] } + }, + ['legacy::wt'] + ) + expect(groups).toEqual([{ hostId: 'runtime:focused-env', orderedIds: ['legacy::wt'] }]) + }) +}) diff --git a/src/renderer/src/lib/worktree-sort-order-host-split.ts b/src/renderer/src/lib/worktree-sort-order-host-split.ts new file mode 100644 index 00000000000..008f7486274 --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-host-split.ts @@ -0,0 +1,36 @@ +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' + +export type WorktreeSortOrderHostGroup = { + hostId: string + orderedIds: string[] +} + +/** Split a worktree sort order into per-owner-host groups (preserving relative + * order within each host). + * + * Why: persisted `sortOrder` lives in each host's `worktreeMeta` and is enriched + * onto worktrees from their owner host. Stamping the full cross-host id list on + * only the focused host loses other hosts' ordering and pollutes the focused + * host with foreign ids, so persist each host's ids on that host. + */ +export function splitWorktreeSortOrderByHost( + state: WorktreeRuntimeOwnerState, + orderedIds: readonly string[] +): WorktreeSortOrderHostGroup[] { + const groups = new Map<string, string[]>() + for (const id of orderedIds) { + const environmentId = getRuntimeEnvironmentIdForWorktree(state, id) + const hostId = environmentId ? toRuntimeExecutionHostId(environmentId) : LOCAL_EXECUTION_HOST_ID + const existing = groups.get(hostId) + if (existing) { + existing.push(id) + } else { + groups.set(hostId, [id]) + } + } + return [...groups.entries()].map(([hostId, ids]) => ({ hostId, orderedIds: ids })) +} diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts index eb1bff0c680..ccb20944e58 100644 --- a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -2,6 +2,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' @@ -23,7 +24,8 @@ export function createCompatibleRuntimeStatusResponse( liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [...RUNTIME_CAPABILITIES] }, _meta: { runtimeId } } diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index e2c6d82aaa7..24dcd410388 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -18,17 +18,36 @@ import type { JiraViewer } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../shared/task-source-context' export type RuntimeJiraSettings = | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | TaskSourceContext | null | undefined export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string } export type JiraCommentResult = { ok: true; id: string } | { ok: false; error: string } +function isTaskSourceRuntimeSettings(settings: RuntimeJiraSettings): settings is TaskSourceContext { + return settings !== null && settings !== undefined && 'kind' in settings +} + +function getJiraRuntimeTarget( + settings: RuntimeJiraSettings +): ReturnType<typeof getActiveRuntimeTarget> { + // Why: task source context makes provider ownership explicit; legacy callers + // still pass focused runtime settings until Tasks finishes migrating. + return getActiveRuntimeTarget( + isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings + ) +} + export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectionStatus>(target, 'jira.status', undefined, { timeoutMs: 15_000 }) : window.api.jira.status() @@ -38,7 +57,7 @@ export async function jiraConnect( settings: RuntimeJiraSettings, args: { siteUrl: string; email: string; apiToken: string } ): Promise<JiraConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectResult>(target, 'jira.connect', args, { timeoutMs: 30_000 }) : window.api.jira.connect(args) @@ -48,7 +67,7 @@ export async function jiraDisconnect( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<void> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) if (target.kind === 'environment') { await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, { timeoutMs: 15_000 @@ -62,7 +81,7 @@ export async function jiraSelectSite( settings: RuntimeJiraSettings, siteId: JiraSiteSelection ): Promise<JiraConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectionStatus>( target, @@ -77,7 +96,7 @@ export async function jiraTestConnection( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<JiraConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectResult>( target, @@ -94,7 +113,7 @@ export async function jiraSearchIssues( limit?: number, siteId?: JiraSiteSelection | null ): Promise<JiraIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { jql, limit, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue[]>(target, 'jira.searchIssues', args, { timeoutMs: 30_000 }) @@ -107,7 +126,7 @@ export async function jiraListIssues( limit?: number, siteId?: JiraSiteSelection | null ): Promise<JiraIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { filter, limit, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 }) @@ -119,7 +138,7 @@ export async function jiraGetIssue( key: string, siteId?: string | null ): Promise<JiraIssue | null> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue | null>(target, 'jira.getIssue', args, { timeoutMs: 30_000 }) @@ -130,7 +149,7 @@ export async function jiraCreateIssue( settings: RuntimeJiraSettings, args: JiraCreateIssueArgs ): Promise<JiraCreateIssueResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraCreateIssueResult>(target, 'jira.createIssue', args, { timeoutMs: 30_000 }) : window.api.jira.createIssue(args) @@ -142,7 +161,7 @@ export async function jiraUpdateIssue( updates: JiraIssueUpdate, siteId?: string | null ): Promise<JiraMutationResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, updates, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraMutationResult>(target, 'jira.updateIssue', args, { timeoutMs: 30_000 }) @@ -155,7 +174,7 @@ export async function jiraAddIssueComment( body: string, siteId?: string | null ): Promise<JiraCommentResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, body, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraCommentResult>(target, 'jira.addIssueComment', args, { @@ -169,7 +188,7 @@ export async function jiraIssueComments( key: string, siteId?: string | null ): Promise<JiraComment[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraComment[]>(target, 'jira.issueComments', args, { timeoutMs: 30_000 }) @@ -180,7 +199,7 @@ export async function jiraListProjects( settings: RuntimeJiraSettings, siteId?: JiraSiteSelection | null ): Promise<JiraProject[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, { timeoutMs: 30_000 @@ -193,7 +212,7 @@ export async function jiraListIssueTypes( projectIdOrKey: string, siteId?: string | null ): Promise<JiraIssueType[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { projectIdOrKey, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssueType[]>(target, 'jira.listIssueTypes', args, { timeoutMs: 30_000 }) @@ -206,7 +225,7 @@ export async function jiraListCreateFields( issueTypeId: string, siteId?: string | null ): Promise<JiraCreateField[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { projectIdOrKey, issueTypeId, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraCreateField[]>(target, 'jira.listCreateFields', args, { @@ -219,7 +238,7 @@ export async function jiraListPriorities( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<JiraPriority[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraPriority[]>( target, @@ -236,7 +255,7 @@ export async function jiraListAssignableUsers( query?: string, siteId?: string | null ): Promise<JiraUser[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, query, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraUser[]>(target, 'jira.listAssignableUsers', args, { timeoutMs: 30_000 }) @@ -248,7 +267,7 @@ export async function jiraListTransitions( key: string, siteId?: string | null ): Promise<JiraTransition[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraTransition[]>(target, 'jira.listTransitions', args, { timeoutMs: 30_000 }) diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 65d909a80ab..706279f1196 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -20,9 +20,14 @@ import type { LinearWorkflowState } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../shared/task-source-context' export type RuntimeLinearSettings = | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | TaskSourceContext | null | undefined @@ -42,6 +47,22 @@ function linearReadForce(options?: LinearReadOptions): { force: true } | {} { return options?.force ? { force: true } : {} } +function isTaskSourceRuntimeSettings( + settings: RuntimeLinearSettings +): settings is TaskSourceContext { + return settings !== null && settings !== undefined && 'kind' in settings +} + +function getLinearRuntimeTarget( + settings: RuntimeLinearSettings +): ReturnType<typeof getActiveRuntimeTarget> { + // Why: task source context makes provider ownership explicit; legacy callers + // still pass focused runtime settings until Tasks finishes migrating. + return getActiveRuntimeTarget( + isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings + ) +} + function normalizeLinearIssueCollectionResult( result: unknown ): LinearCollectionResult<LinearIssue> { @@ -65,7 +86,7 @@ function normalizeLinearIssueCollectionResult( export async function linearStatus( settings: RuntimeLinearSettings ): Promise<LinearConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectionStatus>(target, 'linear.status', undefined, { timeoutMs: 15_000 @@ -77,7 +98,7 @@ export async function linearTestConnection( settings: RuntimeLinearSettings, workspaceId?: string | null ): Promise<LinearConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectResult>( target, @@ -94,7 +115,7 @@ export async function linearConnect( settings: RuntimeLinearSettings, apiKey: string ): Promise<LinearConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectResult>( target, @@ -113,7 +134,7 @@ export async function linearDisconnectWorkspace( settings: RuntimeLinearSettings, workspaceId?: string | null ): Promise<void> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) if (target.kind === 'environment') { await callRuntimeRpc<{ ok: true }>( target, @@ -132,7 +153,7 @@ export async function linearSelectWorkspace( settings: RuntimeLinearSettings, workspaceId: LinearWorkspaceSelection ): Promise<LinearConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectionStatus>( target, @@ -149,7 +170,7 @@ export async function linearSearchIssues( limit?: number, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearIssue[]>( target, @@ -166,7 +187,7 @@ export async function linearListIssues( limit?: number, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) const result = target.kind === 'environment' ? await callRuntimeRpc<unknown>( @@ -198,7 +219,7 @@ export async function linearCreateIssue( labelIds?: string[] } ): Promise<LinearCreateIssueResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCreateIssueResult>(target, 'linear.createIssue', args, { timeoutMs: 30_000 @@ -225,7 +246,7 @@ export async function linearGetIssue( id: string, workspaceId?: string | null ): Promise<LinearIssue | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearIssue | null>( target, @@ -242,7 +263,7 @@ export async function linearUpdateIssue( updates: LinearIssueUpdate, workspaceId?: string | null ): Promise<LinearMutationResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearMutationResult>( target, @@ -259,7 +280,7 @@ export async function linearAddIssueComment( body: string, workspaceId?: string | null ): Promise<LinearCommentResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCommentResult>( target, @@ -275,7 +296,7 @@ export async function linearIssueComments( issueId: string, workspaceId?: string | null ): Promise<LinearComment[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearComment[]>( target, @@ -290,7 +311,7 @@ export async function linearListTeams( settings: RuntimeLinearSettings, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearTeam[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearTeam[]>( target, @@ -308,7 +329,7 @@ export async function linearListProjects( workspaceId?: LinearWorkspaceSelection | null, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearProjectSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>( target, @@ -342,7 +363,7 @@ export async function linearCreateProject( targetDate?: string } ): Promise<LinearCreateProjectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, { timeoutMs: 30_000 @@ -356,7 +377,7 @@ export async function linearGetProject( workspaceId: string, options?: LinearReadOptions ): Promise<LinearProjectDetail | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearProjectDetail | null>( target, @@ -374,7 +395,7 @@ export async function linearListProjectIssues( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearIssue>>( target, @@ -397,7 +418,7 @@ export async function linearListCustomViews( workspaceId?: LinearWorkspaceSelection | null, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearCustomViewSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>( target, @@ -420,7 +441,7 @@ export async function linearGetCustomView( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCustomViewSummary | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCustomViewSummary | null>( target, @@ -438,7 +459,7 @@ export async function linearListCustomViewIssues( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearIssue>>( target, @@ -461,7 +482,7 @@ export async function linearListCustomViewProjects( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearProjectSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>( target, @@ -482,7 +503,7 @@ export async function linearTeamStates( teamId: string, workspaceId?: string | null ): Promise<LinearWorkflowState[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearWorkflowState[]>( target, @@ -498,7 +519,7 @@ export async function linearTeamLabels( teamId: string, workspaceId?: string | null ): Promise<LinearLabel[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearLabel[]>( target, @@ -514,7 +535,7 @@ export async function linearTeamMembers( teamId: string, workspaceId?: string | null ): Promise<LinearMember[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearMember[]>( target, diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 5e6869328e1..a7f9c225b89 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { callRuntimeRpc, + assertRuntimeEnvironmentCapability, clearRuntimeCompatibilityCacheForTests, getActiveRuntimeTarget, RuntimeRpcCallError, @@ -135,6 +136,52 @@ describe('runtime RPC client routing', () => { ]) }) + it('checks advertised runtime capabilities after protocol compatibility', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['project-host-setup.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).resolves.toBeUndefined() + }) + + it('rejects missing advertised runtime capabilities with the caller message', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).rejects.toThrow('Project setup is unavailable.') + }) + it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 79a15f8ac0d..74183cce258 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../shared/types' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' @@ -141,6 +142,35 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve()) } +export async function getRuntimeEnvironmentStatus( + environmentId: string, + timeoutMs?: number +): Promise<RuntimeStatus> { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult<RuntimeStatus>( + response as RuntimeRpcResponse<RuntimeStatus> + ) + assertRuntimeStatusCompatible(status) + markRuntimeEnvironmentCompatible(environmentId) + return status +} + +export async function assertRuntimeEnvironmentCapability( + environmentId: string, + capability: RuntimeCapability, + message: string, + timeoutMs?: number +): Promise<void> { + const status = await getRuntimeEnvironmentStatus(environmentId, timeoutMs) + if (!status.capabilities?.includes(capability)) { + throw new Error(message) + } +} + export function clearRuntimeCompatibilityCacheForTests(): void { clearRuntimeCompatibilityCache() } diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 445af07419b..7401ec55c7f 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -150,7 +150,8 @@ describe('createWebRuntimeSessionBrowserTab', () => { ) expect(mocks.createBrowserTab).toHaveBeenCalledWith(WORKTREE_ID, 'https://example.com/', { title: 'https://example.com/', - focusAddressBar: true + focusAddressBar: true, + browserRuntimeEnvironmentId: ENVIRONMENT_ID }) expect(mocks.setRemoteBrowserPageHandle).toHaveBeenCalledWith('local-page-1', { environmentId: ENVIRONMENT_ID, diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 3d8ce8cb01b..9dab68eea3a 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -182,6 +182,7 @@ function stageWebRuntimeBrowserTab(args: { const browserTab = useAppStore.getState().createBrowserTab(args.worktreeId, url, { title: url === 'about:blank' ? 'New Browser Tab' : url, focusAddressBar: true, + browserRuntimeEnvironmentId: args.environmentId, targetGroupId: args.targetGroupId }) const pageId = browserTab.activePageId ?? browserTab.pageIds?.[0] ?? null diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 73b9c5ec28e..c3714eebb09 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -824,6 +824,7 @@ function buildMirroredBrowserTabs( canGoForward: tab.canGoForward, loadError: null, createdAt, + browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null } const workspace: BrowserWorkspace = { @@ -1320,6 +1321,7 @@ function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { a.loadError?.description === b.loadError?.description && a.loadError?.validatedUrl === b.loadError?.validatedUrl && a.createdAt === b.createdAt && + a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId && a.viewportPresetId === b.viewportPresetId ) } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8e430d6e5d1..0e43297c579 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -29,6 +29,7 @@ import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' import { createDictationSlice } from './slices/dictation' import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup' +import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { e2eConfig } from '@/lib/e2e-config' @@ -64,6 +65,7 @@ export const useAppStore = create<AppState>()((...a) => ({ ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 8879b6fe1ab..231c31e5be6 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { Worktree } from '../../../shared/types' +import type { Repo, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { AppState } from './types' import { getAllWorktreesFromState, + getProjectHostSetupProjectionFromState, getWorktreeMapFromState, resetFloatingVisibleTabCountSelectorCacheForTest, selectFloatingVisibleTabCount @@ -31,6 +33,15 @@ function makeWorktree(args: { id: string; repoId: string; displayName: string }) } } +function makeRepo(args: Pick<Repo, 'id' | 'path' | 'displayName'> & Partial<Repo>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...args + } +} + describe('store selectors', () => { beforeEach(() => { resetFloatingVisibleTabCountSelectorCacheForTest() @@ -156,4 +167,147 @@ describe('store selectors', () => { expect(selectFloatingVisibleTabCount({ ...state })).toBe(3) expect(openFileScans).toBe(1) }) + + it('caches the project host setup projection by repo slice identity', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const state = { repos } + + const projection = getProjectHostSetupProjectionFromState(state) + + expect(projection.projects).toHaveLength(1) + expect(projection.setups[0]).toMatchObject({ + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local' + }) + expect(getProjectHostSetupProjectionFromState({ repos })).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ repos: [...repos] })).not.toBe(projection) + }) + + it('prefers hydrated project host setup state when present', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const projects = [ + { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] + const projectHostSetups = [ + { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local' as const, + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 1, + updatedAt: 1 + } + ] + + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toEqual({ + projects, + setups: projectHostSetups + }) + }) + + it('falls back to repo compatibility projection when hydrated setup state is empty', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [], + projectHostSetups: [] + }) + + expect(projection.projects).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + sourceRepoIds: ['repo-1'] + }) + ]) + expect(projection.setups).toEqual([ + expect.objectContaining({ + id: 'repo-1', + projectId: 'github:stablyai/orca', + repoId: 'repo-1', + hostId: 'local', + path: '/Users/alice/orca' + }) + ]) + }) + + it('merges missing repo compatibility rows with independent hydrated setups', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const projects = [ + { + id: 'cloud-project', + displayName: 'Cloud Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + } + ] + const projectHostSetups = [ + { + id: 'cloud-project::gpu-vm', + projectId: 'cloud-project', + hostId: toRuntimeExecutionHostId('gpu-vm'), + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM', + setupState: 'ready' as const, + setupMethod: 'provisioned' as const, + createdAt: 1, + updatedAt: 1 + } + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects, + projectHostSetups + }) + + expect(projection.projects.map((project) => project.id)).toEqual([ + 'repo:repo-1', + 'cloud-project' + ]) + expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe( + projection + ) + }) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 45e7e3bcd49..53a03d2e3d9 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -1,8 +1,12 @@ import { useAppStore } from './index' import { useShallow } from 'zustand/react/shallow' -import type { Repo, Worktree, TerminalTab } from '../../../shared/types' +import type { Project, ProjectHostSetup, Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../shared/project-host-setup-projection' const EMPTY_WORKTREES: Worktree[] = [] const EMPTY_TABS: TerminalTab[] = [] @@ -31,6 +35,15 @@ type FloatingVisibleTabCountCache = { const worktreeSnapshotCache = new WeakMap<AppState['worktreesByRepo'], WorktreeSnapshot>() const hasAnyWorktreesCache = new WeakMap<AppState['worktreesByRepo'], boolean>() const repoMapCache = new WeakMap<AppState['repos'], Map<string, Repo>>() +const projectHostSetupProjectionCache = new WeakMap<AppState['repos'], ProjectHostSetupProjection>() +const providedProjectHostSetupProjectionCache = new WeakMap< + Project[], + WeakMap<ProjectHostSetup[], ProjectHostSetupProjection> +>() +const mergedProjectHostSetupProjectionCache = new WeakMap< + AppState['repos'], + WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>> +>() let floatingVisibleTabCountCache: FloatingVisibleTabCountCache | null = null function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot { @@ -94,6 +107,85 @@ function getCachedRepoMap(repos: AppState['repos']): Map<string, Repo> { return repoMap } +function getCachedProjectHostSetupProjection(repos: AppState['repos']): ProjectHostSetupProjection { + const cachedProjection = projectHostSetupProjectionCache.get(repos) + if (cachedProjection) { + return cachedProjection + } + + const projection = projectHostSetupProjectionFromRepos(repos) + projectHostSetupProjectionCache.set(repos, projection) + return projection +} + +function getCachedProvidedProjectHostSetupProjection( + projects: Project[], + setups: ProjectHostSetup[] +): ProjectHostSetupProjection { + const cachedBySetups = providedProjectHostSetupProjectionCache.get(projects) + const cachedProjection = cachedBySetups?.get(setups) + if (cachedProjection) { + return cachedProjection + } + + const projection = { projects, setups } + const nextCachedBySetups = + cachedBySetups ?? new WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>() + nextCachedBySetups.set(setups, projection) + if (!cachedBySetups) { + providedProjectHostSetupProjectionCache.set(projects, nextCachedBySetups) + } + return projection +} + +function mergeById<T extends { id: string }>(base: readonly T[], overlay: readonly T[]): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} + +function mergeProjectHostSetupProjection( + repos: AppState['repos'], + projects: Project[], + setups: ProjectHostSetup[] +): ProjectHostSetupProjection { + const cachedByProjects = mergedProjectHostSetupProjectionCache.get(repos) + const cachedBySetups = cachedByProjects?.get(projects) + const cachedProjection = cachedBySetups?.get(setups) + if (cachedProjection) { + return cachedProjection + } + const derived = getCachedProjectHostSetupProjection(repos) + // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays + // beside legacy repos. Keep repo-backed compatibility rows visible in that case. + const projection = { + projects: mergeById(derived.projects, projects), + setups: mergeById(derived.setups, setups) + } + const nextCachedByProjects = + cachedByProjects ?? + new WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>>() + const nextCachedBySetups = + cachedBySetups ?? new WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>() + nextCachedBySetups.set(setups, projection) + if (!cachedBySetups) { + nextCachedByProjects.set(projects, nextCachedBySetups) + } + if (!cachedByProjects) { + mergedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects) + } + return projection +} + export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountState): number { const terminalTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS const browserTabs = @@ -169,6 +261,36 @@ export function getRepoMapFromState(state: Pick<AppState, 'repos'>): Map<string, return getCachedRepoMap(state.repos) } +export function getProjectHostSetupProjectionFromState( + state: Pick<AppState, 'repos'> & Partial<Pick<AppState, 'projects' | 'projectHostSetups'>> +): ProjectHostSetupProjection { + if (state.projects && state.projectHostSetups) { + const repoIds = new Set(state.repos.map((repo) => repo.id)) + const coveredRepoIds = new Set<string>() + for (const setup of state.projectHostSetups) { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + if (repoIds.has(repoId)) { + coveredRepoIds.add(repoId) + } + if (repoIds.has(setup.id)) { + coveredRepoIds.add(setup.id) + } + } + if (state.repos.length > 0 && coveredRepoIds.size < repoIds.size) { + return mergeProjectHostSetupProjection( + state.repos, + state.projects as Project[], + state.projectHostSetups as ProjectHostSetup[] + ) + } + return getCachedProvidedProjectHostSetupProjection( + state.projects as Project[], + state.projectHostSetups as ProjectHostSetup[] + ) + } + return getCachedProjectHostSetupProjection(state.repos) +} + // ─── Repos ────────────────────────────────────────────────────────── export const useRepos = () => useAppStore((s) => s.repos) export const useActiveRepoId = () => useAppStore((s) => s.activeRepoId) @@ -177,6 +299,8 @@ export const useActiveRepo = () => export const useRepoMap = () => useAppStore((s) => getCachedRepoMap(s.repos)) export const useRepoById = (repoId: string | null) => useAppStore((s) => (repoId ? (getCachedRepoMap(s.repos).get(repoId) ?? null) : null)) +export const useProjectHostSetupProjection = () => + useAppStore((s) => getProjectHostSetupProjectionFromState(s)) // ─── Worktrees ────────────────────────────────────────────────────── export const useActiveWorktreeId = () => useAppStore((s) => s.activeWorktreeId) diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index d07fa741299..314bdae32f6 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -11,9 +11,14 @@ import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/brow import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock +})) + const mockApi = { browser: { sessionListProfiles: vi.fn().mockResolvedValue([]), @@ -189,6 +194,27 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() }) + it('uses local browser profile defaults for client-local fallback pages', () => { + const store = createTestStore() + store.setState({ + settings: settingsWithRuntime('env-1'), + defaultBrowserSessionProfileIdByHostId: { + local: 'local-profile', + 'runtime:env-1': 'runtime-profile' + } + }) + + const localFallback = store.getState().createBrowserTab('wt-1', 'about:blank', { + browserRuntimeEnvironmentId: null + }) + const remoteTab = store.getState().createBrowserTab('wt-1', 'about:blank', { + browserRuntimeEnvironmentId: 'env-1' + }) + + expect(localFallback.sessionProfileId).toBe('local-profile') + expect(remoteTab.sessionProfileId).toBe('runtime-profile') + }) + it('preserves browser map references when a page-state update is unchanged', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { @@ -441,6 +467,8 @@ describe('createBrowserSlice runtime guard', () => { clearRuntimeCompatibilityCacheForTests() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() + createWebRuntimeSessionBrowserTabMock.mockReset() + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -488,6 +516,193 @@ describe('createBrowserSlice runtime guard', () => { source: null } ]) + expect(store.getState().browserSessionProfilesByHostId['runtime:env-1']).toEqual([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) + }) + + it('keeps browser profile lists separate per host', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-remote', + ok: true, + result: { + profiles: [ + { + id: 'remote-default', + scope: 'default', + partition: 'persist:orca-remote', + label: 'Remote Default', + source: null + } + ] + }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ settings: settingsWithRuntime('env-1') }) + + await store.getState().fetchBrowserSessionProfiles() + + mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ + { + id: 'local-default', + scope: 'default', + partition: 'persist:orca-local', + label: 'Local Default', + source: null + } + ]) + store.setState({ settings: { activeRuntimeEnvironmentId: null } as AppState['settings'] }) + + await store.getState().fetchBrowserSessionProfiles() + + expect(store.getState().browserSessionProfilesByHostId['runtime:env-1']?.[0]?.id).toBe( + 'remote-default' + ) + expect(store.getState().browserSessionProfilesByHostId.local?.[0]?.id).toBe('local-default') + expect(store.getState().browserSessionProfiles[0]?.id).toBe('local-default') + }) + + it('uses the target worktree host default profile when creating a browser tab', () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-remote', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + ] + }, + defaultBrowserSessionProfileId: 'local-default', + defaultBrowserSessionProfileIdByHostId: { + local: 'local-default', + 'runtime:env-1': 'remote-default' + } + }) + + const tab = store.getState().createBrowserTab('wt-remote', 'https://example.com') + + expect(tab.sessionProfileId).toBe('remote-default') + }) + + it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => { + const store = createTestStore() + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + browserDefaultUrl: 'about:blank', + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-remote', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + ] + } + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-remote', + environmentId: 'env-1', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() + expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') + }) + + it('creates a local fallback tab when runtime browser creation fails', async () => { + const store = createTestStore() + createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false) + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-remote', + environmentId: 'env-1', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( + 'wt-remote', + 'browser', + expect.objectContaining({ targetGroupId: 'group-1' }) + ) + const [tab] = store.getState().browserTabsByWorktree['wt-remote'] ?? [] + expect(tab).toBeDefined() + expect(store.getState().browserPagesByWorkspace[tab!.id]?.[0]).toMatchObject({ + browserRuntimeEnvironmentId: null, + url: 'about:blank', + title: 'New Tab' + }) + expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') }) it('does not import local browser cookies while a runtime environment is active', async () => { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index a9c4a07a7c3..89344d6b569 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -39,6 +39,16 @@ import type { } from '../../../../shared/runtime-types' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' type CreateBrowserTabOptions = { activate?: boolean @@ -54,11 +64,13 @@ type CreateBrowserTabOptions = { // (context menu, window.open, http link routing) leave this unset so focus // stays on the webview. When omitted, we fall back to the blank-URL check. focusAddressBar?: boolean + browserRuntimeEnvironmentId?: string | null } type CreateBrowserPageOptions = { activate?: boolean title?: string + browserRuntimeEnvironmentId?: string | null } type BrowserTabPageState = { @@ -158,6 +170,7 @@ export type BrowserSlice = { hydrateBrowserSession: (session: WorkspaceSessionState) => void switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void browserSessionProfiles: BrowserSessionProfile[] + browserSessionProfilesByHostId: Partial<Record<ExecutionHostId, BrowserSessionProfile[]>> browserSessionImportState: { profileId: string status: 'idle' | 'importing' | 'success' | 'error' @@ -190,6 +203,7 @@ export type BrowserSlice = { addBrowserHistoryEntry: (url: string, title: string) => void clearBrowserHistory: () => void defaultBrowserSessionProfileId: string | null + defaultBrowserSessionProfileIdByHostId: Partial<Record<ExecutionHostId, string | null>> setDefaultBrowserSessionProfileId: (profileId: string | null) => void } @@ -226,6 +240,44 @@ function isRuntimeEnvironmentActive(state: AppState): boolean { return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim()) } +function getBrowserSettingsHostId(state: Pick<AppState, 'settings'>): ExecutionHostId { + return getSettingsFocusedExecutionHostId(state.settings) +} + +function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { + return getExecutionHostIdForWorktree(state, worktreeId) +} + +function getBrowserSessionProfileHostId( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): ExecutionHostId { + if (browserRuntimeEnvironmentId === null) { + return LOCAL_EXECUTION_HOST_ID + } + if (browserRuntimeEnvironmentId !== undefined) { + const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + } + return getBrowserWorktreeHostId(state, worktreeId) +} + +function profileListByHostUpdate( + state: Pick<AppState, 'browserSessionProfilesByHostId' | 'settings'>, + profiles: BrowserSessionProfile[] +): Partial<BrowserSlice> { + return { + browserSessionProfiles: profiles, + browserSessionProfilesByHostId: { + ...state.browserSessionProfilesByHostId, + [getBrowserSettingsHostId(state)]: profiles + } + } +} + function closeRemoteBrowserPageInOwningEnvironment( worktreeId: string, handle: RemoteBrowserPageHandle @@ -243,7 +295,8 @@ function buildBrowserPage( workspaceId: string, worktreeId: string, url: string, - title?: string + title?: string, + browserRuntimeEnvironmentId?: string | null ): BrowserPage { const normalizedUrl = normalizeUrl(url) return { @@ -260,7 +313,8 @@ function buildBrowserPage( canGoBack: false, canGoForward: false, loadError: null, - createdAt: Date.now() + createdAt: Date.now(), + ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}) } } @@ -413,24 +467,40 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = pendingAddressBarFocusByTabId: {}, pendingAddressBarFocusByPageId: {}, browserSessionProfiles: [], + browserSessionProfilesByHostId: {}, browserSessionImportState: null, browserUrlHistory: [], defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: {}, setDefaultBrowserSessionProfileId: (profileId) => { - set({ defaultBrowserSessionProfileId: profileId }) + set((s) => ({ + defaultBrowserSessionProfileId: profileId, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: profileId + } + })) }, createBrowserTab: (worktreeId, url, options) => { const workspaceId = createBrowserUuid() - const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title) + const page = buildBrowserPage( + workspaceId, + worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId + ) // Why: when no explicit profile is passed, inherit the user's chosen default // profile. This lets users set a preferred profile in Settings that all new // browser tabs use automatically. const sessionProfileId = options?.sessionProfileId !== undefined ? options.sessionProfileId - : get().defaultBrowserSessionProfileId + : (get().defaultBrowserSessionProfileIdByHostId[ + getBrowserSessionProfileHostId(get(), worktreeId, options?.browserRuntimeEnvironmentId) + ] ?? get().defaultBrowserSessionProfileId) const browserTab = buildWorkspaceFromPage( workspaceId, worktreeId, @@ -531,17 +601,30 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = return } const defaultUrl = state.browserDefaultUrl ?? 'about:blank' - const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }) - .__ORCA_WEB_CLIENT__ - ? state.settings?.activeRuntimeEnvironmentId?.trim() - : null - if (pairedWebRuntimeEnvironmentId) { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: pairedWebRuntimeEnvironmentId, - url: defaultUrl, - targetGroupId: groupId + try { + const created = await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url: defaultUrl, + targetGroupId: groupId + }) + if (created) { + get().recordFeatureInteraction('browser-tab-created') + return + } + } catch { + // Fall through to the client-local fallback below. + } + // Why: headless remote runtimes cannot host browser panes yet. Keep the + // workspace remote-owned, but open this browser page on the desktop client. + get().createBrowserTab(worktreeId, defaultUrl, { + title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), + focusAddressBar: true, + targetGroupId: groupId, + browserRuntimeEnvironmentId: null }) get().recordFeatureInteraction('browser-tab-created') return @@ -553,7 +636,6 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = }) get().recordFeatureInteraction('browser-tab-created') }, - closeBrowserTab: (tabId) => { let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { @@ -755,13 +837,15 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const restored = get().createBrowserTab(worktreeId, firstPage.url, { title: firstPage.title, activate: true, - sessionProfileId + sessionProfileId, + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, - title: p.title + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId }) } @@ -829,7 +913,13 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = if (!workspace) { return null } - const page = buildBrowserPage(workspaceId, workspace.worktreeId, url, options?.title) + const page = buildBrowserPage( + workspaceId, + workspace.worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId + ) set((s) => { const pages = s.browserPagesByWorkspace[workspaceId] ?? [] @@ -1005,7 +1095,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, - activate: true + activate: true, + browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId }) }, @@ -1622,15 +1713,15 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = undefined, { timeoutMs: 15_000 } ) - set({ browserSessionProfiles: result.profiles }) + set((s) => profileListByHostUpdate(s, result.profiles)) } catch { - set({ browserSessionProfiles: [] }) + set((s) => profileListByHostUpdate(s, [])) } return } try { const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] - set({ browserSessionProfiles: profiles }) + set((s) => profileListByHostUpdate(s, profiles)) } catch { /* best-effort — stale profile list is preferable to a crash */ } @@ -1648,7 +1739,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const profile = result.profile if (profile) { set((s) => ({ - browserSessionProfiles: [...s.browserSessionProfiles, profile] + ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) })) } return profile @@ -1663,7 +1754,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = })) as BrowserSessionProfile | null if (profile) { set((s) => ({ - browserSessionProfiles: [...s.browserSessionProfiles, profile] + ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) })) } return profile @@ -1683,9 +1774,18 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = ) if (result.deleted) { set((s) => ({ - browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...profileListByHostUpdate( + s, + s.browserSessionProfiles.filter((p) => p.id !== profileId) + ), ...(s.defaultBrowserSessionProfileId === profileId - ? { defaultBrowserSessionProfileId: null } + ? { + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: null + } + } : {}) })) } @@ -1698,9 +1798,18 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const ok = await window.api.browser.sessionDeleteProfile({ profileId }) if (ok) { set((s) => ({ - browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...profileListByHostUpdate( + s, + s.browserSessionProfiles.filter((p) => p.id !== profileId) + ), ...(s.defaultBrowserSessionProfileId === profileId - ? { defaultBrowserSessionProfileId: null } + ? { + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: null + } + } : {}) })) } diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 4de07397ea0..733261f1daa 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -41,7 +41,7 @@ describe('Cmd+J lifted creation actions', () => { delete pairedWebFlag.__ORCA_WEB_CLIENT__ }) - it('does not fall back to a local browser tab when paired-web creation fails', async () => { + it('opens a local browser tab when paired-web browser creation fails', async () => { createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) const store = createTestStore() seedActiveWorkspace(store) @@ -54,7 +54,42 @@ describe('Cmd+J lifted creation actions', () => { url: 'about:blank', targetGroupId: 'group-1' }) - expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + }) + + it('creates browser tabs on the explicit owner runtime when another runtime is focused', async () => { + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) + const store = createTestStore() + seedActiveWorkspace(store) + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'runtime:owner-runtime' }], + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'owner-runtime', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + }) + + it('creates a local browser tab for explicitly local workspaces while a runtime is focused', async () => { + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) + const store = createTestStore() + seedActiveWorkspace(store) + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'local' }], + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) }) it('does not fall back to a local terminal tab when paired-web creation fails', async () => { diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index d5672d751cb..198ec1bd38c 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -136,6 +136,7 @@ import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' +import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' @@ -170,6 +171,7 @@ function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) @@ -350,6 +352,35 @@ describe('updateDiffComment', () => { }) }) + it('persists explicit local worktree comments locally while a runtime is focused', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: REPO, + path: '/path/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' + } + ] + }) + seed(store, [makeComment({ id: 'c1', body: 'old body' })]) + + const ok = await store.getState().updateDiffComment(WT, 'c1', 'local body') + + expect(ok).toBe(true) + expect(updateMeta).toHaveBeenCalledWith({ + worktreeId: WT, + updates: { + diffComments: [expect.objectContaining({ id: 'c1', body: 'local body' })] + } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('rejects an empty body without persisting', async () => { const store = createTestStore() seed(store, [ diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index adc695231f8..5e0f4b82cec 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -8,6 +8,7 @@ import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -115,6 +116,13 @@ async function persist( ) } +function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + // Why: IPC writes from `persist` are not ordered with respect to each other. // If two mutations (e.g. rapid add then delete, or two adds) are in flight // concurrently, their `updateMeta` resolutions can arrive out of call order, @@ -141,7 +149,7 @@ function enqueuePersist(worktreeId: string, get: () => AppState): Promise<void> const repoList = get().worktreesByRepo[repoId] const target = repoList?.find((w) => w.id === worktreeId) const latest = (target?.diffComments ?? []).map(normalizeDiffComment) - await persist(get().settings, worktreeId, latest) + await persist(settingsForWorktreeOwner(get(), worktreeId), worktreeId, latest) } const next = prior.then(run, run) persistQueueByWorktree.set(worktreeId, next) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 11d38269717..f9774e42dfa 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -2098,6 +2098,28 @@ describe('createEditorSlice remote branch actions', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('routes git operations through the explicit runtime owner instead of ambient focus', async () => { + const store = createEditorStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + await store.getState().pushBranch('wt-1', '/repo', false, undefined, undefined, { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + }) + + expect(gitPushMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + publish: false, + connectionId: undefined, + pushTarget: undefined, + forceWithLease: undefined + }) + expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + pushTarget: undefined + }) + }) + it('runs rebase from base and refreshes upstream on success', async () => { const store = createEditorStore() const pushTarget = { remoteName: 'fork', branchName: 'feature' } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ed5b75804b8..334818b20f6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -16,6 +16,7 @@ import type { GitConflictOperation, GitConflictResolutionStatus, GitConflictStatusSource, + GlobalSettings, GitPushTarget, GitStatusEntry, GitStatusResult, @@ -240,6 +241,10 @@ type EditorOpenTargetOptions = { runtimeEnvironmentId?: string | null } +type GitRuntimeOperationOptions = { + runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null +} + export type PendingEditorReveal = { filePath: string fileId?: string @@ -512,7 +517,8 @@ export type EditorSlice = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> pushBranch: ( worktreeId: string, @@ -520,38 +526,43 @@ export type EditorSlice = { publish?: boolean, connectionId?: string, pushTarget?: GitPushTarget, - options?: { forceWithLease?: boolean } + options?: GitRuntimeOperationOptions & { forceWithLease?: boolean } ) => Promise<void> pullBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> fastForwardBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> syncBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> rebaseFromBase: ( worktreeId: string, worktreePath: string, baseRef: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> fetchBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> gitBranchChangesByWorktree: Record<string, GitBranchChangeEntry[]> gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary | null> @@ -3385,11 +3396,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s inFlightRemoteOpKind: next > 0 ? s.inFlightRemoteOpKind : null } }), - fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { try { + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings const status = await getRuntimeGitUpstreamStatus( { - settings: get().settings, + settings: runtimeSettings, worktreeId, worktreePath, connectionId @@ -3428,9 +3440,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s publish ? 'publish' : options.forceWithLease === true ? 'force_push' : 'push' ) let shouldRefreshAfterRejectedPush = false + const runtimeSettings = options.runtimeTargetSettings ?? get().settings try { await pushRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, { publish, pushTarget, forceWithLease: options.forceWithLease } ) } catch (error) { @@ -3446,26 +3459,33 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() if (shouldRefreshAfterRejectedPush) { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } // Why: the rejected push proved the publish branch moved. Fetch first // so legacy base-tracking worktrees can discover origin/<branch>, then // refresh ahead/behind so Pull/Sync become actionable immediately. void fetchRuntimeGit(context, pushTarget) .catch(() => undefined) - .then(() => get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget)) + .then(() => + get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) + ) } } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('pull') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await pullRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3474,17 +3494,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('fast_forward') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fastForwardRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3493,13 +3516,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // post-op upstream refresh after the busy flag clears so the primary // button label rotates immediately when the IPC resolves. @@ -3510,8 +3535,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false let pushed = false + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } await fetchRuntimeGit(context, pushTarget) const upstreamStatusBeforePull = await getRuntimeGitUpstreamStatus(context, pushTarget) if (shouldForcePushWithLeaseForUpstream(upstreamStatusBeforePull)) { @@ -3556,7 +3582,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) if (pushed) { const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -3564,11 +3592,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } } }, - rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget) => { + rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget, options) => { get().beginRemoteOperation('rebase') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await rebaseRuntimeGitFromBase( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, baseRef ) } catch (error) { @@ -3577,21 +3606,24 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // upstream refresh after the busy flag clears. Fetch updates the // remote refs only, so the visible signal we want is the new // ahead/behind counts on the upstream-status payload. get().beginRemoteOperation('fetch') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fetchRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3600,7 +3632,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) }, gitBranchChangesByWorktree: {}, gitBranchCompareSummaryByWorktree: {}, diff --git a/src/renderer/src/store/slices/github-cache-key.ts b/src/renderer/src/store/slices/github-cache-key.ts index 4d74acbe3be..1bab2b1093e 100644 --- a/src/renderer/src/store/slices/github-cache-key.ts +++ b/src/renderer/src/store/slices/github-cache-key.ts @@ -1,21 +1,45 @@ -import type { AppState } from '../types' +import type { GlobalSettings } from '../../../../shared/types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' + +type RuntimeFocusSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined export function getGitHubRepoCacheKey( repoPath: string, repoId: string | undefined, suffix: string, - settings?: AppState['settings'], - connectionId?: string | null + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null ): string { - const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() const owner = repoId ?? repoPath + const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId) // Why: runtime/SSH lookups can observe different remotes than the local repo - // path, so cache keys include the active remote execution boundary. + // path, so cache keys include the repo's owning execution boundary. + if (scope) { + return `${scope}::${owner}::${suffix}` + } + return `${owner}::${suffix}` +} + +function getGitHubCacheHostScope( + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null +): string | null { + const hostId = normalizeExecutionHostId(executionHostId) + if (hostId) { + return hostId === LOCAL_EXECUTION_HOST_ID ? null : hostId + } + const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() if (runtimeEnvironmentId) { - return `runtime:${runtimeEnvironmentId}::${owner}::${suffix}` + return `runtime:${encodeURIComponent(runtimeEnvironmentId)}` } const sshConnectionId = connectionId?.trim() - return sshConnectionId ? `ssh:${sshConnectionId}::${owner}::${suffix}` : `${owner}::${suffix}` + return sshConnectionId ? toSshExecutionHostId(sshConnectionId) : null } export function getLegacyGitHubRepoCacheKey( @@ -30,10 +54,11 @@ export function getGitHubPRCacheKey( repoPath: string, repoId: string | undefined, branch: string, - settings?: AppState['settings'], - connectionId?: string | null + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId) + return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) } export function getLegacyGitHubPRCacheKey( diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 3149f280d86..067d18d655b 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -43,14 +43,22 @@ export function syncPRChecksStatus( headSha?: string, prRepo?: GitHubOwnerRepo | null, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): Partial<AppState> | null { const normalized = branch ? normalizeBranchName(branch) : '' if (!normalized) { return null } - const prCacheKey = getGitHubPRCacheKey(repoPath, repoId, normalized, settings, connectionId) + const prCacheKey = getGitHubPRCacheKey( + repoPath, + repoId, + normalized, + settings, + connectionId, + executionHostId + ) const prEntry = state.prCache[prCacheKey] if (!prEntry?.data) { return null diff --git a/src/renderer/src/store/slices/github-project-row-owner.test.ts b/src/renderer/src/store/slices/github-project-row-owner.test.ts new file mode 100644 index 00000000000..dccaec073e3 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-row-owner.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { settingsForProjectRowOwner } from './github-project-row-owner' +import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' + +vi.mock('@/lib/repo-slug-cache', () => ({ + lookupReposBySlugFromCache: vi.fn() +})) + +const mockedLookup = vi.mocked(lookupReposBySlugFromCache) + +function repo(id: string, executionHostId: string | null): Repo { + return { id, executionHostId, connectionId: null } as unknown as Repo +} + +describe('settingsForProjectRowOwner', () => { + beforeEach(() => { + mockedLookup.mockReset() + }) + + it('routes to the matched repo owner host when the slug matches', () => { + mockedLookup.mockReturnValue([repo('repo-1', 'runtime:owner-env')]) + const state = { + repos: [repo('repo-1', 'runtime:owner-env')], + settings: { activeRuntimeEnvironmentId: 'focused-env' } + } + expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ + activeRuntimeEnvironmentId: 'owner-env' + }) + }) + + it('falls back to focused settings when no repo matches the slug', () => { + mockedLookup.mockReturnValue([]) + const state = { + repos: [repo('repo-1', 'runtime:owner-env')], + settings: { activeRuntimeEnvironmentId: 'focused-env' } + } + expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ + activeRuntimeEnvironmentId: 'focused-env' + }) + }) +}) diff --git a/src/renderer/src/store/slices/github-project-row-owner.ts b/src/renderer/src/store/slices/github-project-row-owner.ts new file mode 100644 index 00000000000..936818c8df2 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-row-owner.ts @@ -0,0 +1,25 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' + +type RepoOwnerState = { + repos: readonly Repo[] + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +} + +/** Resolve the runtime settings to route a GitHub Project row mutation through. + * When the row's `owner/repo` slug matches a known repo, route by that repo's + * owner host; otherwise fall back to the focused settings (the row may belong + * to a repo Orca doesn't track). */ +export function settingsForProjectRowOwner( + state: RepoOwnerState, + owner: string, + repo: string, + fallbackSettings: + | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | null + | undefined = state.settings +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined { + const matchedRepo = lookupReposBySlugFromCache(state.repos, state.settings, `${owner}/${repo}`)[0] + return matchedRepo ? getSettingsForRepoRuntimeOwner(state, matchedRepo.id) : fallbackSettings +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c45ecf55801..d663bf21338 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -8,6 +8,7 @@ import { _getGitHubPRRefreshStartedEntryCountForTest, _getGitHubPRRequestGenerationCountForTest, createGitHubSlice, + issueCacheKey, mergePRCommentIntoList, prChecksCacheSuffix, prCommentsCacheSuffix, @@ -25,6 +26,8 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import type { TaskSourceContext } from '../../../../shared/task-source-context' const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -43,7 +46,12 @@ const mockApi = { resolveReviewThread: vi.fn(), listWorkItems: vi.fn(), countWorkItems: vi.fn().mockResolvedValue(0), - getProjectViewTable: vi.fn() + getProjectViewTable: vi.fn(), + updateProjectItemField: vi.fn(), + clearProjectItemField: vi.fn(), + updateIssueBySlug: vi.fn(), + updatePullRequestBySlug: vi.fn(), + updateIssueTypeBySlug: vi.fn() }, hostedReview: { forBranch: vi.fn().mockResolvedValue(null), @@ -95,6 +103,21 @@ function makePR(overrides: Partial<PRInfo> = {}): PRInfo { } } +function githubSourceContext( + hostId: TaskSourceContext['hostId'], + repoId = 'source-repo-id' +): TaskSourceContext { + return { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId, + projectHostSetupId: 'setup-1', + repoId, + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } +} + describe('createGitHubSlice.evictGitHubRepoCaches', () => { beforeEach(() => { vi.clearAllMocks() @@ -302,6 +325,134 @@ describe('createGitHubSlice cache bounds', () => { await vi.runOnlyPendingTimersAsync() }) + + it('routes runtime-owned issue fetches through the owning runtime when local is focused', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-issue-owner', + ok: true, + result: { + number: 123, + title: 'Runtime issue', + state: 'open', + url: 'https://example.com/issues/123' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + store.setState({ + settings: null, + repos: [ + { + id: 'repo-runtime', + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 123, { repoId: 'repo-runtime' }) + ).resolves.toMatchObject({ number: 123, title: 'Runtime issue' }) + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.issue', + params: { repo: 'repo-runtime', number: 123 }, + timeoutMs: 30_000 + }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-runtime', 123, null, null, 'runtime:env-1') + ]?.data + ).toMatchObject({ number: 123 }) + }) + + it('routes explicit source-context issue fetches through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-issue', + ok: true, + result: { + number: 19, + title: 'Source issue', + state: 'open', + url: 'https://example.com/issues/19' + }, + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 19, { repoId, sourceContext }) + ).resolves.toMatchObject({ number: 19, title: 'Source issue' }) + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.issue', + params: { repo: 'runtime-repo-id', number: 19 }, + timeoutMs: 30_000 + }) + expect( + store.getState().issueCache[`${getTaskSourceCacheScope(sourceContext)}::${repoId}::19`]?.data + ).toMatchObject({ number: 19 }) + }) + + it('routes SSH-owned issue fetches through local IPC when a runtime is focused', async () => { + mockApi.gh.issue.mockResolvedValueOnce({ + number: 321, + title: 'SSH issue', + state: 'open', + url: 'https://example.com/issues/321' + }) + const store = createTestStore() + const repoPath = '/ssh/repo' + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'repo-ssh', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 321, { repoId: 'repo-ssh' }) + ).resolves.toMatchObject({ + number: 321, + title: 'SSH issue' + }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.issue).toHaveBeenCalledWith({ repoPath, repoId: 'repo-ssh', number: 321 }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-ssh', 321, null, 'ssh-1', 'ssh:ssh-1') + ]?.data + ).toMatchObject({ number: 321 }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-ssh', 321, { + activeRuntimeEnvironmentId: 'env-focused' + } as AppState['settings']) + ] + ).toBeUndefined() + }) }) describe('createGitHubSlice.patchWorkItem', () => { @@ -345,6 +496,58 @@ describe('createGitHubSlice.patchWorkItem', () => { }) expect(repoTwoPatched).toBe(repoTwoItem) }) + + it('can scope patches to one GitHub task source when hosts share a repo id and work-item id', () => { + const store = createTestStore() + const firstSourceContext = githubSourceContext('runtime:first-host', 'repo-1') + const secondSourceContext = githubSourceContext('runtime:second-host', 'repo-1') + const firstItem = { + id: 'pr:42', + repoId: 'repo-1', + type: 'pr', + number: 42, + title: 'First host PR' + } as GitHubWorkItem + const secondItem = { + id: 'pr:42', + repoId: 'repo-1', + type: 'pr', + number: 42, + title: 'Second host PR' + } as GitHubWorkItem + + store.setState({ + workItemsCache: { + [workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(firstSourceContext))]: { + data: [firstItem], + fetchedAt: 1 + }, + [workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(secondSourceContext))]: { + data: [secondItem], + fetchedAt: 1 + } + } + }) + + store.getState().patchWorkItem('pr:42', { reviewRequests: [] }, 'repo-1', { + sourceContext: firstSourceContext + }) + + const state = store.getState() + const firstPatched = + state.workItemsCache[ + workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(firstSourceContext)) + ]?.data?.[0] + const secondPatched = + state.workItemsCache[ + workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(secondSourceContext)) + ]?.data?.[0] + expect(firstPatched).toMatchObject({ + title: 'First host PR', + reviewRequests: [] + }) + expect(secondPatched).toBe(secondItem) + }) }) describe('createGitHubSlice.fetchPRChecks', () => { @@ -734,6 +937,48 @@ describe('createGitHubSlice.fetchPRChecks', () => { expect(store.getState().prCache[repoScopedKey]?.data?.checksStatus).toBe('success') expect(store.getState().prCache[pathScopedKey]?.data?.checksStatus).toBe('pending') }) + + it('routes explicit source-context PR checks through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-checks', + ok: true, + result: [{ name: 'source-build', status: 'completed', conclusion: 'success', url: null }], + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().fetchPRChecks(repoPath, 12, 'feature/source', 'head-1', null, { + force: true, + repoId, + sourceContext + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.prChecks', + params: { + repo: 'runtime-repo-id', + prNumber: 12, + headSha: 'head-1', + prRepo: null, + noCache: true + }, + timeoutMs: 30_000 + }) + expect( + store.getState().checksCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::${prChecksCacheSuffix(12, null, 'head-1')}` + ]?.data?.[0].name + ).toBe('source-build') + expect(mockApi.gh.prChecks).not.toHaveBeenCalled() + }) }) describe('createGitHubSlice.fetchPRComments', () => { @@ -824,6 +1069,48 @@ describe('createGitHubSlice.fetchPRComments', () => { ).toBeUndefined() }) + it('routes explicit source-context PR comments through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-comments', + ok: true, + result: [{ id: 1, author: 'source', authorAvatarUrl: '', body: '', createdAt: '', url: '' }], + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().fetchPRComments(repoPath, 12, { + force: true, + repoId, + sourceContext, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.prComments', + params: { + repo: 'runtime-repo-id', + prNumber: 12, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + noCache: true + }, + timeoutMs: 30_000 + }) + expect( + store.getState().commentsCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::pr-comments::acme/widgets::12` + ]?.data?.[0].author + ).toBe('source') + expect(mockApi.gh.prComments).not.toHaveBeenCalled() + }) + it('bounds PR comment cache entries across many repos', async () => { vi.useFakeTimers() @@ -847,6 +1134,62 @@ describe('createGitHubSlice.fetchPRComments', () => { vi.useRealTimers() } }) + + it('preserves cached checks when the checks IPC fails', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const checksCacheKey = `${repoPath}::pr-checks::12` + const cachedChecks = [ + { name: 'build', status: 'completed', conclusion: 'failure', url: null } as const + ] + + store.setState({ + checksCache: { + [checksCacheKey]: { + data: cachedChecks, + fetchedAt: 1, + headSha: 'abc123head' + } + } + } as unknown as Partial<AppState>) + mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) + + await expect( + store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true }) + ).resolves.toEqual(cachedChecks) + + expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks) + expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1) + }) + + it('does not return cached checks for a different requested head SHA after IPC failure', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const checksCacheKey = `${repoPath}::pr-checks::12` + const oldHeadChecks = [ + { name: 'build', status: 'completed', conclusion: 'success', url: null } as const + ] + + store.setState({ + checksCache: { + [checksCacheKey]: { + data: oldHeadChecks, + fetchedAt: 1, + headSha: 'old-head' + } + } + } as unknown as Partial<AppState>) + mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) + + await expect( + store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true }) + ).resolves.toEqual([]) + + expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks) + expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head') + }) }) describe('createGitHubSlice.fetchPRCheckDetails', () => { @@ -1008,6 +1351,37 @@ describe('createGitHubSlice PR comment mutations', () => { ).toBe('done') }) + it('posts top-level PR comments with explicit local source context', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const sourceContext = githubSourceContext('local', repoId) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().addPRConversationComment(repoPath, 12, 'done', { + repoId, + sourceContext, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(mockApi.gh.addIssueComment).toHaveBeenCalledWith({ + repoPath, + repoId, + number: 12, + body: 'done', + type: 'pr', + prRepo: { owner: 'Acme', repo: 'Widgets' }, + sourceContext + }) + expect( + store.getState().commentsCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::pr-comments::acme/widgets::12` + ]?.data?.[0].body + ).toBe('done') + }) + it('routes runtime PR review replies with prRepo and merges returned thread metadata', async () => { runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'rpc-pr-reply', @@ -2241,7 +2615,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) }) - it('does not apply local GitHub PR refresh events while a runtime is active', () => { + it('applies local GitHub PR refresh events without touching runtime-scoped cache', () => { const store = createTestStore() const repoPath = '/repo' const repoId = 'repo-1' @@ -2249,6 +2623,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { const cacheKey = `${repoId}::${branch}` const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] const runtimeHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, settings, repoId) + const localHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) store.setState({ settings } as Partial<AppState>) @@ -2263,8 +2638,15 @@ describe('createGitHubSlice.fetchPRForBranch', () => { } }) - expect(store.getState().prCache[cacheKey]).toBeUndefined() - expect(store.getState().prRefreshSequences[cacheKey]).toBeUndefined() + expect(store.getState().prCache[cacheKey]?.data).toMatchObject({ + number: 12, + title: 'Local PR status' + }) + expect(store.getState().prRefreshSequences[cacheKey]).toBe(1) + expect(store.getState().hostedReviewCache[localHostedReviewCacheKey]?.data).toMatchObject({ + provider: 'github', + number: 12 + }) expect(store.getState().hostedReviewCache[runtimeHostedReviewCacheKey]).toBeUndefined() }) @@ -2898,6 +3280,92 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(store.getState().prCache[`repo-1::${branch}`]).toBeUndefined() }) + it('fetches PR through the owning runtime when local host is focused', async () => { + resetRemoteRuntimeMocks() + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23, title: 'Owner runtime PR' }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/owner-runtime' + + store.setState({ + settings: null, + repos: [ + { + id: 'repo-runtime', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-runtime' }) + ).resolves.toMatchObject({ number: 23 }) + + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({ + number: 23, + title: 'Owner runtime PR' + }) + }) + + it('fetches SSH-owned PRs through local IPC when a runtime host is focused', async () => { + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 34, title: 'SSH PR' }), + fetchedAt: 10 + }) + const store = createTestStore() + const repoPath = '/ssh/repo' + const branch = 'feature/ssh-owner' + + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'repo-ssh', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-ssh' }) + ).resolves.toMatchObject({ number: 34 }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + cacheKey: `ssh:ssh-1::repo-ssh::${branch}`, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + }) + expect(store.getState().prCache[`ssh:ssh-1::repo-ssh::${branch}`]?.data).toMatchObject({ + number: 34, + title: 'SSH PR' + }) + expect(store.getState().prCache[`runtime:env-focused::repo-ssh::${branch}`]).toBeUndefined() + }) + it('uses the cached PR number as a fallback refresh hint when worktree metadata is not linked yet', () => { const store = createTestStore() const repoPath = '/repo' @@ -3404,12 +3872,214 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }, timeoutMs: 30_000 }) - expect(store.getState().workItemsCache['caller-repo-id::24::is:open'].data?.[0]).toMatchObject({ + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-1') + ].data?.[0] + ).toMatchObject({ repoId: 'caller-repo-id', number: 7 }) }) + it('routes work item fetches through the owning runtime when local is focused', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-work-items-owner', + ok: true, + result: { + items: [ + { type: 'issue', number: 17, title: 'Owner issue', url: 'https://example.test/17' } + ], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + store.setState({ + settings: null, + repos: [ + { + id: 'runtime-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ] + } as Partial<AppState>) + + await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open') + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.listWorkItems', + params: { + repo: 'runtime-repo-id', + limit: 24, + query: 'is:open' + }, + timeoutMs: 30_000 + }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-1') + ]?.data?.[0] + ).toMatchObject({ repoId: 'caller-repo-id', number: 17 }) + }) + + it('routes work item fetches through an explicit GitHub source context', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-work-items-source-context', + ok: true, + result: { + items: [ + { type: 'issue', number: 19, title: 'Source issue', url: 'https://example.test/19' } + ], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [ + { + id: 'local-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as Partial<AppState>) + + const sourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + projectId: 'github:stablyai/orca', + hostId: 'runtime:source-runtime' as const, + projectHostSetupId: 'setup-1', + repoId: 'source-runtime-repo-id', + providerIdentity: { provider: 'github' as const, owner: 'stablyai', repo: 'orca' } + } + + await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', { + sourceContext + }) + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.listWorkItems', + params: { + repo: 'source-runtime-repo-id', + limit: 24, + query: 'is:open' + }, + timeoutMs: 30_000 + }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', getTaskSourceCacheScope(sourceContext)) + ]?.data?.[0] + ).toMatchObject({ repoId: 'caller-repo-id', number: 19 }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:focused-runtime') + ] + ).toBeUndefined() + }) + + it('keeps explicit GitHub source identities in separate work-item cache buckets', async () => { + const store = createTestStore() + const firstSourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + projectId: 'project-1', + hostId: 'local' as const, + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github' as const, owner: 'acme', repo: 'orca' } + } + const secondSourceContext = { + ...firstSourceContext, + providerIdentity: { provider: 'github' as const, owner: 'stablyai', repo: 'orca' } + } + mockApi.gh.listWorkItems + .mockResolvedValueOnce({ + items: [{ type: 'issue', number: 1, title: 'Acme', url: 'https://example.test/1' }], + sources: { issues: { owner: 'acme', repo: 'orca' }, prs: { owner: 'acme', repo: 'orca' } } + }) + .mockResolvedValueOnce({ + items: [{ type: 'issue', number: 2, title: 'Stably', url: 'https://example.test/2' }], + sources: { + issues: { owner: 'stablyai', repo: 'orca' }, + prs: { owner: 'stablyai', repo: 'orca' } + } + }) + + await store.getState().fetchWorkItems('repo-1', '/repo', 24, '', { + sourceContext: firstSourceContext + }) + await store.getState().fetchWorkItems('repo-1', '/repo', 24, '', { + sourceContext: secondSourceContext + }) + + expect( + store.getState().workItemsCache[ + workItemsCacheKey('repo-1', 24, '', getTaskSourceCacheScope(firstSourceContext)) + ]?.data?.[0]?.number + ).toBe(1) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('repo-1', 24, '', getTaskSourceCacheScope(secondSourceContext)) + ]?.data?.[0]?.number + ).toBe(2) + }) + + it('routes SSH-owned work item fetches through local IPC when a runtime is focused', async () => { + const store = createTestStore() + mockApi.gh.listWorkItems.mockResolvedValueOnce({ + items: [{ type: 'issue', number: 27, title: 'SSH issue', url: 'https://example.test/27' }], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'ssh-repo-id', + path: '/ssh/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as Partial<AppState>) + + await store.getState().fetchWorkItems('ssh-repo-id', '/ssh/repo', 24, '') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.listWorkItems).toHaveBeenCalledWith({ + repoPath: '/ssh/repo', + repoId: 'ssh-repo-id', + limit: 24, + query: undefined + }) + expect( + store.getState().workItemsCache[workItemsCacheKey('ssh-repo-id', 24, '', 'ssh:ssh-1')] + ?.data?.[0] + ).toMatchObject({ repoId: 'ssh-repo-id', number: 27 }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('ssh-repo-id', 24, '', 'runtime:env-focused') + ] + ).toBeUndefined() + }) + it('falls back to local work-item IPC when no runtime environment is active', async () => { const store = createTestStore() mockApi.gh.listWorkItems.mockResolvedValueOnce({ @@ -3644,7 +4314,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) await expect(oldFetch).resolves.toEqual([{ ...oldRuntimeItem, repoId: 'caller-repo-id' }]) expect( - store.getState().workItemsCache[workItemsCacheKey('caller-repo-id', 24, 'is:open')]?.data + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-new') + ]?.data ).toEqual([{ ...newRuntimeItem, repoId: 'caller-repo-id' }]) }) @@ -3922,6 +4594,271 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('keeps GitHub project view caches separate for runtime and local sources', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } + } as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + ok: true, + data: { + project: { + id: 'project-remote', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Remote Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + store.setState({ + settings: { activeRuntimeEnvironmentId: null } + } as Partial<AppState>) + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ + ok: true, + data: { + project: { + id: 'project-local', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Local Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }) + + const localResult = await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + expect(localResult.ok).toBe(true) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + expect( + store.getState().projectViewCache[ + projectViewCacheKey('organization', 'acme', 1, 'view-1', undefined, 'runtime:env-1') + ]?.data?.project.id + ).toBe('project-remote') + expect( + store.getState().projectViewCache[projectViewCacheKey('organization', 'acme', 1, 'view-1')] + ?.data?.project.id + ).toBe('project-local') + }) + + it('routes project field mutations through the source encoded in the cache key', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey( + 'organization', + 'acme', + 1, + 'view-1', + undefined, + 'runtime:env-project' + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' }, + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [{ id: 'field-1', name: 'Notes', dataType: 'TEXT', kind: 'text' }], + groupByFields: [], + sortByFields: [] + }, + rows: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: {} + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-field', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store + .getState() + .updateProjectFieldValue(cacheKey, 'row-1', 'field-1', { kind: 'text', text: 'next' }) + + expect(result).toEqual({ ok: true }) + expect(mockApi.gh.updateProjectItemField).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-project', + method: 'github.project.updateItemField', + params: { + projectId: 'project-1', + itemId: 'row-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'next' } + }, + timeoutMs: 30_000 + }) + }) + + it('routes slug-only project row mutations through the source encoded in the cache key', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey( + 'organization', + 'acme', + 1, + 'view-1', + undefined, + 'runtime:env-project' + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' }, + repos: [], + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: {} + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-issue', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store + .getState() + .patchProjectIssueOrPr(cacheKey, 'row-1', { addLabels: ['bug'] }) + + expect(result).toEqual({ ok: true }) + expect(mockApi.gh.updateIssueBySlug).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-project', + method: 'github.project.updateIssueBySlug', + params: { + owner: 'acme', + repo: 'repo', + number: 12, + updates: { addLabels: ['bug'] } + }, + timeoutMs: 30_000 + }) + }) + it('bounds project view table cache entries across many projects', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 7aaf6a21e07..0ecfce8df20 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -20,7 +20,8 @@ import type { Repo, Worktree, GitHubWorkItem, - ListWorkItemsResult + ListWorkItemsResult, + GlobalSettings } from '../../../../shared/types' import type { GetProjectViewTableArgs, @@ -38,12 +39,27 @@ import { } from '../../../../shared/work-items' import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { settingsForProjectRowOwner } from './github-project-row-owner' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key' import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' import { translate } from '@/i18n/i18n' +import { + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: declared separately from CacheEntry<T> (not a generified E parameter) @@ -64,6 +80,10 @@ export type ProjectRowContentUpdate = { removeAssignees?: string[] } +export type GitHubPatchWorkItemOptions = { + sourceContext?: TaskSourceContext | null +} + /** Optimistic, IPC-free patch shape for `projectViewCache` rows. * Why: the dialog already issues mutations via slug-addressed IPCs and only * needs to keep the Project table view in sync optimistically. Replacing @@ -126,16 +146,130 @@ type GitHubWorkItemsListArgs = { noCache?: true } -function activeRuntimeEnvironmentId(settings: AppState['settings']): string | null { - return settings?.activeRuntimeEnvironmentId ?? null +function settingsForGitHubRepoOwner( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned GitHub lookups are served by the desktop client; + // host focus must not redirect them to the currently selected runtime. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { + const explicitHostId = normalizeExecutionHostId(alias.executionHostId) + if (explicitHostId) { + return explicitHostId + } + const scope = alias.cacheKey.split('::', 1)[0] + return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID +} + +function findRepoForGitHubOwner( + state: Partial<Pick<AppState, 'repos'>>, + repoId: string | undefined, + repoPath: string +): Repo | undefined { + return (state.repos ?? []).find((candidate) => + repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath + ) +} + +function getGitHubRepoOwnerHostId( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): string { + if (repo?.executionHostId || repo?.connectionId) { + return getRepoExecutionHostId(repo) + } + return getSettingsFocusedExecutionHostId(settings) +} + +function getWorkItemsCacheKeyForOwner( + state: Partial<Pick<AppState, 'repos' | 'settings'>>, + repoId: string, + limit: number, + query: string, + repoPath?: string +): string { + const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') + return workItemsCacheKey( + repoId, + limit, + query, + repo ? getGitHubRepoOwnerHostId(state.settings ?? null, repo) : undefined + ) +} + +function getGitHubWorkItemSourceHostId( + state: AppState, + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): ExecutionHostId | undefined { + if (sourceContext?.provider === 'github') { + return sourceContext.hostId + } + return repo + ? (normalizeExecutionHostId(getGitHubRepoOwnerHostId(state.settings, repo)) ?? undefined) + : undefined +} + +function getGitHubWorkItemSourceCacheScope( + state: AppState, + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): string | undefined { + if (sourceContext?.provider === 'github') { + return getTaskSourceCacheScope(sourceContext) + } + return getGitHubWorkItemSourceHostId(state, repo, sourceContext) +} + +function getGitHubWorkItemSourceSettings( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubRepoOwner(settings, repo) } function getGitHubWorkItemRequestContext( state: AppState, settings: AppState['settings'], repoId: string, - repoPath: string + repoPath: string, + sourceContext?: TaskSourceContext | null ): GitHubWorkItemRequestContext { + if (sourceContext?.provider === 'github') { + const parsedHost = parseExecutionHostId(sourceContext.hostId) + if (parsedHost?.kind === 'runtime') { + return { + repoId, + repoPath, + target: { + kind: 'environment', + environmentId: parsedHost.environmentId, + runtimeRepoId: sourceContext.repoId ?? repoId + } + } + } + } const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) return { repoId, @@ -199,12 +333,13 @@ export function projectViewCacheKey( owner: string, projectNumber: number, resolvedViewId: string, - queryOverride?: string + queryOverride?: string, + sourceScope = 'local' ): string { - return `github-project:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` + return `github-project:${sourceScope}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` } -function projectViewRequestKey(args: GetProjectViewTableArgs): string { +function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { // Why: callers without `viewId` can't compute the resolved cache key up // front. Use the input-arg signature for inflight dedup; the resolved // cache key is only known after the main-process IPC returns. @@ -215,7 +350,23 @@ function projectViewRequestKey(args: GetProjectViewTableArgs): string { : args.viewName ? `name:${args.viewName}` : 'default' - return `${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}` + return `${sourceScope}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}` +} + +function projectViewSourceScope(settings: AppState['settings']): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} + +function settingsForProjectViewCacheKey( + settings: AppState['settings'], + cacheKey: string +): Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'> { + const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) + if (runtimeMatch) { + return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } + } + return { ...settings, activeRuntimeEnvironmentId: null } } // Why: module-scope inflight map — must mirror `inflightWorkItemsRequests` @@ -407,6 +558,7 @@ export type CacheEntry<T> = { type FetchOptions = { force?: boolean noCache?: boolean + sourceContext?: TaskSourceContext | null } type RepoScopedFetchOptions = FetchOptions & { @@ -442,11 +594,12 @@ const inflightPRRequests = new Map< { promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string } >() const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>() -type InflightChecksRequest = { +type InflightChecks = { promise: Promise<PRCheckDetail[]> + force: boolean noCache: boolean } -const inflightChecksRequests = new Map<string, InflightChecksRequest>() +const inflightChecksRequests = new Map<string, InflightChecks>() const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>() type InflightWorkItems = { promise: Promise<GitHubWorkItem[]> @@ -505,8 +658,19 @@ function releaseWorkItemSlot(): void { workItemFetchInFlight -= 1 } -export function workItemsCacheKey(repoId: string, limit: number, query: string): string { - return `${repoId}::${limit}::${query}` +export function workItemsCacheKey( + repoId: string, + limit: number, + query: string, + executionHostId?: string | null +): string { + const scope = executionHostId?.trim() ?? '' + const hostId = normalizeExecutionHostId(scope) + const owner = `${repoId}::${limit}::${query}` + if (hostId) { + return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + } + return scope ? `${scope}::${owner}` : owner } function workItemsInflightRequestKey( @@ -518,8 +682,22 @@ function workItemsInflightRequestKey( return `${cacheKey}::${targetPart}` } -function repoScopedCacheKey(repoPath: string, repoId: string | undefined, suffix: string): string { - return `${repoId ?? repoPath}::${suffix}` +export function issueCacheKey( + repoPath: string, + repoId: string | undefined, + issueNumber: number | string, + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + connectionId?: string | null, + executionHostId?: string | null +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + String(issueNumber), + settings, + connectionId, + executionHostId + ) } function runtimeScopedRepoCacheKey( @@ -527,9 +705,32 @@ function runtimeScopedRepoCacheKey( repoId: string | undefined, suffix: string, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId) + return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId, executionHostId) +} + +function sourceScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + sourceContext?: TaskSourceContext | null +): string { + if (sourceContext?.provider === 'github') { + return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` + } + return runtimeScopedRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId + ) } function prCacheKey( @@ -537,9 +738,10 @@ function prCacheKey( repoId: string | undefined, branch: string, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId) + return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) } function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { @@ -659,7 +861,7 @@ function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is return entry !== undefined && Date.now() - entry.fetchedAt < ttl } -function checksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number { +function getPRChecksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number { return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL } @@ -701,8 +903,9 @@ function buildPRRefreshCandidate( repoPath ?? repo.path, repo.id, branch, - state.settings, - repo.connectionId + settingsForGitHubRepoOwner(state.settings, repo), + repo.connectionId, + repo.executionHostId ) const cachedPR = state.prCache[cacheKey]?.data ?? null const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( @@ -710,7 +913,8 @@ function buildPRRefreshCandidate( repoPath ?? repo.path, repo.id, branch, - repo.connectionId + repo.connectionId, + repo.executionHostId ) const cachedFallbackPRNumber = cachedPR?.number ?? null const fallbackPRNumber = @@ -739,6 +943,7 @@ function buildPRRefreshCandidate( isBare: worktree.isBare, isArchived: worktree.isArchived, connectionId: repo.connectionId ?? null, + executionHostId: repo.executionHostId ?? null, connectionState: repo.connectionId ? sshStatus === 'connected' ? 'connected' @@ -758,14 +963,16 @@ function githubHostedReviewFallbackPRNumber( repoPath: string, repoId: string | undefined, branch: string, - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): number | null { const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, branch, state.settings, repoId, - connectionId + connectionId, + executionHostId ) const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data return hostedReview?.provider === 'github' ? hostedReview.number : null @@ -828,6 +1035,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -841,7 +1049,8 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) if ( args.requestStartedAt !== undefined && @@ -985,16 +1194,6 @@ function setPRRefreshStartedHostedReviewEntry( } } -function deletePRRefreshStartedEntriesForEvent( - event: GitHubPRRefreshEvent, - sequences: AppState['prRefreshSequences'] -): void { - for (const alias of event.aliases) { - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - deletePRRefreshStartedEntry(sequences[alias.cacheKey], alias.cacheKey) - } -} - function setGitHubPRResultCaches( state: AppState, args: { @@ -1004,6 +1203,7 @@ function setGitHubPRResultCaches( settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -1020,6 +1220,7 @@ function setGitHubPRResultCaches( settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, + executionHostId: args.executionHostId, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1033,7 +1234,8 @@ function setGitHubPRResultCaches( args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) return { prCache: applyPRCacheResult( @@ -1071,6 +1273,7 @@ function applyGitHubPRResultToCaches(args: { settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -1089,6 +1292,7 @@ function applyGitHubPRResultToCaches(args: { settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, + executionHostId: args.executionHostId, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1102,7 +1306,8 @@ function applyGitHubPRResultToCaches(args: { args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) return { prCache: applyPRCacheResult( @@ -1275,7 +1480,13 @@ export type GitHubSlice = { * background refresh when stale. Callers can render the cached list while * the SWR revalidate hydrates the latest. */ - getCachedWorkItems: (repoId: string, limit: number, query: string) => GitHubWorkItem[] | null + getCachedWorkItems: ( + repoId: string, + limit: number, + query: string, + repoPath?: string, + sourceContext?: TaskSourceContext | null + ) => GitHubWorkItem[] | null /** * Why: the Tasks view header reads sources from the cache to render the * "Issues from owner/repo" indicator, and the Tasks empty/partial banner @@ -1287,7 +1498,8 @@ export type GitHubSlice = { getWorkItemsSourcesAndError: ( repoId: string, limit: number, - query: string + query: string, + repoPath?: string ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } /** * Why: the dialog renders the "Issue from owner/repo" chip for a single work @@ -1304,7 +1516,11 @@ export type GitHubSlice = { * mutated) on every write, so reference equality is preserved between * unchanged entries. */ - getWorkItemsAnySourcesForRepo: (repoId: string, limit: number) => WorkItemsCacheSources | null + getWorkItemsAnySourcesForRepo: ( + repoId: string, + limit: number, + repoPath?: string + ) => WorkItemsCacheSources | null fetchWorkItems: ( repoId: string, repoPath: string, @@ -1321,7 +1537,12 @@ export type GitHubSlice = { * the single-repo behavior of quietly serving stale data. */ fetchWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1332,7 +1553,12 @@ export type GitHubSlice = { * pagination pages are ephemeral and managed by TaskPage state. */ fetchWorkItemsNextPage: ( - repos: { repoId: string; path: string }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1343,15 +1569,31 @@ export type GitHubSlice = { * Returns the sum of per-repo counts for the given query. */ countWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], query: string ) => Promise<number> /** * Fire-and-forget prefetch used by UI entry points (hover/focus of the * "new workspace" buttons) to warm the cache before the page mounts. */ - prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void - patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>, repoId?: string | null) => void + prefetchWorkItems: ( + repoId: string, + repoPath: string, + limit?: number, + query?: string, + options?: { sourceContext?: TaskSourceContext | null } + ) => void + patchWorkItem: ( + itemId: string, + patch: Partial<GitHubWorkItem>, + repoId?: string | null, + options?: GitHubPatchWorkItemOptions + ) => void /** * Monotonic counter bumped whenever a repo's issue-source preference is * flipped. Subscribers (TaskPage's fetch effect) include this in their @@ -1426,7 +1668,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s projectViewCache: {}, fetchProjectViewTable: async (args, options) => { - const requestKey = projectViewRequestKey(args) + const target = getActiveRuntimeTarget(get().settings) + const sourceScope = projectViewSourceScope(get().settings) + const requestKey = projectViewRequestKey(args, sourceScope) // Fast path: when the caller supplies `viewId`, we already know the // resolved cache key and can serve a fresh entry directly. @@ -1436,7 +1680,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s args.owner, args.projectNumber, args.viewId, - args.queryOverride + args.queryOverride, + sourceScope ) : null if (!options?.force && maybeKnownKey) { @@ -1461,7 +1706,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async (): Promise<GetProjectViewTableResult> => { await acquireWorkItemSlot() try { - const target = getActiveRuntimeTarget(get().settings) const envelope = target.kind === 'environment' ? await callRuntimeRpc<GetProjectViewTableResult>( @@ -1478,7 +1722,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s table.project.owner, table.project.number, table.selectedView.id, - args.queryOverride + args.queryOverride, + sourceScope ) set((s) => ({ projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { @@ -1560,7 +1805,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) const result = target.kind === 'environment' ? await callRuntimeRpc<GitHubProjectMutationResult>( @@ -1618,7 +1863,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) const result = target.kind === 'environment' ? await callRuntimeRpc<GitHubProjectMutationResult>( @@ -1719,7 +1964,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // PRs goes through updatePullRequestBySlug; for issues through // updateIssueBySlug. We dispatch both as needed. let envelope: GitHubProjectMutationResult = { ok: true } - const target = getActiveRuntimeTarget(get().settings) + // Why: Project rows may be slug-only and have no registered Orca repo. + // Fall back to the view source encoded in the cache key, not focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) if ( previousRow.itemType === 'PULL_REQUEST' && (updates.title !== undefined || updates.body !== undefined) @@ -1838,7 +2092,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s content: { ...previousRow.content, issueType } } applyRowPatch(set, cacheKey, rowId, optimistic) - const target = getActiveRuntimeTarget(get().settings) + // Why: slug-only Project rows still belong to the source host that loaded + // the view; focused host may have changed after the table was fetched. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) const args = { owner, repo, @@ -1902,13 +2165,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s applyRowPatch(set, cacheKey, rowId, nextRow) }, - getCachedWorkItems: (repoId, limit, query) => { - const key = workItemsCacheKey(repoId, limit, query) + getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { + const state = get() + const key = + sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) + : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) return get().workItemsCache[key]?.data ?? null }, - getWorkItemsSourcesAndError: (repoId, limit, query) => { - const key = workItemsCacheKey(repoId, limit, query) + getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { + const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) const entry = get().workItemsCache[key] return { sources: entry?.sources ?? null, @@ -1916,14 +2183,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } }, - getWorkItemsAnySourcesForRepo: (repoId, limit) => { + getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { const cache = get().workItemsCache - const primaryKey = workItemsCacheKey(repoId, limit, '') + const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) const primary = cache[primaryKey]?.sources if (primary) { return primary } - const prefix = `${repoId}::` + const prefix = primaryKey for (const [key, entry] of Object.entries(cache)) { if (key.startsWith(prefix) && entry.sources) { return entry.sources @@ -1933,21 +2200,28 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s }, fetchWorkItems: async (repoId, repoPath, limit, query, options): Promise<GitHubWorkItem[]> => { - const key = workItemsCacheKey(repoId, limit, query) + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) + const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) + const key = workItemsCacheKey(repoId, limit, query, cacheScope) const cached = get().workItemsCache[key] if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { return cached.data ?? [] } - const requestState = get() - const requestSettings = requestState.settings - const requestRuntimeEnvironmentId = activeRuntimeEnvironmentId(requestSettings) const requestInvalidationNonce = requestState.workItemsInvalidationNonce const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, repoId, - repoPath + repoPath, + options?.sourceContext ) const inflightKey = workItemsInflightRequestKey(key, requestContext.target) const existing = inflightWorkItemsRequests.get(inflightKey) @@ -1993,9 +2267,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s issuesError && envelope.sources.issues ? { ...issuesError, source: envelope.sources.issues } : undefined - // Why: runtime switches reset server-scoped caches; queued old-runtime - // responses can still satisfy callers but must not revive reset entries. - if (activeRuntimeEnvironmentId(get().settings) !== requestRuntimeEnvironmentId) { + const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) + const currentHostId = getGitHubWorkItemSourceHostId( + get(), + currentRepo, + options?.sourceContext + ) + // Why: host focus changes are allowed, but repo ownership changes mean + // this response belongs to an older execution host bucket. + if ((currentHostId ?? null) !== (ownerHostId ?? null)) { return items } // Why: clearing in-flight entries lets the next fetch start, but the @@ -2041,7 +2321,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const perProjectResults = await Promise.all( repos.map(async (r) => { try { - return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, options) + return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { + ...options, + sourceContext: r.sourceContext ?? options?.sourceContext + }) } catch (err) { // Why: fall back to any cache entry (stale or not) before declaring // this repo failed. Matches single-repo behavior of silently serving @@ -2052,7 +2335,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s if (isGitHubWorkItemsSshRemoteRequiredError(err)) { return [] as GitHubWorkItem[] } - const key = workItemsCacheKey(r.repoId, perRepoLimit, query) + const key = + r.sourceContext?.provider === 'github' + ? workItemsCacheKey( + r.repoId, + perRepoLimit, + query, + getTaskSourceCacheScope(r.sourceContext) + ) + : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) const cached = get().workItemsCache[key]?.data if (cached) { console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) @@ -2073,12 +2364,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const perProjectResults = await Promise.all( repos.map(async (r) => { const requestState = get() - const requestSettings = requestState.settings + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, r.repoId, - r.path + r.path, + r.sourceContext ) await acquireWorkItemSlot() try { @@ -2122,12 +2419,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s repos.map(async (r) => { try { const requestState = get() - const requestSettings = requestState.settings + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, r.repoId, - r.path + r.path, + r.sourceContext ) return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) } catch { @@ -2138,15 +2441,25 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s return counts.reduce((sum, c) => sum + c, 0) }, - prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '') => { - const key = workItemsCacheKey(repoId, limit, query) - const cached = get().workItemsCache[key] + prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const key = + options?.sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) + : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) + const cached = get().workItemsCache[key] + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, - requestState.settings, + requestSettings, repoId, - repoPath + repoPath, + options?.sourceContext ) const inflightKey = workItemsInflightRequestKey(key, requestContext.target) // Skip when the cache is fresh or a request is already in flight. @@ -2154,7 +2467,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s return } void get() - .fetchWorkItems(repoId, repoPath, limit, query) + .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) .catch(() => {}) }, @@ -2177,15 +2490,23 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = prCacheKey(repoPath, repoId, branch, requestSettings, repo?.connectionId) + const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) + const cacheKey = prCacheKey( + repoPath, + repoId, + branch, + requestSettings, + repo?.connectionId, + repo?.executionHostId + ) const cached = get().prCache[cacheKey] const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, branch, requestSettings, repoId, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) // Why: if a prior caller without a linkedPR cached `null` for this branch, // the worktree-card lookup (which has a linked PR fallback) would otherwise @@ -2198,7 +2519,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s repoPath, repoId, branch, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) const fallbackPRNumber = linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null @@ -2260,6 +2582,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s fallbackPRNumber, fallbackPRSource, connectionId: repo?.connectionId ?? null, + executionHostId: repo?.executionHostId ?? null, cachedFetchedAt: cached?.fetchedAt ?? null, cachedHasPR: cached?.data ? true : cached ? false : null, cachedPRState: cached?.data?.state ?? null, @@ -2298,6 +2621,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s settings: requestSettings, repoId, connectionId: repo?.connectionId, + executionHostId: repo?.executionHostId, pr, fetchedAt: outcome.fetchedAt, linkedPRNumber, @@ -2348,8 +2672,22 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s }, fetchIssue: async (repoPath, number, options) => { - const repoId = options?.repoId ?? get().repos?.find((repo) => repo.path === repoPath)?.id - const cacheKey = repoScopedCacheKey(repoPath, repoId, String(number)) + const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + String(number), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) const cached = get().issueCache[cacheKey] if (isFresh(cached)) { return cached.data @@ -2362,7 +2700,27 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async () => { try { - const issue = await window.api.gh.issue({ repoPath, repoId, number }) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const issue = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<IssueInfo | null>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.issue', + { repo: requestContext.target.runtimeRepoId, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ + repoPath, + repoId, + number, + sourceContext: options?.sourceContext + }) set((s) => ({ issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { data: issue, @@ -2402,29 +2760,37 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prChecksCacheSuffix(prNumber, prRepo, headSha), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) const legacyCacheKey = headSha - ? runtimeScopedRepoCacheKey( + ? sourceScopedRepoCacheKey( repoPath, repoId, prChecksCacheSuffix(prNumber, prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) : cacheKey const inflightKey = cacheKey - const requestNoCache = options?.noCache === true || options?.force === true const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] if ( - !requestNoCache && - isFresh(cached, checksCacheTtl(cached)) && + !options?.force && + !options?.noCache && + isFresh(cached, getPRChecksCacheTtl(cached)) && (!headSha || cached.headSha === headSha) ) { const cachedChecks = cached.data ?? [] @@ -2437,7 +2803,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s cached.headSha, prRepo, requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) if (prStatusUpdate) { set(prStatusUpdate) @@ -2448,42 +2815,48 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const inflightRequest = inflightChecksRequests.get(inflightKey) if (inflightRequest) { - if (!requestNoCache || inflightRequest.noCache) { + if ( + (options?.force && !inflightRequest.force) || + (options?.noCache && !inflightRequest.noCache) + ) { + await inflightRequest.promise.catch(() => {}) + } else { return inflightRequest.promise } - // Why: manual refreshes must not inherit an automatic request that may - // still be served from the GitHub CLI cache. Wait, then issue fresh. - await inflightRequest.promise.catch(() => {}) - const latestInflightRequest = inflightChecksRequests.get(inflightKey) - if (latestInflightRequest?.noCache) { - return latestInflightRequest.promise - } } const request = (async () => { try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const checks = runtimeRepo - ? await callRuntimeRpc<PRCheckDetail[]>( - runtimeRepo.target, - 'github.prChecks', - { - repo: runtimeRepo.repo.id, + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const checks = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<PRCheckDetail[]>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prChecks', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache) + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + repoId, prNumber, headSha, prRepo: prRepo ?? null, - noCache: requestNoCache - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prChecks({ - repoPath, - repoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: requestNoCache - })) as PRCheckDetail[]) + noCache: Boolean(options?.force || options?.noCache), + sourceContext: options?.sourceContext + })) as PRCheckDetail[]) set((s) => { const nextState: Partial<AppState> = { checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { @@ -2502,7 +2875,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s headSha, prRepo, requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) if (prStatusUpdate?.prCache) { nextState.prCache = prStatusUpdate.prCache @@ -2524,7 +2898,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } })() - inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache }) + inflightChecksRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.force || options?.noCache) + }) return request }, @@ -2533,14 +2911,24 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - return runtimeRepo + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + return requestContext.target.kind === 'environment' ? await callRuntimeRpc<PRCheckRunDetails | null>( - runtimeRepo.target, + { kind: 'environment', environmentId: requestContext.target.environmentId }, 'github.prCheckDetails', { - repo: runtimeRepo.repo.id, + repo: requestContext.target.runtimeRepoId, checkRunId: args.checkRunId, workflowRunId: args.workflowRunId, checkName: args.checkName, @@ -2556,7 +2944,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s workflowRunId: args.workflowRunId, checkName: args.checkName, url: args.url, - prRepo: args.prRepo ?? null + prRepo: args.prRepo ?? null, + sourceContext: options?.sourceContext })) as PRCheckRunDetails | null) }, @@ -2565,13 +2954,19 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) const cached = get().commentsCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -2585,26 +2980,34 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async () => { try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const comments = runtimeRepo - ? await callRuntimeRpc<PRComment[]>( - runtimeRepo.target, - 'github.prComments', - { - repo: runtimeRepo.repo.id, + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const comments = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<PRComment[]>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prComments', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + repoId, prNumber, prRepo: options?.prRepo ?? null, - noCache: options?.force - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prComments({ - repoPath, - repoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force - })) as PRComment[]) + noCache: options?.force, + sourceContext: options?.sourceContext + })) as PRComment[]) set((s) => ({ commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { data: comments, @@ -2629,38 +3032,52 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext ) - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) let result: GitHubCommentResult try { - result = runtimeRepo - ? await callRuntimeRpc<GitHubCommentResult>( - runtimeRepo.target, - 'github.addIssueComment', - { - repo: runtimeRepo.repo.id, + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<GitHubCommentResult>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addIssueComment', + { + repo: requestContext.target.runtimeRepoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addIssueComment({ + repoPath, + repoId, number: prNumber, body, type: 'pr', - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addIssueComment({ - repoPath, - repoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null - }) + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) } catch (err) { const error = err instanceof Error ? err.message : 'Failed to post comment.' return { ok: false, error } @@ -2693,44 +3110,58 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext ) - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) let result: GitHubCommentResult try { - result = runtimeRepo - ? await callRuntimeRpc<GitHubCommentResult>( - runtimeRepo.target, - 'github.addPRReviewCommentReply', - { - repo: runtimeRepo.repo.id, + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<GitHubCommentResult>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addPRReviewCommentReply', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addPRReviewCommentReply({ + repoPath, + repoId, prNumber, commentId, body, threadId: options?.threadId, path: options?.path, line: options?.line, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addPRReviewCommentReply({ - repoPath, - repoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null - }) + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) } catch (err) { const error = err instanceof Error ? err.message : 'Failed to post reply.' return { ok: false, error } @@ -2769,13 +3200,19 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) // Optimistic update: toggle isResolved on all comments in this thread immediately @@ -2793,17 +3230,30 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s })) } - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) let ok = false try { - ok = runtimeRepo - ? await callRuntimeRpc<boolean>( - runtimeRepo.target, - 'github.resolveReviewThread', - { repo: runtimeRepo.repo.id, threadId, resolve }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.resolveReviewThread({ repoPath, repoId, threadId, resolve }) + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<boolean>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.resolveReviewThread', + { repo: requestContext.target.runtimeRepoId, threadId, resolve }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ + repoPath, + repoId, + threadId, + resolve, + sourceContext: options?.sourceContext + }) } catch (err) { console.error('Failed to update review thread:', err) ok = false @@ -2894,12 +3344,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s applyGitHubPRRefreshEvent: (event) => { set((s) => { - // Why: local main-process refresh events are keyed only by repo/branch; - // applying them while a runtime is active can leak local PR state into SSH. - if (getActiveRuntimeTarget(s.settings).kind === 'environment') { - deletePRRefreshStartedEntriesForEvent(event, s.prRefreshSequences) - return {} - } const nextSequences = { ...s.prRefreshSequences } const nextStates = { ...s.prRefreshStates } let nextPRCache = s.prCache @@ -2907,6 +3351,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s let changed = false for (const alias of event.aliases) { + const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) const previousSequence = nextSequences[alias.cacheKey] ?? 0 if ( event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence @@ -2950,7 +3395,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.repoId, prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2959,7 +3405,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.repoId, prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2970,7 +3417,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s undefined, prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2979,21 +3427,22 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s undefined, prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ), `${alias.repoPath}::pr-checks::${pr.number}` ] const checksEntry = checksCacheKeys .map((key) => s.checksCache[key]) - .find( - (entry) => - entry?.data && - entry.headSha && - pr.headSha && - entry.headSha === pr.headSha && - event.outcome.fetchedAt - entry.fetchedAt < checksCacheTtl(entry) - ) - if (checksEntry?.data) { + .find((entry) => entry?.data) + if ( + checksEntry?.data && + checksEntry.headSha && + pr.headSha && + checksEntry.headSha === pr.headSha && + event.outcome.fetchedAt - checksEntry.fetchedAt < + getPRChecksCacheTtl(checksEntry) + ) { return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } } return pr @@ -3013,6 +3462,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s settings: s.settings, repoId: alias.repoId, connectionId: alias.connectionId, + executionHostId: aliasExecutionHostId, pr: data, fetchedAt: event.outcome.fetchedAt, linkedPRNumber: alias.linkedPRNumber, @@ -3036,7 +3486,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.branch, s.settings, alias.repoId, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) setPRRefreshStartedHostedReviewEntry( prRefreshStartedEntryKey(event.sequence, alias.cacheKey), @@ -3111,7 +3562,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const branch = wt.branch.replace(/^refs\/heads\//, '') if (shouldRefreshPRs && !wt.isBare && branch) { - const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { const candidate = buildPRRefreshCandidate(state, wt) @@ -3126,7 +3585,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } } if (shouldRefreshIssues && wt.linkedIssue) { - const issueKey = repoScopedCacheKey(repo.path, repo.id, String(wt.linkedIssue)) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + wt.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) @@ -3138,7 +3605,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s .sort((a, b) => b.score - a.score) .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) for (const { candidate } of candidatesToRefresh) { - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + const candidateSettings = settingsForGitHubRepoOwner( + state.settings, + candidate as Pick<Repo, 'connectionId' | 'executionHostId'> + ) + if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, worktreeId: candidate.worktreeId, @@ -3172,9 +3643,24 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // Invalidate this worktree's cache entries const branch = worktree.branch.replace(/^refs\/heads\//, '') - const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueKey = worktree.linkedIssue - ? repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue)) + ? issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) : '' set((s) => { @@ -3214,11 +3700,20 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } }, - patchWorkItem: (itemId, patch, repoId) => { + patchWorkItem: (itemId, patch, repoId, options) => { set((s) => { const nextCache = { ...s.workItemsCache } let changed = false + const sourceScope = + options?.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(options.sourceContext) + : null for (const key of Object.keys(nextCache)) { + // Why: task edits from one host/account must not optimistically patch + // another host's visually identical GitHub issue or PR cache entry. + if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { + continue + } const entry = nextCache[key] if (!entry?.data) { continue @@ -3262,7 +3757,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // normalizes `'auto'` to `undefined` so the persisted record drops // the key entirely (see main/persistence.ts#updateRepo). const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } - const target = getActiveRuntimeTarget(get().settings) + // Why: persist to the repo's owner host (same routing as updateRepo) so the + // write lands where the repo lives, not on the focused runtime. + const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) await (target.kind === 'local' ? window.api.repos.update({ repoId, updates }) : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) @@ -3396,7 +3893,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - const issueKey = repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue)) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) diff --git a/src/renderer/src/store/slices/hosted-review-cache-identity.ts b/src/renderer/src/store/slices/hosted-review-cache-identity.ts index c8e9a3db9bc..db099a11782 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-identity.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-identity.ts @@ -1,4 +1,9 @@ import type { GlobalSettings } from '../../../../shared/types' +import { + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' export type LinkedReviewHints = { linkedGitHubPR?: number | null @@ -14,18 +19,29 @@ export function getHostedReviewCacheKey( branch: string, settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, repoId?: string | null, - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - const sshConnectionId = connectionId?.trim() - const scope = environmentId - ? `runtime:${environmentId}` - : sshConnectionId - ? `ssh:${sshConnectionId}` - : 'local' + const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId) return `${scope}::${repoId ?? repoPath}::${branch}` } +function getHostedReviewCacheHostScope( + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + connectionId?: string | null, + executionHostId?: string | null +): string { + const hostId = normalizeExecutionHostId(executionHostId) + if (hostId) { + return hostId + } + const sshConnectionId = connectionId?.trim() + if (sshConnectionId) { + return toSshExecutionHostId(sshConnectionId) + } + return getSettingsFocusedExecutionHostId(settings) +} + // Why: a branch-keyed lookup can describe a different PR than the persisted // linked review number. Track that distinction without changing the cache key. export function linkedReviewHintKey(options?: LinkedReviewHints): string { diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 3c68df51bb2..afdc0c65113 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -189,6 +189,72 @@ describe('hosted review slice', () => { ) }) + it('routes runtime-owned review lookups through the owning runtime when local is focused', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) + const store = makeStore(null) + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/runtime/repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } as unknown as AppState['repos'][number] + ] + } as Partial<AppState>) + + await expect( + store.getState().fetchHostedReviewForBranch('/runtime/repo', 'feature/runtime', { + repoId: 'repo-1' + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'hostedReview.forBranch', + expect.objectContaining({ repo: 'repo-1', branch: 'feature/runtime' }), + { timeoutMs: 30_000 } + ) + expect(store.getState().hostedReviewCache['runtime:env-1::repo-1::feature/runtime']).toEqual( + expect.objectContaining({ data: review }) + ) + }) + + it('uses SSH ownership instead of the focused runtime for branch review lookups', async () => { + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-focused' + } as AppState['settings']) + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/ssh/repo', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } as unknown as AppState['repos'][number] + ] + } as Partial<AppState>) + + await expect( + store.getState().fetchHostedReviewForBranch('/ssh/repo', 'feature/ssh', { + repoId: 'repo-1' + }) + ).resolves.toEqual(review) + + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/ssh/repo', repoId: 'repo-1', branch: 'feature/ssh' }) + ) + expect(store.getState().hostedReviewCache['ssh:ssh-1::repo-1::feature/ssh']).toEqual( + expect.objectContaining({ data: review }) + ) + expect( + store.getState().hostedReviewCache['runtime:env-focused::repo-1::feature/ssh'] + ).toBeUndefined() + }) + it('forwards the selected worktree path when creating a local pull request', async () => { mockApi.hostedReview.create.mockResolvedValueOnce({ ok: true, @@ -209,6 +275,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: null, provider: 'github', base: 'main', @@ -241,6 +308,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: 'ssh-1', provider: 'github', base: 'main', @@ -272,6 +340,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.getCreationEligibility).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: 'ssh-1', worktreePath: '/remote/worktree', branch: 'feature/create-pr', diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index f94dec34471..c535ba617bc 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -8,6 +8,7 @@ import type { HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' import { @@ -16,11 +17,13 @@ import { type LinkedReviewHints } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' type CacheEntry<T> = { data: T | null; fetchedAt: number; linkedReviewHintKey?: string } type FetchOptions = { force?: boolean; repoId?: string; staleWhileRevalidate?: boolean } +type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } const CACHE_TTL_MS = 60_000 const HOSTED_REVIEW_CACHE_MAX = 500 @@ -50,6 +53,14 @@ function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> { return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS } +function findHostedReviewRepoByPath( + repos: readonly Repo[] | undefined, + repoPath: string, + repoId?: string | null +): Repo | undefined { + return repos?.find((candidate) => (repoId ? candidate.id === repoId : candidate.path === repoPath)) +} + function shouldRefetchForLinkedHint( cached: CacheEntry<HostedReviewInfo> | undefined, hintKey: string @@ -118,6 +129,26 @@ function withHostedReviewCacheEntry( return pruned } +function settingsForHostedReviewRepoOwner( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned reviews are served by the desktop client's local + // IPC path, even when the sidebar is focused on a runtime host. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + export type HostedReviewSlice = { hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>> getHostedReviewCreationEligibility: ( @@ -125,7 +156,7 @@ export type HostedReviewSlice = { ) => Promise<HostedReviewCreationEligibility> createHostedReview: ( repoPath: string, - input: CreateHostedReviewInput + input: CreateHostedReviewStoreInput ) => Promise<CreateHostedReviewResult> fetchHostedReviewForBranch: ( repoPath: string, @@ -171,9 +202,10 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie getHostedReviewCreationEligibility: async (args) => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) + const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) + const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const target = getActiveRuntimeTarget(ownerSettings) if (target.kind === 'environment') { - const repo = get().repos.find((candidate) => candidate.path === args.repoPath) const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args void _repoPath return callRuntimeRpc<HostedReviewCreationEligibility>( @@ -187,19 +219,21 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie { timeoutMs: 30_000 } ) } - const repo = get().repos.find((candidate) => candidate.path === args.repoPath) return window.api.hostedReview.getCreationEligibility({ ...args, + repoId: repo?.id ?? args.repoId, connectionId: repo?.connectionId ?? null }) }, createHostedReview: async (repoPath, input) => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) + const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId) + const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const target = getActiveRuntimeTarget(ownerSettings) + const { repoId: inputRepoId, ...hostedReviewInput } = input if (target.kind === 'environment') { - const repo = get().repos.find((candidate) => candidate.path === repoPath) - const { worktreePath, ...runtimeInput } = input + const { worktreePath, ...runtimeInput } = hostedReviewInput return callRuntimeRpc<CreateHostedReviewResult>( target, 'hostedReview.create', @@ -211,11 +245,11 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie { timeoutMs: 60_000 } ) } - const repo = get().repos.find((candidate) => candidate.path === repoPath) return window.api.hostedReview.create({ repoPath, + repoId: repo?.id ?? inputRepoId ?? undefined, connectionId: repo?.connectionId ?? null, - ...input + ...hostedReviewInput }) }, @@ -225,17 +259,19 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie options ): Promise<HostedReviewInfo | null> => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) const repo = get().repos?.find((candidate) => options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) + const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const target = getActiveRuntimeTarget(ownerSettings) const repoId = options?.repoId ?? repo?.id const cacheKey = getHostedReviewCacheKey( repoPath, branch, - settings, + ownerSettings, options?.repoId, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) const cached = get().hostedReviewCache[cacheKey] const hintKey = linkedReviewHintKey(options) @@ -273,14 +309,17 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie ? await callRuntimeRpc<HostedReviewInfo | null>( target, 'hostedReview.forBranch', - { repo: options?.repoId ?? repoPath, repoPath, ...args }, + { repo: repo?.id ?? options?.repoId ?? repoPath, repoPath, ...args }, // Why: remote dev boxes can be slower at `git`/`gh` lookups // than local desktop repos, especially on Windows filesystem // paths. The main-process queue caps concurrency, so a longer // timeout no longer risks a background socket stampede. { timeoutMs: 30_000 } ) - : await window.api.hostedReview.forBranch({ repoPath, ...args }) + : await window.api.hostedReview.forBranch({ + repoPath, + ...args + }) if (requestGenerations.get(cacheKey) === generation) { set((state) => { if ( @@ -294,7 +333,14 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie return {} } const prCacheKeys = [ - getGitHubPRCacheKey(repoPath, repoId, branch, settings, repo?.connectionId), + getGitHubPRCacheKey( + repoPath, + repoId, + branch, + ownerSettings, + repo?.connectionId, + repo?.executionHostId + ), getLegacyGitHubPRCacheKey(repoPath, repoId, branch), getLegacyGitHubPRCacheKey(repoPath, undefined, branch) ] diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 6b320ca9c51..565467e0542 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { JiraConnectionStatus, JiraIssue, JiraViewer } from '../../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createJiraSlice } from './jira' @@ -72,6 +76,19 @@ function issue(key: string): JiraIssue { } } +function jiraSourceContext(environmentId: string, siteId = 'site-1'): TaskSourceContext { + return { + kind: 'task-source', + provider: 'jira', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'jira', + siteId + } + } +} + describe('createJiraSlice runtime context', () => { beforeEach(() => { vi.clearAllMocks() @@ -117,6 +134,79 @@ describe('createJiraSlice runtime context', () => { expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue') }) + it('routes explicit source reads through their source context when focused runtime changes', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + const sourceContext = jiraSourceContext('source-runtime') + const sourceResult = deferred<JiraIssue[]>() + jiraListIssues.mockReturnValueOnce(sourceResult.promise) + + const request = store.getState().listJiraIssues('assigned', 30, { sourceContext }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + sourceResult.resolve([{ ...issue('ALP-1'), title: 'Source issue' }]) + await expect(request).resolves.toMatchObject([{ key: 'ALP-1', title: 'Source issue' }]) + expect(jiraListIssues).toHaveBeenCalledWith(sourceContext, 'assigned', 30, 'site-1') + expect(Object.values(store.getState().jiraSearchCache)).toHaveLength(1) + expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined() + }) + + it('scopes optimistic issue patches to the selected Jira source context', () => { + const store = createTestStore() + const localSource = jiraSourceContext('local-runtime') + const remoteSource = jiraSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + store.setState({ + jiraIssueCache: { + [`${localScope}::site-1::ALP-1`]: { + data: { ...issue('ALP-1'), title: 'Local title' }, + fetchedAt: Date.now() + }, + [`${remoteScope}::site-1::ALP-1`]: { + data: { ...issue('ALP-1'), title: 'Remote title' }, + fetchedAt: Date.now() + } + }, + jiraSearchCache: { + [`${localScope}::site-1::list::assigned::30`]: { + data: [{ ...issue('ALP-1'), title: 'Local title' }], + fetchedAt: Date.now() + }, + [`${remoteScope}::site-1::list::assigned::30`]: { + data: [{ ...issue('ALP-1'), title: 'Remote title' }], + fetchedAt: Date.now() + } + } + }) + + store.getState().patchJiraIssue( + 'ALP-1', + { title: 'Patched local title' }, + { + sourceContext: localSource + } + ) + + expect(store.getState().jiraIssueCache[`${localScope}::site-1::ALP-1`]?.data?.title).toBe( + 'Patched local title' + ) + expect(store.getState().jiraIssueCache[`${remoteScope}::site-1::ALP-1`]?.data?.title).toBe( + 'Remote title' + ) + expect( + store.getState().jiraSearchCache[`${localScope}::site-1::list::assigned::30`]?.data?.[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().jiraSearchCache[`${remoteScope}::site-1::list::assigned::30`]?.data?.[0] + ?.title + ).toBe('Remote title') + }) + it('returns a failed Jira connect result when the active runtime changes before completion', async () => { const store = createTestStore() const connectResult = deferred<{ ok: true; viewer: JiraViewer }>() diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index b34d894f5a4..9892ee8e79b 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -24,6 +24,11 @@ import { } from '@/runtime/runtime-jira-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' const CACHE_TTL = 60_000 const MAX_CACHE_ENTRIES = 500 @@ -59,6 +64,16 @@ type InflightJiraReadRequest<T> = { mutationGeneration: number } +type JiraReadOptions = { sourceContext?: TaskSourceContext | null } +type JiraPatchOptions = { sourceContext?: TaskSourceContext | null } + +type JiraReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} + const inflightIssueRequests = new Map<string, InflightJiraReadRequest<JiraIssue | null>>() const inflightSearchRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>() const inflightListRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>() @@ -100,14 +115,40 @@ function isCurrentJiraRuntimeContext(contextKey: string, settings: AppState['set function canWriteJiraReadResult( contextKey: string, mutationGeneration: number, - settings: AppState['settings'] + settings: AppState['settings'], + explicitSource = false ): boolean { return ( mutationGeneration === jiraMutationGeneration && - isCurrentJiraRuntimeContext(contextKey, settings) + (explicitSource || isCurrentJiraRuntimeContext(contextKey, settings)) ) } +function getJiraReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): JiraReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, + cachePrefix: getTaskSourceCacheScope(sourceContext), + explicitSource: true + } +} + +function scopedJiraCacheKey(scope: JiraReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + export type JiraSlice = { jiraStatus: JiraConnectionStatus jiraStatusChecked: boolean @@ -126,10 +167,18 @@ export type JiraSlice = { ) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> selectJiraSite: (siteId: JiraSiteSelection) => Promise<void> disconnectJira: (siteId?: string | null) => Promise<void> - fetchJiraIssue: (key: string, siteId?: string | null) => Promise<JiraIssue | null> - searchJiraIssues: (jql: string, limit?: number) => Promise<JiraIssue[]> - listJiraIssues: (filter?: JiraIssueFilter, limit?: number) => Promise<JiraIssue[]> - patchJiraIssue: (issueKey: string, patch: Partial<JiraIssue>) => void + fetchJiraIssue: ( + key: string, + siteId?: string | null, + options?: JiraReadOptions + ) => Promise<JiraIssue | null> + searchJiraIssues: (jql: string, limit?: number, options?: JiraReadOptions) => Promise<JiraIssue[]> + listJiraIssues: ( + filter?: JiraIssueFilter, + limit?: number, + options?: JiraReadOptions + ) => Promise<JiraIssue[]> + patchJiraIssue: (issueKey: string, patch: Partial<JiraIssue>, options?: JiraPatchOptions) => void } export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, get) => ({ @@ -295,9 +344,10 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, }) }, - fetchJiraIssue: async (key, siteId) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const issueCacheKey = `${siteId ?? 'selected'}::${key}` + fetchJiraIssue: async (key, siteId, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'selected'}::${key}`) const cached = get().jiraIssueCache[issueCacheKey] ?? get().jiraIssueCache[key] if (isFresh(cached)) { return cached.data @@ -312,11 +362,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue | null> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraGetIssue(get().settings, key, siteId) + const promise = jiraGetIssue(scope.settings, key, siteId) .then((issue) => { if ( inflightIssueRequests.get(issueCacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraIssueCache: evictStaleEntries({ @@ -331,14 +386,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] fetchJiraIssue failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -350,7 +415,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -360,10 +430,11 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - searchJiraIssues: async (jql, limit = 30) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + searchJiraIssues: async (jql, limit = 30, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const siteId = getSelectedSiteId(get().jiraStatus) - const cacheKey = `${siteId ?? 'default'}::${jql}::${limit}` + const cacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'default'}::${jql}::${limit}`) const cached = get().jiraSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -378,11 +449,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue[]> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraSearchIssues(get().settings, jql, limit, siteId) + const promise = jiraSearchIssues(scope.settings, jql, limit, siteId) .then((issues) => { if ( inflightSearchRequests.get(cacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraSearchCache: evictStaleEntries({ @@ -397,14 +473,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] searchJiraIssues failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -416,7 +502,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -426,10 +517,11 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - listJiraIssues: async (filter = 'assigned', limit = 30) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + listJiraIssues: async (filter = 'assigned', limit = 30, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const siteId = getSelectedSiteId(get().jiraStatus) - const cacheKey = `${siteId ?? 'default'}::list::${filter}::${limit}` + const cacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'default'}::list::${filter}::${limit}`) const cached = get().jiraSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -444,11 +536,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue[]> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraListIssues(get().settings, filter, limit, siteId) + const promise = jiraListIssues(scope.settings, filter, limit, siteId) .then((issues) => { if ( inflightListRequests.get(cacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraSearchCache: evictStaleEntries({ @@ -463,14 +560,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] listJiraIssues failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -482,7 +589,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -492,12 +604,18 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - patchJiraIssue: (issueKey, patch) => { + patchJiraIssue: (issueKey, patch, options) => { + const sourceScope = + options?.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) set((s) => { let changed = false const nextIssueCache = { ...s.jiraIssueCache } for (const [key, entry] of Object.entries(nextIssueCache)) { - if (entry?.data?.key !== issueKey) { + if (!canPatchCacheKey(key) || entry?.data?.key !== issueKey) { continue } nextIssueCache[key] = { ...entry, data: { ...entry.data, ...patch }, fetchedAt: 0 } @@ -506,7 +624,7 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, const nextSearchCache = { ...s.jiraSearchCache } for (const key of Object.keys(nextSearchCache)) { const entry = nextSearchCache[key] - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const index = entry.data.findIndex((issue) => issue.key === issueKey) diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 725470840a1..190e60773b4 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -12,6 +12,10 @@ import type { LinearTeam, LinearViewer } from '../../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createLinearSlice } from './linear' @@ -88,6 +92,22 @@ function project(id: string): LinearProjectSummary { return { id, name: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' } } +function linearSourceContext( + environmentId: string, + workspaceId = 'workspace-1' +): TaskSourceContext { + return { + kind: 'task-source', + provider: 'linear', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'linear', + workspaceId + } + } +} + function deferred<T>() { let resolve!: (value: T) => void const promise = new Promise<T>((res) => { @@ -752,6 +772,87 @@ describe('createLinearSlice caching', () => { expect(store.getState().getCachedLinearTeams('workspace-1')).toMatchObject([{ id: 'team-1' }]) }) + it('routes explicit source reads through their source context when focused runtime changes', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const sourceContext = linearSourceContext('source-runtime') + const sourceResult = deferred<LinearCollectionResult<LinearIssue>>() + linearListIssues.mockReturnValueOnce(sourceResult.promise) + + const request = store.getState().listLinearIssues('all', 36, { sourceContext }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + sourceResult.resolve({ items: [issue('LIN-SOURCE')] }) + await expect(request).resolves.toMatchObject({ items: [{ id: 'LIN-SOURCE' }] }) + expect(linearListIssues).toHaveBeenCalledWith(sourceContext, 'all', 36, 'workspace-1') + expect( + store + .getState() + .getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { sourceContext }) + ).toMatchObject({ items: [{ id: 'LIN-SOURCE' }] }) + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).toBeNull() + }) + + it('scopes cached Linear teams, projects, and views to the explicit source context', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const localSource = linearSourceContext('local-runtime') + const remoteSource = linearSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + const fetchedAt = Date.now() + + store.setState({ + linearTeamCache: { + [`${localScope}::workspace-1::teams`]: { data: [team('local-team')], fetchedAt }, + [`${remoteScope}::workspace-1::teams`]: { data: [team('remote-team')], fetchedAt } + }, + linearProjectCache: { + [`${localScope}::workspace-1::projects::::20`]: { + data: { items: [project('local-project')] }, + fetchedAt + }, + [`${remoteScope}::workspace-1::projects::::20`]: { + data: { items: [project('remote-project')] }, + fetchedAt + } + }, + linearCustomViewCache: { + [`${localScope}::workspace-1::custom-views::issue::20`]: { + data: { items: [{ id: 'local-view', name: 'Local view', model: 'issue' }] }, + fetchedAt + }, + [`${remoteScope}::workspace-1::custom-views::issue::20`]: { + data: { items: [{ id: 'remote-view', name: 'Remote view', model: 'issue' }] }, + fetchedAt + } + } + }) + + expect( + store.getState().getCachedLinearTeams('workspace-1', { sourceContext: remoteSource }) + ).toMatchObject([{ id: 'remote-team' }]) + expect( + store + .getState() + .getCachedLinearProjects(undefined, 20, 'workspace-1', { sourceContext: remoteSource }) + ).toMatchObject({ items: [{ id: 'remote-project' }] }) + expect( + store + .getState() + .getCachedLinearCustomViews('issue', 20, 'workspace-1', { sourceContext: remoteSource }) + ).toMatchObject({ items: [{ id: 'remote-view' }] }) + expect(store.getState().getCachedLinearTeams('workspace-1')).toBeNull() + expect(store.getState().getCachedLinearProjects(undefined, 20, 'workspace-1')).toBeNull() + expect(store.getState().getCachedLinearCustomViews('issue', 20, 'workspace-1')).toBeNull() + }) + it('patches issue-cache entries keyed by workspace-qualified ids', () => { const store = createTestStore() store.setState({ @@ -794,6 +895,78 @@ describe('createLinearSlice caching', () => { .data?.items[0]?.title ).toBe('Updated') }) + + it('scopes optimistic issue patches to the selected Linear source context', () => { + const store = createTestStore() + const localSource = linearSourceContext('local-runtime') + const remoteSource = linearSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + store.setState({ + linearIssueCache: { + [`${localScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Local title' }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Remote title' }, + fetchedAt: Date.now() + } + }, + linearSearchCache: { + [`${localScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Local title' }], + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Remote title' }], + fetchedAt: Date.now() + } + }, + linearListCache: { + [`${localScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Local title' }] }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Remote title' }] }, + fetchedAt: Date.now() + } + } + }) + + store.getState().patchLinearIssue( + 'issue-id', + { title: 'Patched local title' }, + { + sourceContext: localSource + } + ) + + expect( + store.getState().linearIssueCache[`${localScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Patched local title') + expect( + store.getState().linearIssueCache[`${remoteScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Remote title') + expect( + store.getState().linearSearchCache[`${localScope}::workspace-1::search::query::20`]?.data?.[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearSearchCache[`${remoteScope}::workspace-1::search::query::20`] + ?.data?.[0]?.title + ).toBe('Remote title') + expect( + store.getState().linearListCache[`${localScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearListCache[`${remoteScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Remote title') + }) }) describe('createLinearSlice', () => { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index d6a9bd58696..795d95605c5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -42,6 +42,11 @@ import { } from '@/runtime/runtime-linear-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. @@ -323,7 +328,8 @@ function largestCachedCollectionBelowLimit<T>( function patchLinearIssueCollectionCache( cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>, issueId: string, - patch: Partial<LinearIssue> + patch: Partial<LinearIssue>, + canPatchCacheKey: (key: string) => boolean ): { cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>> changed: boolean @@ -331,7 +337,7 @@ function patchLinearIssueCollectionCache( let changed = false const nextCache = { ...cache } for (const [key, entry] of Object.entries(nextCache)) { - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.items.findIndex((item) => item.id === issueId) @@ -353,7 +359,15 @@ type LinearIssueReadArgs = | { kind: 'search'; query: string; limit?: number } | { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number } -type LinearFetchOptions = { force?: boolean } +type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } +type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } + +type LinearReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} function beginLinearMutation(): number { linearMutationGeneration += 1 @@ -376,15 +390,41 @@ function canWriteLinearReadResult( contextKey: string, generation: number, mutationGeneration: number, - settings: AppState['settings'] + settings: AppState['settings'], + explicitSource = false ): boolean { return ( generation === linearCacheGeneration && mutationGeneration === linearMutationGeneration && - isCurrentLinearRuntimeContext(contextKey, settings) + (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) ) } +function getLinearReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): LinearReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, + cachePrefix: getTaskSourceCacheScope(sourceContext), + explicitSource: true + } +} + +function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + export type LinearSlice = { linearStatus: LinearConnectionStatus linearStatusChecked: boolean @@ -414,12 +454,21 @@ export type LinearSlice = { selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise<void> disconnectLinear: () => Promise<void> disconnectLinearWorkspace: (workspaceId: string) => Promise<void> - fetchLinearIssue: (id: string, workspaceId?: string | null) => Promise<LinearIssue | null> - refreshLinearIssue: (id: string, workspaceId?: string | null) => Promise<LinearIssue | null> + fetchLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise<LinearIssue | null> + refreshLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise<LinearIssue | null> getCachedLinearIssues: ( - args: LinearIssueReadArgs + args: LinearIssueReadArgs, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearIssue[] | LinearCollectionResult<LinearIssue> | null - prefetchLinearIssues: (args: LinearIssueReadArgs) => void + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void searchLinearIssues: ( query: string, limit?: number, @@ -430,7 +479,10 @@ export type LinearSlice = { limit?: number, options?: LinearFetchOptions ) => Promise<LinearCollectionResult<LinearIssue>> - getCachedLinearTeams: (workspaceId?: LinearWorkspaceSelection | null) => LinearTeam[] | null + getCachedLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> + ) => LinearTeam[] | null listLinearTeams: ( workspaceId?: LinearWorkspaceSelection | null, options?: LinearFetchOptions @@ -438,7 +490,8 @@ export type LinearSlice = { getCachedLinearProjects: ( query?: string, limit?: number, - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearCollectionResult<LinearProjectSummary> | null listLinearProjects: ( query?: string, @@ -460,7 +513,8 @@ export type LinearSlice = { getCachedLinearCustomViews: ( model: LinearCustomViewModel, limit?: number, - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearCollectionResult<LinearCustomViewSummary> | null listLinearCustomViews: ( model: LinearCustomViewModel, @@ -486,7 +540,11 @@ export type LinearSlice = { limit?: number, options?: LinearFetchOptions ) => Promise<LinearCollectionResult<LinearProjectSummary>> - patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void + patchLinearIssue: ( + issueId: string, + patch: Partial<LinearIssue>, + options?: LinearPatchOptions + ) => void } export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({ @@ -802,9 +860,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }) }, - fetchLinearIssue: async (id: string, workspaceId?: string | null) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` + fetchLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] if (isFresh(cached)) { return cached.data @@ -822,7 +885,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearIssueRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetIssue(get().settings, id, workspaceId) + const promise = linearGetIssue(scope.settings, id, workspaceId) .then((issue) => { const data = issue as LinearIssue | null if ( @@ -831,7 +894,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -851,7 +915,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -868,7 +933,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -885,8 +951,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - refreshLinearIssue: async (id: string, workspaceId?: string | null) => { - const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` + refreshLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) inflightIssueRequests.delete(issueCacheKey) clearLinearIssueCollectionRequestMaps() set((s) => { @@ -909,26 +980,37 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s linearCustomViewIssueCache: {} } }) - return get().fetchLinearIssue(id, workspaceId) + return get().fetchLinearIssue(id, workspaceId, options) }, - getCachedLinearIssues: (args) => { + getCachedLinearIssues: (args, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const workspaceId = getSelectedWorkspaceId(get().linearStatus) if (args.kind === 'search') { - const cacheKey = linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + ) return get().linearSearchCache[cacheKey]?.data ?? null } const limit = clampLinearIssueListLimit(args.limit) - const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + ) return get().linearListCache[cacheKey]?.data ?? null }, - prefetchLinearIssues: (args) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + prefetchLinearIssues: (args, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) if (args.kind === 'search') { const limit = args.limit ?? 20 - const cacheKey = linearSearchCacheKey(workspaceId, args.query, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, limit) + ) const inflight = inflightSearchRequests.get(cacheKey) if ( isFresh(get().linearSearchCache[cacheKey]) || @@ -939,12 +1021,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return } void get() - .searchLinearIssues(args.query, limit) + .searchLinearIssues(args.query, limit, options) .catch(() => {}) return } const limit = clampLinearIssueListLimit(args.limit) - const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + ) const inflight = inflightListRequests.get(cacheKey) if ( isFresh(get().linearListCache[cacheKey]) || @@ -955,14 +1040,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return } void get() - .listLinearIssues(args.filter, limit) + .listLinearIssues(args.filter, limit, options) .catch(() => {}) }, searchLinearIssues: async (query: string, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearSearchCacheKey(workspaceId, query, limit) + const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) const cached = get().linearSearchCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? [] @@ -981,7 +1067,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearListRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearSearchIssues(get().settings, query, limit, workspaceId) + const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) .then((issues) => { const data = issues as LinearIssue[] if ( @@ -990,7 +1076,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1010,7 +1097,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { @@ -1030,7 +1118,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1049,10 +1138,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearIssues: async (filter = 'assigned', limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearListCacheKey(workspaceId, filter, effectiveLimit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, filter, effectiveLimit) + ) const cached = get().linearListCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearIssue>() @@ -1072,7 +1165,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise: Promise<LinearCollectionResult<LinearIssue>> = linearListIssues( - get().settings, + scope.settings, filter, effectiveLimit, workspaceId @@ -1085,7 +1178,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1105,7 +1199,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { @@ -1125,7 +1220,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1143,15 +1239,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearTeams: (workspaceId) => { + getCachedLinearTeams: (workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) - return get().linearTeamCache[key]?.data ?? null + return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null }, listLinearTeams: async (workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearTeamsCacheKey(resolvedWorkspaceId) + const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) const cached = get().linearTeamCache[cacheKey] if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { return cached.data ?? [] @@ -1170,7 +1268,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearTeamRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListTeams(get().settings, resolvedWorkspaceId) + const promise = linearListTeams(scope.settings, resolvedWorkspaceId) .then((teams) => { const data = teams as LinearTeam[] if ( @@ -1179,7 +1277,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1199,7 +1298,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { @@ -1219,7 +1319,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1237,17 +1338,22 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearProjects: (query, limit = 20, workspaceId) => { + getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit) - return get().linearProjectCache[cacheKey]?.data ?? null + return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null }, listLinearProjects: async (query, limit = 20, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const trimmed = query?.trim() || undefined - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + ) const cached = get().linearProjectCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearProjectSummary>() @@ -1266,7 +1372,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearProjectSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjects(get().settings, trimmed, limit, resolvedWorkspaceId, { + const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { @@ -1276,7 +1382,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1296,7 +1403,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1315,7 +1423,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1334,8 +1443,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, fetchLinearProject: async (id, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'project-detail', id) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-detail', id) + ) const cached = get().linearProjectDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data @@ -1354,7 +1467,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearDetailRequest<LinearProjectDetail | null> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetProject(get().settings, id, workspaceId, { + const promise = linearGetProject(scope.settings, id, workspaceId, { force: options?.force }) .then((project) => { @@ -1364,7 +1477,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1384,7 +1498,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1408,7 +1523,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1426,13 +1542,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearCollectionCacheKey( - workspaceId, - 'project-issues', - projectId, - effectiveLimit + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) ) const cached = get().linearProjectIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1453,7 +1568,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise = linearListProjectIssues( - get().settings, + scope.settings, projectId, effectiveLimit, workspaceId, @@ -1468,7 +1583,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1488,7 +1604,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1515,7 +1632,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1533,16 +1651,21 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearCustomViews: (model, limit = 20, workspaceId) => { + getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - return get().linearCustomViewCache[cacheKey]?.data ?? null + return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null }, listLinearCustomViews: async (model, limit = 20, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + ) const cached = get().linearCustomViewCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearCustomViewSummary>() @@ -1561,7 +1684,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearCustomViewSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViews(get().settings, model, limit, resolvedWorkspaceId, { + const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { @@ -1571,7 +1694,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1591,7 +1715,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1611,7 +1736,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1630,8 +1756,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, fetchLinearCustomView: async (viewId, workspaceId, model, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + ) const cached = get().linearCustomViewDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data @@ -1650,7 +1780,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearDetailRequest<LinearCustomViewSummary | null> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetCustomView(get().settings, viewId, model, workspaceId, { + const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { force: options?.force }) .then((view) => { @@ -1660,7 +1790,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1680,7 +1811,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1704,7 +1836,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1722,13 +1855,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearCollectionCacheKey( - workspaceId, - 'custom-view-issues', - viewId, - effectiveLimit + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) ) const cached = get().linearCustomViewIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1749,7 +1881,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise = linearListCustomViewIssues( - get().settings, + scope.settings, viewId, effectiveLimit, workspaceId, @@ -1764,7 +1896,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1784,7 +1917,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1811,7 +1945,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1830,8 +1965,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + ) const cached = get().linearCustomViewProjectCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearProjectSummary>() @@ -1850,7 +1989,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearProjectSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewProjects(get().settings, viewId, limit, workspaceId, { + const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { force: options?.force }) .then((result) => { @@ -1860,7 +1999,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1880,7 +2020,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1900,7 +2041,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1918,13 +2060,19 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - patchLinearIssue: (issueId, patch) => { + patchLinearIssue: (issueId, patch, options) => { + const sourceScope = + options?.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) set((s) => { let changed = false const nextIssueCache = { ...s.linearIssueCache } for (const [key, issueEntry] of Object.entries(nextIssueCache)) { - if (issueEntry?.data?.id !== issueId) { + if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { continue } // Why: set fetchedAt to 0 so the next fetchLinearIssue call @@ -1940,7 +2088,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextSearchCache = { ...s.linearSearchCache } for (const key of Object.keys(nextSearchCache)) { const entry = nextSearchCache[key] - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.findIndex((item) => item.id === issueId) @@ -1953,7 +2101,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s changed = true } - const nextListCache = patchLinearIssueCollectionCache(s.linearListCache, issueId, patch) + const nextListCache = patchLinearIssueCollectionCache( + s.linearListCache, + issueId, + patch, + canPatchCacheKey + ) if (nextListCache.changed) { changed = true } @@ -1961,7 +2114,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextProjectIssueCache = patchLinearIssueCollectionCache( s.linearProjectIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextProjectIssueCache.changed) { changed = true @@ -1970,7 +2124,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextCustomViewIssueCache = patchLinearIssueCollectionCache( s.linearCustomViewIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextCustomViewIssueCache.changed) { changed = true diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts new file mode 100644 index 00000000000..8ba15f5edd9 --- /dev/null +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { splitRepoReorderByHost } from './repo-reorder-host-split' + +function repo(id: string, executionHostId: string | null): Repo { + return { id, executionHostId, connectionId: null } as unknown as Repo +} + +describe('splitRepoReorderByHost', () => { + it('groups ids by owner host, preserving relative order', () => { + const repos = [ + repo('local-a', 'local'), + repo('runtime-a', 'runtime:env-1'), + repo('local-b', 'local'), + repo('runtime-b', 'runtime:env-1') + ] + const groups = splitRepoReorderByHost(['runtime-a', 'local-a', 'runtime-b', 'local-b'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'runtime:env-1', orderedIds: ['runtime-a', 'runtime-b'] }, + { hostId: 'local', orderedIds: ['local-a', 'local-b'] } + ]) + }) + + it('falls back to the focused host for repos without an explicit owner', () => { + const groups = splitRepoReorderByHost(['a', 'b'], [repo('a', null), repo('b', null)], { + activeRuntimeEnvironmentId: 'focused-env' + }) + expect(groups).toEqual([{ hostId: 'runtime:focused-env', orderedIds: ['a', 'b'] }]) + }) + + it('treats unowned repos as local when no runtime is focused', () => { + const groups = splitRepoReorderByHost(['a', 'b'], [repo('a', null), repo('b', null)], { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a', 'b'] }]) + }) + + it('ignores ids that no longer map to a repo', () => { + const groups = splitRepoReorderByHost(['a', 'gone'], [repo('a', 'local')], { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) + }) +}) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts new file mode 100644 index 00000000000..2ef975b4646 --- /dev/null +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -0,0 +1,47 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId +} from '../../../../shared/execution-host' + +export type RepoReorderHostGroup = { + hostId: string + orderedIds: string[] +} + +/** Split a cross-host reorder permutation into per-host permutations. + * + * Why: each host persists only its own repos and rejects any id list that is not + * a full permutation of that host's repos (persistence.ts#reorderRepos). So a + * single combined id list can only be applied on the host that owns every id — + * never the case once repos span hosts. We instead group ids by their owner host + * (preserving the user's relative order within each host) and dispatch one + * permutation per host. Repos without an explicit owner fall back to the focused + * host, matching the rest of the owner-routing helpers. + */ +export function splitRepoReorderByHost( + orderedIds: readonly string[], + repos: readonly Repo[], + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): RepoReorderHostGroup[] { + const focusedHostId = getSettingsFocusedExecutionHostId(settings) + const hostByRepoId = new Map<string, string>() + for (const repo of repos) { + const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) + hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + } + const groups = new Map<string, string[]>() + for (const id of orderedIds) { + const hostId = hostByRepoId.get(id) + if (!hostId) { + continue + } + const existing = groups.get(hostId) + if (existing) { + existing.push(id) + } else { + groups.set(hostId, [id]) + } + } + return [...groups.entries()].map(([hostId, ids]) => ({ hostId, orderedIds: ids })) +} diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index 589ec56dc9e..019e004aab0 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -525,7 +525,9 @@ describe('project group store routing', () => { expect(folderWorkspacesList).toHaveBeenCalled() expect(reposList).toHaveBeenCalled() expect(store.getState().projectGroups).toEqual([projectGroup]) - expect(store.getState().repos).toEqual([importedRepo]) + // Why: the repos slice stamps fetched repos with their owning execution + // host so multi-host routing never has to guess (multi-host design). + expect(store.getState().repos).toEqual([{ ...importedRepo, executionHostId: 'local' }]) }) it('routes local nested scan progress by scanId and unsubscribes after completion', async () => { @@ -659,7 +661,9 @@ describe('project group store routing', () => { groupId: projectGroup.id, order: 3 }) - expect(store.getState().repos).toEqual([movedRepo]) + // Why: the repos slice stamps updated repos with their owning execution + // host so multi-host routing never has to guess (multi-host design). + expect(store.getState().repos).toEqual([{ ...movedRepo, executionHostId: 'local' }]) }) it('removes local project group subtrees from renderer state after delete', async () => { diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts new file mode 100644 index 00000000000..862babeb51c --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore } from './store-test-helpers' + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +const reposList = vi.fn() +const reposClone = vi.fn() +const reposCloneRemote = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +let runtimeCapabilities: string[] = [] + +function runtimeStatusWithoutProjectHostSetup() { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-remote', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: runtimeCapabilities + }, + _meta: { runtimeId: 'runtime-remote' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeCapabilities = [] + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'status.get') { + return runtimeStatusWithoutProjectHostSetup() + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + clone: reposClone, + cloneRemote: reposCloneRemote + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice project-host setup runtime capability', () => { + it('falls back to repo-derived project setup state when a remote runtime lacks support', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + }) + + it('blocks runtime project setup when the server does not advertise support', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project clone before mutating unsupported servers', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project setup mutations when workspace run-context support is missing', async () => { + runtimeCapabilities = [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts new file mode 100644 index 00000000000..b8398436e02 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +const projectsCreateHostSetup = vi.fn() +const projectsUpdateHostSetup = vi.fn() +const projectsDeleteHostSetup = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const runtimeRepo: Repo = { + id: 'runtime-repo', + path: '/srv/project', + displayName: 'Project', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' +} + +const runtimeSetup: ProjectHostSetup = { + id: 'setup-gpu', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: '', + path: '/srv/project', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 1 +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + projectsCreateHostSetup.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: vi.fn() + }, + projects: { + createHostSetup: projectsCreateHostSetup, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice project host setup lifecycle', () => { + it('creates independent project host setup metadata through local IPC', async () => { + const setup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + path: '', + setupState: 'setting-up' + } + projectsCreateHostSetup.mockResolvedValue({ project, setup }) + const store = createTestStore() + + await expect( + store.getState().createProjectHostSetup({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ).resolves.toEqual({ project, setup }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsCreateHostSetup).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + }) + + it('updates runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-update-setup', + ok: true, + result: { + result: { + project, + setup: { ...runtimeSetup, displayName: 'GPU VM renamed' } + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().updateProjectHostSetup({ + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }) + ).resolves.toEqual({ + project, + setup: { ...runtimeSetup, displayName: 'GPU VM renamed' }, + repo: undefined + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.update', + params: { + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }, + timeoutMs: 15_000 + }) + }) + + it('deletes runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-delete-setup', + ok: true, + result: { result: { project, setup: runtimeSetup } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectHostSetup({ setupId: runtimeSetup.id }) + ).resolves.toEqual({ + project, + setup: runtimeSetup, + repo: undefined + }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.delete', + params: { setupId: runtimeSetup.id }, + timeoutMs: 15_000 + }) + }) + + it('preserves runtime-fetched setup-only states during repo hydration', async () => { + const pendingSetup: ProjectHostSetup = { + ...runtimeSetup, + id: 'setup-pending', + repoId: '', + path: '', + setupState: 'setting-up' + } + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repos', + ok: true, + result: { repos: [runtimeRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-projects', + ok: true, + result: { projects: [project] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-setups', + ok: true, + result: { setups: [pendingSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + throw new Error(`Unexpected runtime method: ${args.method}`) + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setup-pending', + hostId: 'runtime:env-1', + setupState: 'setting-up' + }) + ]) + ) + }) +}) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index fd12d5c7c29..0572187df52 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -24,12 +24,28 @@ const remoteRepo: Repo = { addedAt: 2 } +const sshRepo: Repo = { + id: 'ssh-repo', + path: '/home/orca/project', + displayName: 'SSH', + badgeColor: '#222', + addedAt: 3, + connectionId: 'ssh-1' +} + const reposList = vi.fn() const reposAdd = vi.fn() const reposPickFolder = vi.fn() +const reposClone = vi.fn() +const reposCloneRemote = vi.fn() const reposRemove = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() +const projectsCreateHostSetup = vi.fn() +const projectsSetupExistingFolder = vi.fn() +const projectsUpdateHostSetup = vi.fn() +const projectsDeleteHostSetup = vi.fn() +const projectGroupsMoveProject = vi.fn() const ptyKill = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -39,9 +55,16 @@ beforeEach(() => { reposList.mockReset() reposAdd.mockReset() reposPickFolder.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() reposRemove.mockReset() reposUpdate.mockReset() reposReorder.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + projectGroupsMoveProject.mockReset() ptyKill.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() @@ -53,11 +76,22 @@ beforeEach(() => { repos: { list: reposList, add: reposAdd, + clone: reposClone, + cloneRemote: reposCloneRemote, pickFolder: reposPickFolder, remove: reposRemove, update: reposUpdate, reorder: reposReorder }, + projects: { + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + projectGroups: { + moveProject: projectGroupsMoveProject + }, pty: { kill: ptyKill }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -71,11 +105,70 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos() - expect(store.getState().repos).toEqual([localRepo]) + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:local-repo', sourceRepoIds: ['local-repo'] }) + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'local-repo', hostId: 'local' }) + ]) expect(reposList).toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('hydrates projects from local IPC when the project API is available', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const projectsList = vi.fn().mockResolvedValue([project]) + const listHostSetups = vi.fn().mockResolvedValue([setup]) + ;( + window.api as typeof window.api & { + projects?: { + list: typeof projectsList + listHostSetups: typeof listHostSetups + createHostSetup: typeof projectsCreateHostSetup + setupExistingFolder: typeof projectsSetupExistingFolder + updateHostSetup: typeof projectsUpdateHostSetup + deleteHostSetup: typeof projectsDeleteHostSetup + } + } + ).projects = { + list: projectsList, + listHostSetups, + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + } + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos() + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsList).toHaveBeenCalled() + expect(listHostSetups).toHaveBeenCalled() + }) + it('fetches repos from the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', @@ -92,7 +185,13 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos() - expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:remote-repo', sourceRepoIds: ['remote-repo'] }) + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) expect(store.getState().activeRepoId).toBeNull() expect(store.getState().filterRepoIds).toEqual(['remote-repo']) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -120,6 +219,7 @@ describe('repo slice runtime routing', () => { await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) expect(store.getState().repos[0]?.displayName).toBe('Renamed') + expect(store.getState().repos[0]?.executionHostId).toBe('runtime:env-1') expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'repo.update', @@ -129,6 +229,24 @@ describe('repo slice runtime routing', () => { expect(reposUpdate).not.toHaveBeenCalled() }) + it('updates SSH-owned repos through local IPC even when a runtime is focused', async () => { + reposUpdate.mockResolvedValue({ ...sshRepo, displayName: 'SSH Renamed' }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [sshRepo] + }) + + await store.getState().updateRepo(sshRepo.id, { displayName: 'SSH Renamed' }) + + expect(store.getState().repos[0]?.displayName).toBe('SSH Renamed') + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: sshRepo.id, + updates: { displayName: 'SSH Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('adds explicit server paths through the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-add', @@ -141,11 +259,12 @@ describe('repo slice runtime routing', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual( - remoteRepo - ) + await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual({ + ...remoteRepo, + executionHostId: 'runtime:env-1' + }) - expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'repo.add', @@ -156,6 +275,373 @@ describe('repo slice runtime routing', () => { expect(reposPickFolder).not.toHaveBeenCalled() }) + it('sets up a project on a local host through the project setup API', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'local-repo', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'local', + path: '/local', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...localRepo, executionHostId: 'local' } + }) + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + path: '/local', + kind: 'git' + }) + }) + + it('sets up a project on the active runtime host through runtime RPC', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'remote-repo', + projectId: project.id, + hostId: 'local', + repoId: 'remote-repo', + path: '/srv/project', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-setup', + ok: true, + result: { result: { project, setup, repo: remoteRepo } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' }, + repo: { ...remoteRepo, executionHostId: 'runtime:env-1' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.setupExistingFolder', + params: { + projectId: project.id, + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }, + timeoutMs: 15_000 + }) + }) + + it('sets up an SSH host through local IPC even when a runtime is focused', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['ssh-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'ssh-repo', + projectId: project.id, + hostId: 'ssh:openclaw%202', + repoId: 'ssh-repo', + path: '/srv/project', + displayName: 'Remote', + connectionId: 'openclaw 2', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + projectsSetupExistingFolder.mockResolvedValue({ + project, + setup, + repo: { ...remoteRepo, connectionId: 'openclaw 2' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'ssh:openclaw%202', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...remoteRepo, connectionId: 'openclaw 2', executionHostId: 'ssh:openclaw%202' } + }) + + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'ssh:openclaw%202', + path: '/srv/project', + kind: 'git' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('clones a project locally before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...localRepo, path: '/workspace/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + reposClone.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'local', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'local' } + }) + + expect(reposClone).toHaveBeenCalledWith({ + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }) + }) + + it('clones a project on a runtime host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...remoteRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-clone', + ok: true, + result: { repo: clonedRepo }, + _meta: { runtimeId: 'runtime-remote' } + }) + .mockResolvedValueOnce({ + id: 'rpc-setup', + ok: true, + result: { result: { project, setup, repo: clonedRepo } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' }, + repo: { ...clonedRepo, executionHostId: 'runtime:env-1' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'repo.clone', + params: { + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }, + timeoutMs: 10 * 60_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'projectHostSetup.setupExistingFolder', + params: { + projectId: project.id, + hostId: 'runtime:env-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }, + timeoutMs: 15_000 + }) + }) + + it('clones a project on an SSH host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['ssh-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...sshRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'ssh:ssh-1', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + reposCloneRemote.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'ssh:ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'ssh:ssh-1' } + }) + + expect(reposCloneRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'ssh:ssh-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }) + }) + + it('keeps runtime ownership when a runtime repo is moved between groups', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-move', + ok: true, + result: { repo: { ...remoteRepo, projectGroupId: 'group-1' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [{ ...remoteRepo, executionHostId: 'runtime:env-1' }] + }) + + await expect(store.getState().moveProjectToGroup(remoteRepo.id, 'group-1')).resolves.toBe(true) + + expect(store.getState().repos).toEqual([ + { ...remoteRepo, projectGroupId: 'group-1', executionHostId: 'runtime:env-1' } + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.moveProject', + params: { repo: remoteRepo.id, groupId: 'group-1', order: undefined }, + timeoutMs: 15_000 + }) + expect(projectGroupsMoveProject).not.toHaveBeenCalled() + }) + it('does not open the client folder picker when a remote runtime environment is active', async () => { const store = createTestStore() store.setState({ @@ -195,6 +681,26 @@ describe('repo slice runtime routing', () => { expect(reposRemove).not.toHaveBeenCalled() }) + it('removes SSH-owned repos through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const worktreeId = `${sshRepo.id}::/home/orca/wt` + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [sshRepo], + activeRepoId: sshRepo.id, + worktreesByRepo: { + [sshRepo.id]: [makeWorktree({ id: worktreeId, repoId: sshRepo.id })] + } + }) + + await store.getState().removeProject(sshRepo.id) + + expect(store.getState().repos).toEqual([]) + expect(store.getState().activeRepoId).toBeNull() + expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('evicts GitHub caches for removed repos using repo id and legacy path keys', async () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index cee1ffa9ac2..6e781c15d52 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -6,12 +6,31 @@ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' import type { + Project, Repo, ProjectGroup, + ProjectHostSetup, FolderWorkspace, ProjectGroupImportResult, - NestedRepoScanResult + NestedRepoScanResult, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult } from '../../../../shared/types' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../../shared/project-host-setup-projection' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS, type FolderWorkspacePathStatus, @@ -25,12 +44,23 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + getActiveRuntimeTarget +} from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' -import { buildDismissedOnboardingFolderAgentStartup } from '../../lib/onboarding-folder-agent-startup' -import { markOnboardingProjectAdded } from '../../lib/onboarding-project-checklist' -import { filterSetupScriptPromptDismissalsToValidRepos } from '../../lib/setup-script-prompt' -import { translate } from '../../i18n/i18n' +import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' +import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' @@ -148,6 +178,204 @@ function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { return [...ids] } +function getRuntimeTargetHostId( + target: ReturnType<typeof getActiveRuntimeTarget> +): ReturnType<typeof toRuntimeExecutionHostId> | typeof LOCAL_EXECUTION_HOST_ID { + return target.kind === 'environment' + ? toRuntimeExecutionHostId(target.environmentId) + : LOCAL_EXECUTION_HOST_ID +} + +function getProjectSetupRuntimeTarget( + hostId: ProjectHostSetupExistingFolderArgs['hostId'] +): ReturnType<typeof getActiveRuntimeTarget> { + const parsedHost = parseExecutionHostId(hostId) + return parsedHost?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsedHost.environmentId } + : { kind: 'local' } +} + +function repoWithFetchedOwner(repo: Repo, target: ReturnType<typeof getActiveRuntimeTarget>): Repo { + if (repo.connectionId) { + return { ...repo, executionHostId: getRepoExecutionHostId(repo) } + } + return { ...repo, executionHostId: getRuntimeTargetHostId(target) } +} + +function setupWithFetchedOwner( + setup: ProjectHostSetup, + target: ReturnType<typeof getActiveRuntimeTarget> +): ProjectHostSetup { + const hostId = getRuntimeTargetHostId(target) + if (target.kind !== 'environment' || setup.hostId !== LOCAL_EXECUTION_HOST_ID) { + return setup + } + return { + ...setup, + hostId, + executionHostId: hostId + } +} + +async function fetchProjectHostSetupCompatibility( + target: ReturnType<typeof getActiveRuntimeTarget>, + repos: readonly Repo[] +): Promise<ProjectHostSetupProjection> { + try { + if (target.kind === 'local') { + const projectsApi = ( + window.api as typeof window.api & { + projects?: { + list?: () => Promise<Project[]> + listHostSetups?: () => Promise<ProjectHostSetup[]> + } + } + ).projects + if (!projectsApi?.list || !projectsApi.listHostSetups) { + throw new Error('projects_api_unavailable') + } + return { + projects: await projectsApi.list(), + setups: await projectsApi.listHostSetups() + } + } + await assertProjectHostSetupRuntimeCapability(target) + const [projectResponse, setupResponse] = await Promise.all([ + callRuntimeRpc<{ projects: Project[] }>(target, 'project.list', undefined, { + timeoutMs: 15_000 + }), + callRuntimeRpc<{ setups: ProjectHostSetup[] }>(target, 'projectHostSetup.list', undefined, { + timeoutMs: 15_000 + }) + ]) + return { + projects: projectResponse.projects, + setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) + } + } catch { + // Why: newer clients must still hydrate against older runtimes/preloads + // that only know `repo.list`; derive the transitional model locally. + return projectHostSetupProjectionFromRepos(repos) + } +} + +async function assertProjectHostSetupRuntimeCapability( + target: ReturnType<typeof getActiveRuntimeTarget> +): Promise<void> { + if (target.kind !== 'environment') { + return + } + await assertRuntimeEnvironmentCapability( + target.environmentId, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + 'The selected Orca server does not support project host setup yet. Update Orca on the server and try again.', + 15_000 + ) +} + +async function assertProjectHostSetupMutationRuntimeCapabilities( + target: ReturnType<typeof getActiveRuntimeTarget> +): Promise<void> { + if (target.kind !== 'environment') { + return + } + await assertProjectHostSetupRuntimeCapability(target) + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + 'The selected Orca server does not support explicit workspace run hosts yet. Update Orca on the server and try again.', + 15_000 + ) +} + +function projectCompatibilityFromRepos( + repos: readonly Repo[] +): Pick<RepoSlice, 'projects' | 'projectHostSetups'> { + const projection = projectHostSetupProjectionFromRepos(repos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +function mergeProjectHostSetupCompatibility( + derived: Pick<RepoSlice, 'projects' | 'projectHostSetups'>, + fetched: ProjectHostSetupProjection +): Pick<RepoSlice, 'projects' | 'projectHostSetups'> { + return { + projects: mergeById(derived.projects, fetched.projects), + projectHostSetups: mergeById(derived.projectHostSetups, fetched.setups) + } +} + +function mergeById<T extends { id: string }>(base: readonly T[], overlay: readonly T[]): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} + +function mergeFetchedReposForHost( + previous: readonly Repo[], + fetched: Repo[], + hostId: string +): Repo[] { + const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const preserved = previous.filter((repo) => { + const existingHostId = getRepoExecutionHostId(repo) + return existingHostId !== hostId || fetchedIds.has(repo.id) + }) + const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) + const merged = [...preserved] + for (const repo of fetched) { + const existingIndex = merged.findIndex((entry) => entry.id === repo.id) + if (existingIndex === -1) { + merged.push(repo) + continue + } + merged[existingIndex] = repo + } + return reconcileFetchedRepos( + previous, + merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) + ) +} + +function settingsForRepoOwner(state: Pick<AppState, 'repos' | 'settings'>, repoId: string) { + const repo = state.repos.find((entry) => entry.id === repoId) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + if (parsed?.kind !== 'ssh') { + return state.settings + } + // Why: SSH repos are owned through local IPC/SSH plumbing. Existing repo + // mutations must not follow whichever runtime server is currently focused. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { return request.scope === 'project-group' ? `project-group:${request.projectGroupId}` @@ -257,6 +485,8 @@ function getFolderWorkspacePathStatusRequestSnapshotForRead( export type RepoSlice = { repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] folderWorkspaces: FolderWorkspace[] folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry> @@ -266,6 +496,19 @@ export type RepoSlice = { fetchFolderWorkspaces: () => Promise<void> addRepo: () => Promise<Repo | null> addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null> + setupProjectExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise<ProjectHostSetupResult | null> + createProjectHostSetup: ( + args: ProjectHostSetupCreateArgs + ) => Promise<ProjectHostSetupCreateResult | null> + updateProjectHostSetup: ( + args: ProjectHostSetupUpdateArgs + ) => Promise<ProjectHostSetupUpdateResult | null> + deleteProjectHostSetup: ( + args: ProjectHostSetupDeleteArgs + ) => Promise<ProjectHostSetupDeleteResult | null> + setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null> addNonGitFolder: (path: string) => Promise<Repo | null> scanNestedRepos: ( path: string, @@ -344,6 +587,8 @@ export type RepoSlice = { export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({ repos: [], + projects: [], + projectHostSetups: [], projectGroups: [], folderWorkspaces: [], folderWorkspacePathStatuses: {}, @@ -352,7 +597,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, fetchRepos: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const repos = + const fetchedRepos = target.kind === 'local' ? await window.api.repos.list() : ( @@ -366,11 +611,25 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, { timeoutMs: 15_000 } ) ).repos + const hostId = getRuntimeTargetHostId(target) + const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) + const fetchedProjectCompatibility = await fetchProjectHostSetupCompatibility(target, repos) set((s) => { - const validRepoIds = new Set(repos.map((repo) => repo.id)) - const reconciledRepos = reconcileFetchedRepos(s.repos, repos) + const reconciledRepos = mergeFetchedReposForHost(s.repos, repos, hostId) + const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + const projectCompatibility = + target.kind === 'local' + ? { + projects: fetchedProjectCompatibility.projects, + projectHostSetups: fetchedProjectCompatibility.setups + } + : mergeProjectHostSetupCompatibility( + projectCompatibilityFromRepos(reconciledRepos), + fetchedProjectCompatibility + ) return { repos: reconciledRepos, + ...projectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -683,6 +942,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, updateProjectGroup: async (groupId, updates) => { try { + // Why: project groups are focused-host-scoped by design — fetch/create/update/ + // delete all route by the focused host, and the list is replaced (not merged). const target = getActiveRuntimeTarget(get().settings) const updated = target.kind === 'local' @@ -711,6 +972,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, deleteProjectGroup: async (groupId) => { try { + // Why: project groups are focused-host-scoped by design (see updateProjectGroup). const target = getActiveRuntimeTarget(get().settings) const deleted = target.kind === 'local' @@ -815,7 +1077,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' ? await window.api.projectGroups.moveProject({ @@ -834,8 +1096,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, if (!moved) { return false } + const ownedMoved = repoWithFetchedOwner(moved, target) set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)), + repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), folderWorkspacePathStatuses: {} })) return true @@ -879,6 +1142,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, openModal('confirm-non-git-folder', { folderPath: path }) return null } + repo = repoWithFetchedOwner(repo, target) const alreadyAdded = get().repos.some((r) => r.id === repo.id) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) @@ -887,7 +1151,12 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, if (s.repos.some((r) => r.id === repo.id)) { return s } - return { repos: [...s.repos, repo], folderWorkspacePathStatuses: {} } + const nextRepos = [...s.repos, repo] + return { + repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), + folderWorkspacePathStatuses: {} + } }) if (alreadyAdded) { toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), { @@ -916,15 +1185,235 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, } }, + setupProjectExistingFolder: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.setupExistingFolder(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupResult }>( + target, + 'projectHostSetup.setupExistingFolder', + args, + { timeoutMs: 15_000 } + ) + ).result + const repo = repoWithFetchedOwner(result.repo, target) + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => { + const nextRepos = s.repos.some((entry) => entry.id === repo.id) + ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...s.repos, repo] + const nextProjects = s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project] + const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + return { + repos: nextRepos, + projects: nextProjects, + projectHostSetups: nextSetups + } + }) + toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { + description: repo.displayName + }) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to set up project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + createProjectHostSetup: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.createHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupCreateResult }>( + target, + 'projectHostSetup.create', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => ({ + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { project: result.project, setup } + } catch (err) { + console.error('Failed to create project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + updateProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.updateHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>( + target, + 'projectHostSetup.update', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + set((s) => ({ + repos: repo + ? s.repos.some((entry) => entry.id === repo.id) + ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...s.repos, repo] + : s.repos, + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to update project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + deleteProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.deleteHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>( + target, + 'projectHostSetup.delete', + args, + { timeoutMs: 15_000 } + ) + ).result + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + set((s) => { + const projectHostSetups = s.projectHostSetups.filter( + (setup) => setup.id !== result.setup.id + ) + const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos + const projects = + repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) + ? s.projects.filter((project) => project.id !== result.project.id) + : s.projects + return { repos, projects, projectHostSetups } + }) + return { ...result, repo } + } catch (err) { + console.error('Failed to delete project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + setupProjectClone: async (args) => { + try { + const parsedHost = parseExecutionHostId(args.hostId) + const target = getProjectSetupRuntimeTarget(args.hostId) + if (parsedHost?.kind !== 'ssh') { + await assertProjectHostSetupMutationRuntimeCapabilities(target) + } + const repo = + parsedHost?.kind === 'ssh' + ? await window.api.repos.cloneRemote({ + connectionId: parsedHost.targetId, + url: args.url, + destination: args.destination + }) + : target.kind === 'local' + ? await window.api.repos.clone({ + url: args.url, + destination: args.destination + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: args.url, + destination: args.destination + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + return await get().setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName, + setupMethod: 'cloned' + }) + } catch (err) { + console.error('Failed to clone project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + addRepo: async () => { const target = getActiveRuntimeTarget(get().settings) if (target.kind !== 'local') { // Why: OS folder pickers return client-local paths. Remote environments - // need an explicit server path, which the Add Project dialog handles. + // need an explicit host path, which the Add Project dialog handles. toast.error( translate( 'auto.store.slices.repos.e649269645', - 'Use a server path to add projects from a remote runtime.' + 'Use Add Project to enter a path on the selected host.' ) ) return null @@ -982,7 +1471,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, removeProject: async (projectId) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) await (target.kind === 'local' ? window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) @@ -1074,6 +1563,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, const nextRepos = s.repos.filter((r) => r.id !== projectId) return { repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -1116,7 +1606,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -1128,13 +1618,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, { timeoutMs: 15_000 } ) ).repo - set((s) => ({ - repos: s.repos.map((r) => { + set((s) => { + const nextRepos = s.repos.map((r) => { if (r.id !== projectId) { return r } if (updatedRepo) { - return updatedRepo + return repoWithFetchedOwner(updatedRepo, target) } if (sanitizedUpdates.sourceControlAi === null) { const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = r @@ -1148,9 +1638,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, ...updatesWithoutSourceControlAi, ...(sourceControlAi !== undefined ? { sourceControlAi } : {}) } - }), - folderWorkspacePathStatuses: {} - })) + }) + return { + repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to update repo:', err) @@ -1191,19 +1685,34 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, // Caller passed a non-permutation — refuse to apply locally. return } - set({ repos: next, folderWorkspacePathStatuses: {} }) + set({ + repos: next, + ...projectCompatibilityFromRepos(next), + folderWorkspacePathStatuses: {} + }) try { - const target = getActiveRuntimeTarget(get().settings) - const result = - target.kind === 'local' - ? await window.api.repos.reorder({ orderedIds }) - : await callRuntimeRpc<{ status: 'applied' | 'rejected' }>( - target, - 'repo.reorder', - { orderedIds }, - { timeoutMs: 15_000 } - ) - if (result.status === 'rejected') { + // Why: each host persists only its own repos and rejects non-permutations, + // so split the cross-host order into per-host permutations and dispatch one + // reorder per owner host. + const groups = splitRepoReorderByHost(orderedIds, next, get().settings) + const results = await Promise.all( + groups.map(async (group) => { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + return target.kind === 'local' + ? window.api.repos.reorder({ orderedIds: group.orderedIds }) + : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + }) + ) + if (results.some((result) => result.status === 'rejected')) { await get().fetchRepos() } } catch (err) { diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts new file mode 100644 index 00000000000..975823de779 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { create } from 'zustand' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' + +function createSliceStore() { + return create<RuntimeStatusSlice>()((...a) => ({ + ...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>)) + })) +} + +function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus { + return { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3, + ...overrides + } as RuntimeStatus +} + +describe('runtime-status slice', () => { + it('starts with an empty map', () => { + const store = createSliceStore() + expect(store.getState().runtimeEnvironments).toEqual([]) + expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(0) + }) + + it('stores saved runtime environments and trims stale statuses', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('drop', { status: makeStatus(), checkedAt: 1 }) + + store.getState().setRuntimeEnvironments([ + { + id: 'keep', + name: 'Dev Box', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws-keep', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: 'ws-keep' + } + ]) + + expect(store.getState().runtimeEnvironments.map((environment) => environment.name)).toEqual([ + 'Dev Box' + ]) + expect(store.getState().runtimeStatusByEnvironmentId.has('keep')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('drop')).toBe(false) + }) + + it('merges per environment id and produces a new map reference', () => { + const store = createSliceStore() + const before = store.getState().runtimeStatusByEnvironmentId + + store.getState().setRuntimeEnvironmentStatus('env-a', { + status: makeStatus(), + checkedAt: 1 + }) + const afterFirst = store.getState().runtimeStatusByEnvironmentId + expect(afterFirst).not.toBe(before) + expect(afterFirst.get('env-a')?.checkedAt).toBe(1) + + store.getState().setRuntimeEnvironmentStatus('env-b', { + status: null, + checkedAt: 2 + }) + const afterSecond = store.getState().runtimeStatusByEnvironmentId + expect(afterSecond.size).toBe(2) + expect(afterSecond.get('env-a')?.checkedAt).toBe(1) + expect(afterSecond.get('env-b')?.status).toBeNull() + }) + + it('overwrites the prior entry for the same id', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 5 }) + + const map = store.getState().runtimeStatusByEnvironmentId + expect(map.size).toBe(1) + expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5 }) + }) + + it('clears a single environment entry', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-b', { status: makeStatus(), checkedAt: 1 }) + + store.getState().clearRuntimeEnvironmentStatus('env-a') + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true) + }) + + it('no-ops clearing an unknown id without creating a new reference', () => { + const store = createSliceStore() + const before = store.getState().runtimeStatusByEnvironmentId + store.getState().clearRuntimeEnvironmentStatus('missing') + expect(store.getState().runtimeStatusByEnvironmentId).toBe(before) + }) + + it('retains only saved environment ids', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('drop', { status: makeStatus(), checkedAt: 1 }) + + store.getState().retainRuntimeEnvironmentStatuses(['keep']) + const map = store.getState().runtimeStatusByEnvironmentId + expect(map.has('keep')).toBe(true) + expect(map.has('drop')).toBe(false) + }) + + it('no-ops retain when nothing is dropped', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + const before = store.getState().runtimeStatusByEnvironmentId + + store.getState().retainRuntimeEnvironmentStatuses(['keep', 'unrelated']) + expect(store.getState().runtimeStatusByEnvironmentId).toBe(before) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts new file mode 100644 index 00000000000..dd93c61ac19 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -0,0 +1,120 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' + +/** Live status for one saved runtime environment, as last observed by the + * renderer. `status === null` records a probe that failed or timed out so the + * sidebar can still distinguish "unknown/unreachable" from "never checked". */ +export type RuntimeEnvironmentStatus = { + status: RuntimeStatus | null + appVersion?: string | null + checkedAt: number +} + +export type RuntimeStatusSlice = { + /** Saved remote Orca servers. Host pickers use this to show user-chosen names + * instead of opaque runtime ids. */ + runtimeEnvironments: PublicKnownRuntimeEnvironment[] + /** Keyed by runtime environment id. Fed into buildExecutionHostRegistry so + * compat verdicts/blocked health show live in the sidebar host pickers. */ + runtimeStatusByEnvironmentId: Map<string, RuntimeEnvironmentStatus> + /** Replaces the saved-environment list and trims stale status entries. */ + setRuntimeEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void + /** Merges one environment's status. Replaces the prior entry for that id. */ + setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void + /** Drops a removed environment so stale hosts don't linger in the registry. */ + clearRuntimeEnvironmentStatus: (environmentId: string) => void + /** Drops every entry whose id is not in the saved-environments set. */ + retainRuntimeEnvironmentStatuses: (environmentIds: Iterable<string>) => void + /** Best-effort: list saved environments and probe each so the sidebar shows + * live health at boot, before the settings pane is ever opened. */ + hydrateRuntimeEnvironmentStatuses: () => Promise<void> +} + +export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeStatusSlice> = ( + set, + get +) => ({ + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + + setRuntimeEnvironments: (environments) => + set((s) => { + const keep = new Set(environments.map((environment) => environment.id)) + const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) + let statusesChanged = false + for (const id of nextStatuses.keys()) { + if (!keep.has(id)) { + nextStatuses.delete(id) + statusesChanged = true + } + } + return { + runtimeEnvironments: environments, + ...(statusesChanged ? { runtimeStatusByEnvironmentId: nextStatuses } : {}) + } + }), + + setRuntimeEnvironmentStatus: (environmentId, status) => + set((s) => { + const next = new Map(s.runtimeStatusByEnvironmentId) + next.set(environmentId, status) + return { runtimeStatusByEnvironmentId: next } + }), + + clearRuntimeEnvironmentStatus: (environmentId) => + set((s) => { + if (!s.runtimeStatusByEnvironmentId.has(environmentId)) { + return s + } + const next = new Map(s.runtimeStatusByEnvironmentId) + next.delete(environmentId) + return { runtimeStatusByEnvironmentId: next } + }), + + retainRuntimeEnvironmentStatuses: (environmentIds) => + set((s) => { + const keep = new Set(environmentIds) + let changed = false + const next = new Map(s.runtimeStatusByEnvironmentId) + for (const id of next.keys()) { + if (!keep.has(id)) { + next.delete(id) + changed = true + } + } + return changed ? { runtimeStatusByEnvironmentId: next } : s + }), + + hydrateRuntimeEnvironmentStatuses: async () => { + let environments: PublicKnownRuntimeEnvironment[] + try { + environments = await window.api.runtimeEnvironments.list() + } catch (err) { + console.error('Failed to list runtime environments for status hydration:', err) + return + } + get().setRuntimeEnvironments(environments) + // Why: fire-and-forget per env; one unreachable server must not block the + // others, and a failure records a null status rather than nothing. + await Promise.allSettled( + environments.map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const status = unwrapRuntimeRpcResult<RuntimeStatus>(response) + get().setRuntimeEnvironmentStatus(environment.id, { status, checkedAt: Date.now() }) + } catch { + get().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + } + }) + ) + } +}) diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index c2261859b05..dd113fa668a 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { AppState } from '../types' @@ -23,6 +22,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentGetStatus = vi.fn() const settingsSet = vi.fn().mockResolvedValue(undefined) +const worktreesListDetected = vi.fn() const env2Lineage: WorktreeLineage = { worktreeId: 'repo-env-2::/env-2/repo', @@ -49,70 +49,92 @@ beforeEach(() => { }, _meta: { runtimeId: 'runtime-2' } }) - runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { - const result = - method === 'status.get' - ? { - runtimeId: 'runtime-2', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } - : method === 'repo.list' + runtimeEnvironmentCall.mockImplementation( + ({ method, params }: { method: string; params?: { repo?: string } }) => { + const detectedRepoId = params?.repo ?? 'repo-env-2' + const detectedPath = detectedRepoId === 'repo-env-1' ? '/env-1/repo' : '/env-2/repo' + const result = + method === 'status.get' ? { - repos: [ - { - id: 'repo-env-2', - path: '/env-2/repo', - displayName: 'Env 2', - badgeColor: 'blue', - addedAt: 1 - } - ] + runtimeId: 'runtime-2', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - : method === 'worktree.list' + : method === 'repo.list' ? { - worktrees: [ - makeWorktree({ - id: 'repo-env-2::/env-2/repo', - repoId: 'repo-env-2', - path: '/env-2/repo' - }) - ], - totalCount: 1, - truncated: false + repos: [ + { + id: 'repo-env-2', + path: '/env-2/repo', + displayName: 'Env 2', + badgeColor: 'blue', + addedAt: 1 + } + ] } - : method === 'worktree.detectedList' + : method === 'worktree.list' ? { - repoId: 'repo-env-2', - authoritative: true, - source: 'git', worktrees: [ - { - ...makeWorktree({ - id: 'repo-env-2::/env-2/repo', - repoId: 'repo-env-2', - path: '/env-2/repo' - }), - ownership: 'orca-managed', - selectedCheckout: true, - visible: true - } - ] + makeWorktree({ + id: 'repo-env-2::/env-2/repo', + repoId: 'repo-env-2', + path: '/env-2/repo' + }) + ], + totalCount: 1, + truncated: false } - : method === 'browser.profile.list' - ? { profiles: [] } - : method === 'projectGroup.list' - ? { groups: [] } - : method === 'worktree.lineageList' - ? { lineage: { [env2Lineage.worktreeId]: env2Lineage } } - : {} - return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + : method === 'worktree.detectedList' + ? { + repoId: detectedRepoId, + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: `${detectedRepoId}::${detectedPath}`, + repoId: detectedRepoId, + path: detectedPath + }), + ownership: 'orca-managed', + selectedCheckout: true, + visible: true + } + ] + } + : method === 'browser.profileList' + ? { profiles: [] } + : method === 'projectGroup.list' + ? { groups: [] } + : method === 'worktree.lineageList' + ? { lineage: { [env2Lineage.worktreeId]: env2Lineage } } + : {} + return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + } + ) + worktreesListDetected.mockResolvedValue({ + repoId: 'repo-env-1', + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: 'repo-env-1::/env-1/repo', + repoId: 'repo-env-1', + path: '/env-1/repo' + }), + ownership: 'orca-managed', + selectedCheckout: true, + visible: true + } + ] }) vi.stubGlobal('window', { api: { settings: { set: settingsSet }, - runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus } + runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus }, + worktrees: { listDetected: worktreesListDetected } } }) }) @@ -163,11 +185,18 @@ describe('createSettingsSlice runtime switching', () => { ]) }) - it('clears stale runtime-owned state before loading the selected environment', async () => { + it('preserves existing host state while loading the selected environment', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], projectGroups: [ { id: 'group-env-1', @@ -229,7 +258,7 @@ describe('createSettingsSlice runtime switching', () => { selector: 'env-2', timeoutMs: 15_000 }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'status.get' }) ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith( @@ -238,49 +267,59 @@ describe('createSettingsSlice runtime switching', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'worktree.lineageList' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'terminal-a' } - }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'legacy-terminal' } - }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'browser.tabClose', - params: { worktree: 'id:repo-env-1::/env-1/repo', page: 'remote-page-1' } - }) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-1', 'repo-env-2']) + expect(store.getState().repos.find((repo) => repo.id === 'repo-env-2')?.executionHostId).toBe( + 'runtime:env-2' ) - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-2']) - expect(store.getState().projectGroups).toEqual([]) + expect(store.getState().projectGroups.map((group) => group.id)).toEqual(['group-env-1']) + expect(store.getState().worktreesByRepo['repo-env-1']?.map((worktree) => worktree.id)).toEqual([ + 'repo-env-1::/env-1/repo' + ]) expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([ 'repo-env-2::/env-2/repo' ]) expect(store.getState().worktreeLineageById).toEqual({ + 'repo-env-1::/env-1/repo': { + ...env2Lineage, + worktreeId: 'repo-env-1::/env-1/repo', + parentWorktreeId: 'repo-env-1::/env-1/parent' + }, [env2Lineage.worktreeId]: env2Lineage }) - expect(store.getState().activeWorktreeId).toBeNull() - expect(store.getState().openFiles).toEqual([]) - expect(store.getState().editorDrafts).toEqual({}) - expect(store.getState().markdownViewMode).toEqual({}) - expect(store.getState().editorViewMode).toEqual({}) - expect(store.getState().markdownFrontmatterVisible).toEqual({}) - expect(store.getState().editorCursorLine).toEqual({}) - expect(store.getState().showDotfilesByWorktree).toEqual({}) - expect(store.getState().gitIgnoredPathsByWorktree).toEqual({}) - expect(store.getState().ptyIdsByTabId).toEqual({}) - expect(store.getState().browserTabsByWorktree).toEqual({}) - expect(store.getState().prCache).toEqual({}) - expect(store.getState().linearIssueCache).toEqual({}) - expect(store.getState().jiraIssueCache).toEqual({}) + expect(store.getState().activeWorktreeId).toBe('repo-env-1::/env-1/repo') + expect(store.getState().openFiles).toEqual([ + { id: '/env-1/repo/a.md', worktreeId: 'repo-env-1::/env-1/repo' } + ]) + expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/stale.md': 'stale' }) + expect(store.getState().markdownViewMode).toEqual({ '/env-1/repo/stale.md': 'rich' }) + expect(store.getState().editorViewMode).toEqual({ '/env-1/repo/stale.md': 'changes' }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ + '/env-1/repo/stale.md': true + }) + expect(store.getState().editorCursorLine).toEqual({ '/env-1/repo/stale.md': 4 }) + expect(store.getState().showDotfilesByWorktree).toEqual({ 'repo-env-1::/env-1/repo': false }) + expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': ['dist/'] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ tab1: ['remote:env-1@@terminal-a'] }) + expect(store.getState().browserTabsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': [{ id: 'browser-env-1' }] + }) + expect(store.getState().prCache).toEqual({ + '/env-1/repo::main': expect.objectContaining({ data: null }) + }) + expect(store.getState().linearIssueCache).toEqual({ + 'LIN-1': expect.objectContaining({ data: { id: 'LIN-1' } }) + }) + expect(store.getState().jiraIssueCache).toEqual({ + 'JIRA-1': expect.objectContaining({ data: { key: 'JIRA-1' } }) + }) }) it('does not close host-owned mirrored resources when a paired web client switches servers', async () => { @@ -288,7 +327,14 @@ describe('createSettingsSlice runtime switching', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], worktreesByRepo: { 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] }, @@ -335,11 +381,108 @@ describe('createSettingsSlice runtime switching', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) ) - expect(store.getState().ptyIdsByTabId).toEqual({}) - expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({}) + expect(store.getState().ptyIdsByTabId).toEqual({ + 'web-terminal-host-tab-1': ['remote:env-1@@terminal-a'] + }) + expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({ + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }) }) - it('refuses to switch environments while editor tabs have unsaved state', async () => { + it('keeps the previous host live terminal and browser resources intact on switch (multi-host keepalive)', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], + worktreesByRepo: { + 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] + }, + activeWorktreeId: 'repo-env-1::/env-1/repo', + tabsByWorktree: { + 'repo-env-1::/env-1/repo': [ + { + id: 'host-tab-1', + ptyId: 'remote:env-1@@terminal-a', + worktreeId: 'repo-env-1::/env-1/repo', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'host-tab-1': ['remote:env-1@@terminal-a'] }, + terminalLayoutsByTabId: { + 'host-tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-1@@terminal-a' } + } + }, + browserPagesByWorkspace: { + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] as never + }, + remoteBrowserPageHandlesByPageId: { + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + + // No teardown RPC was issued against the previous host's live resources. + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) + ) + + // Every previous-host map is byte-for-byte unchanged after the switch. + expect(store.getState().tabsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': [ + { + id: 'host-tab-1', + ptyId: 'remote:env-1@@terminal-a', + worktreeId: 'repo-env-1::/env-1/repo', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ + 'host-tab-1': ['remote:env-1@@terminal-a'] + }) + expect(store.getState().terminalLayoutsByTabId).toEqual({ + 'host-tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-1@@terminal-a' } + } + }) + expect(store.getState().browserPagesByWorkspace).toEqual({ + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] + }) + expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({ + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }) + }) + + it('allows switching focus while editor tabs have unsaved state', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], @@ -353,16 +496,16 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/dirty.md': 'draft' } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) - expect(settingsSet).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) + ) + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-2') expect(store.getState().openFiles).toHaveLength(1) expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/dirty.md': 'draft' }) - expect(toast.error).toHaveBeenCalledWith( - 'Save or close unsaved editor tabs before switching servers.' - ) + expect(toast.error).not.toHaveBeenCalled() }) it('keeps the current environment when the selected remote server is unreachable', async () => { diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index f1fb069401e..f7b05e003f3 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -1,20 +1,13 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/types' import { toast } from 'sonner' import { - callRuntimeRpc, clearRuntimeCompatibilityCache, markRuntimeEnvironmentCompatible, unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { assertRuntimeStatusCompatible } from '@/runtime/runtime-protocol-compat' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { - getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle -} from '@/runtime/runtime-terminal-stream' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' @@ -49,210 +42,6 @@ function createOpenInApplicationId(): string { ) } -function runtimeScopedStateReset(): Partial<AppState> { - return { - repos: [], - projectGroups: [], - folderWorkspaces: [], - activeRepoId: null, - sparsePresetsByRepo: {}, - sparsePresetsLoadingByRepo: {}, - sparsePresetsLoadStatusByRepo: {}, - sparsePresetsErrorByRepo: {}, - worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - worktreeLineageById: {}, - activeWorktreeId: null, - activeWorkspaceKey: null, - deleteStateByWorktreeId: {}, - baseStatusByWorktreeId: {}, - remoteBranchConflictByWorktreeId: {}, - sortEpoch: 0, - everActivatedWorktreeIds: new Set<string>(), - lastVisitedAtByWorktreeId: {}, - hasHydratedWorktreePurge: false, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - activeGroupIdByWorktree: {}, - layoutByWorktree: {}, - tabsByWorktree: {}, - activeTabId: null, - activeTabIdByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - unreadTerminalTabs: {}, - suppressedPtyExitIds: {}, - pendingCodexPaneRestartIds: {}, - codexRestartNoticeByPtyId: {}, - expandedPaneByTabId: {}, - canExpandPaneByTabId: {}, - terminalLayoutsByTabId: {}, - pendingStartupByTabId: {}, - pendingSetupSplitByTabId: {}, - pendingIssueCommandSplitByTabId: {}, - tabBarOrderByWorktree: {}, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {}, - lastKnownRelayPtyIdByTabId: {}, - pendingSnapshotByPtyId: {}, - pendingColdRestoreByPtyId: {}, - deferredSshReconnectTargets: [], - deferredSshSessionIdsByTabId: {}, - cacheTimerByKey: {}, - recentQuickCommandIdByGroup: {}, - showDotfilesByWorktree: {}, - expandedDirs: {}, - pendingExplorerReveal: null, - openFiles: [], - editorDrafts: {}, - markdownViewMode: {}, - editorViewMode: {}, - markdownFrontmatterVisible: {}, - editorCursorLine: {}, - gitIgnoredPathsByWorktree: {}, - activeFileId: null, - activeFileIdByWorktree: {}, - activeTabTypeByWorktree: {}, - activeTabType: 'terminal', - recentlyClosedEditorTabsByWorktree: {}, - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - browserAnnotationsByPageId: {}, - remoteBrowserPageHandlesByPageId: {}, - activeBrowserTabId: null, - activeBrowserTabIdByWorktree: {}, - recentlyClosedBrowserTabsByWorktree: {}, - recentlyClosedBrowserPagesByWorkspace: {}, - pendingAddressBarFocusByTabId: {}, - pendingAddressBarFocusByPageId: {}, - browserSessionProfiles: [], - browserSessionImportState: null, - defaultBrowserSessionProfileId: null, - detectedBrowsers: [], - detectedBrowsersLoaded: false, - prCache: {}, - issueCache: {}, - checksCache: {}, - commentsCache: {}, - workItemsCache: {}, - workItemsInvalidationNonce: 0, - projectViewCache: {}, - linearStatus: { connected: false, viewer: null }, - linearStatusChecked: false, - linearStatusContextKey: null, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - jiraStatus: { connected: false, viewer: null }, - jiraStatusChecked: false, - jiraStatusContextKey: null, - jiraIssueCache: {}, - jiraSearchCache: {} - } -} - -function hasUnsavedEditorState(state: AppState): boolean { - return state.openFiles.some((file) => file.isDirty || state.editorDrafts[file.id] !== undefined) -} - -function isPairedWebClient(): boolean { - return Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) -} - -async function closeRemoteBrowserPagesBeforeRuntimeSwitch(state: AppState): Promise<void> { - const worktreeIdByPageId = new Map<string, string>() - for (const pages of Object.values(state.browserPagesByWorkspace)) { - for (const page of pages) { - worktreeIdByPageId.set(page.id, page.worktreeId) - } - } - await Promise.allSettled( - Object.entries(state.remoteBrowserPageHandlesByPageId).map(([pageId, handle]) => { - const worktreeId = worktreeIdByPageId.get(pageId) - if (!worktreeId) { - return Promise.resolve() - } - return callRuntimeRpc( - { kind: 'environment', environmentId: handle.environmentId }, - 'browser.tabClose', - { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, - { timeoutMs: 15_000 } - ) - }) - ) -} - -function collectRemoteTerminalHandlesForRuntimeSwitch( - state: AppState, - fallbackEnvironmentId: string | null -): Map<string, Set<string>> { - const handlesByEnvironmentId = new Map<string, Set<string>>() - const collect = (ptyId: string | null | undefined): void => { - if (!ptyId) { - return - } - const handle = getRemoteRuntimeTerminalHandle(ptyId) - if (!handle) { - return - } - const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) ?? fallbackEnvironmentId - if (!environmentId) { - return - } - const handles = handlesByEnvironmentId.get(environmentId) ?? new Set<string>() - handles.add(handle) - handlesByEnvironmentId.set(environmentId, handles) - } - - for (const ptyIds of Object.values(state.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - collect(ptyId) - } - } - for (const tabs of Object.values(state.tabsByWorktree)) { - for (const tab of tabs) { - collect(tab.ptyId) - } - } - for (const layout of Object.values(state.terminalLayoutsByTabId)) { - for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { - collect(ptyId) - } - } - return handlesByEnvironmentId -} - -async function closeRemoteTerminalsBeforeRuntimeSwitch( - state: AppState, - fallbackEnvironmentId: string | null -): Promise<void> { - const handlesByEnvironmentId = collectRemoteTerminalHandlesForRuntimeSwitch( - state, - fallbackEnvironmentId - ) - await Promise.allSettled( - Array.from(handlesByEnvironmentId.entries()).flatMap(([environmentId, handles]) => - Array.from(handles).map((terminal) => - callRuntimeRpc( - { kind: 'environment', environmentId }, - 'terminal.close', - { terminal }, - { timeoutMs: 15_000 } - ) - ) - ) - ) -} - async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise<void> { if (!environmentId) { return @@ -276,6 +65,10 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> try { const settings = await window.api.settings.get() set({ settings }) + // Why: best-effort boot probe so sidebar host pickers show live runtime + // health before the settings pane is ever opened. Fire-and-forget to keep + // startup off the network round-trips. + void get().hydrateRuntimeEnvironmentStatuses() } catch (err) { console.error('Failed to fetch settings:', err) } @@ -343,40 +136,23 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> if (previousId === nextId) { return true } - if (hasUnsavedEditorState(get())) { - toast.error( - translate( - 'auto.store.slices.settings.faa8fb83dd', - 'Save or close unsaved editor tabs before switching servers.' - ) - ) - return false - } try { clearRuntimeCompatibilityCache(nextId) await verifyRuntimeEnvironmentReachable(nextId) - if (!isPairedWebClient()) { - // Why: desktop-created remote resources live on their owning server. - // Paired web clients only mirror host-owned tabs/PTYs, so switching - // pairings must detach local state without killing the host session. - await closeRemoteTerminalsBeforeRuntimeSwitch(get(), previousId) - await closeRemoteBrowserPagesBeforeRuntimeSwitch(get()) - } const nextSettings = await window.api.settings.set({ activeRuntimeEnvironmentId: nextId }) bumpProviderRuntimeSessionGeneration() set((s) => ({ - ...runtimeScopedStateReset(), + // Why: in the multi-host model this is a focus/default-host change, + // not a teardown boundary. Existing host-owned sessions stay alive. settings: (nextSettings as GlobalSettings | undefined) ?? (s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null) })) - // Why: server-owned state is cleared before refetch so old worktree, - // terminal, browser, and issue IDs cannot be used against the new server - // while the new environment is loading. + // Why: hydration is host-merged by downstream slices. Switching focus + // should add/update the selected host without discarding other hosts. await get().fetchRepos() - await get().fetchProjectGroups() await get().fetchAllWorktrees() await get().fetchWorktreeLineage() await get().fetchBrowserSessionProfiles() diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 7373d4b9587..c44eb2fe8ff 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2107,6 +2107,105 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(capture).toHaveBeenCalledWith({ includeLocalBuffers: false }) }) + it('does not stop the active runtime when sleeping an SSH-owned worktree', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + repos: [ + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'ssh:ssh-1@@pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.stop' }) + ) + expect(mockApi.pty.kill).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', { keepHistory: true }) + }) + + it('stops the owner runtime when sleeping a runtime-owned compatibility worktree', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'runtime-1', + method: 'terminal.stop' + }) + ) + }) + + it('stops the explicit owner runtime when another host is focused', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [ + { + id: 'repo1', + path: '/path/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'terminal.stop' + }) + ) + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'terminal.stop' + }) + ) + }) + it('drops live agentStatusByPaneKey entries on sleep so the working row disappears', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index a65cb122a2c..34fac9864db 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -37,6 +37,7 @@ import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' +import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { translate } from '@/i18n/i18n' @@ -80,6 +81,7 @@ export function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index cd1b95a4947..a39e9c7e519 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -41,7 +41,7 @@ import { } from '@/components/terminal-pane/pty-transport' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -49,6 +49,7 @@ import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connectio import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set<number>() @@ -233,6 +234,13 @@ export function worktreeUsesRemoteConnection( return Boolean(repo?.connectionId) } +function resolveTerminalStopRuntimeEnvironmentId( + state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>, + worktreeId: string +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, worktreeId) +} + export type TerminalSlice = { tabsByWorktree: Record<string, TerminalTab[]> activeTabId: string | null @@ -759,7 +767,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> } const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }) .__ORCA_WEB_CLIENT__ - ? state.settings?.activeRuntimeEnvironmentId?.trim() + ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null if (pairedWebRuntimeEnvironmentId) { const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') @@ -1765,10 +1773,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> return } - const target = getActiveRuntimeTarget(get().settings) - if (target.kind === 'environment') { + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + if (runtimeEnvironmentId) { await callRuntimeRpc( - target, + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'terminal.stop', { worktree: toRuntimeWorktreeSelector(worktreeId) }, { timeoutMs: 15_000 } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index a8f4e8fbc0d..57f05c45484 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -4,12 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' import type { GitHubWorkItem, + JiraIssue, LinearIssue, PersistedUIState, TerminalTab, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import { createUISlice } from './ui' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createSettingsSearchState } from './settings-search-state' @@ -19,6 +21,7 @@ import type { FeatureInteractionState } from '../../../../shared/feature-interac import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' const mocks = vi.hoisted(() => ({ sendBracketedPasteToRunningAgent: vi.fn(), @@ -140,6 +143,40 @@ function makeLinearIssue(overrides: Partial<LinearIssue> = {}): LinearIssue { } as LinearIssue } +function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem { + return { + id: 'mr-12', + type: 'mr', + number: 12, + title: 'Fix runner routing', + state: 'opened', + url: 'https://gitlab.com/acme/repo/-/merge_requests/12', + labels: [], + updatedAt: '2026-05-30T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1', + ...overrides + } +} + +function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue { + return { + id: 'ORC-1', + key: 'ORC-1', + title: 'Fix task source context', + url: 'https://example.atlassian.net/browse/ORC-1', + siteId: 'site-1', + siteName: 'Example Jira', + project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + ...overrides + } +} + function makePersistedUI(overrides: Partial<PersistedUIState> = {}): PersistedUIState { return { ...getDefaultUIState(), @@ -582,6 +619,15 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showSleepingWorkspaces).toBe(true) }) + it('defaults workspace host scope to all hosts', () => { + expect(getDefaultUIState().workspaceHostScope).toBe('all') + expect(createUIStore().getState().workspaceHostScope).toBe('all') + expect(getDefaultUIState().visibleWorkspaceHostIds).toBeNull() + expect(createUIStore().getState().visibleWorkspaceHostIds).toBeNull() + expect(getDefaultUIState().workspaceHostOrder).toEqual([]) + expect(createUIStore().getState().workspaceHostOrder).toEqual([]) + }) + it('preserves the current right sidebar width when older persisted UI omits it', () => { const store = createUIStore() @@ -641,6 +687,106 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().rightSidebarExplorerView).toBe('search') }) + it('hydrates a persisted workspace host scope', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ workspaceHostScope: 'ssh:win%20vm' })) + + expect(store.getState().workspaceHostScope).toBe('ssh:win%20vm') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['ssh:win%20vm']) + }) + + it('hydrates a persisted visible workspace host set', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceHostScope: 'ssh:win%20vm', + visibleWorkspaceHostIds: [ + 'local', + 'ssh:win%20vm', + 'bogus' as NonNullable<PersistedUIState['visibleWorkspaceHostIds']>[number], + 'local' + ] + }) + ) + + expect(store.getState().workspaceHostScope).toBe('ssh:win%20vm') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'ssh:win%20vm']) + }) + + it('hydrates a persisted workspace host order', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceHostOrder: [ + 'ssh:win%20vm', + 'bogus' as NonNullable<PersistedUIState['workspaceHostOrder']>[number], + 'local', + 'ssh:win%20vm' + ] + }) + ) + + expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local']) + }) + + it('falls back to all hosts for invalid persisted workspace host scopes', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI( + makePersistedUI({ workspaceHostScope: 'bogus' as PersistedUIState['workspaceHostScope'] }) + ) + + expect(store.getState().workspaceHostScope).toBe('all') + expect(store.getState().visibleWorkspaceHostIds).toBeNull() + }) + + it('persists workspace host scope changes', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostScope('runtime:env-1') + + expect(store.getState().workspaceHostScope).toBe('runtime:env-1') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['runtime:env-1']) + expect(setUI).toHaveBeenCalledWith({ + workspaceHostScope: 'runtime:env-1', + visibleWorkspaceHostIds: ['runtime:env-1'] + }) + }) + + it('persists visible workspace host changes independently of focused host', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostScope('runtime:env-1') + store.getState().setVisibleWorkspaceHostIds(['local', 'runtime:env-1']) + + expect(store.getState().workspaceHostScope).toBe('runtime:env-1') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'runtime:env-1']) + expect(setUI).toHaveBeenLastCalledWith({ + workspaceHostScope: 'runtime:env-1', + visibleWorkspaceHostIds: ['local', 'runtime:env-1'] + }) + }) + + it('persists workspace host order changes', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostOrder(['ssh:win%20vm', 'bogus' as never, 'local']) + + expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local']) + expect(setUI).toHaveBeenCalledWith({ workspaceHostOrder: ['ssh:win%20vm', 'local'] }) + }) + it('hydrates persisted per-worktree dotfile visibility', () => { const store = createUIStore() @@ -1264,11 +1410,94 @@ describe('createUISlice settings navigation', () => { 'repo-1', '/repo', expect.any(Number), - 'is:issue is:open' + 'is:issue is:open', + { sourceContext: null } ) expect(prefetchLinearIssues).not.toHaveBeenCalled() }) + it('prefetches direct GitHub task opens with their source context', () => { + const store = createUIStore() + const prefetchWorkItems = vi.fn() + const workItem = makeGitHubWorkItem() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' } + } + + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + } + ], + settings: { + visibleTaskProviders: ['github'], + defaultTaskSource: 'github', + defaultTaskViewPreset: 'all' + } as unknown as AppState['settings'], + prefetchWorkItems + } as unknown as Partial<AppState>) + + store.getState().openTaskPage({ + taskSource: 'github', + preselectedRepoId: 'repo-1', + openGitHubWorkItem: workItem, + openGitHubSourceContext: sourceContext + }) + + expect(prefetchWorkItems).toHaveBeenCalledWith( + 'repo-1', + '/repo', + expect.any(Number), + 'is:issue is:open', + { sourceContext } + ) + }) + + it('prefetches direct Linear task opens with their source context', () => { + const store = createUIStore() + const prefetchLinearIssues = vi.fn() + const linearIssue = makeLinearIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1' } + } + + store.setState({ + settings: { + visibleTaskProviders: ['linear'], + defaultTaskSource: 'linear' + } as unknown as AppState['settings'], + linearStatus: { connected: true } as AppState['linearStatus'], + prefetchLinearIssues + } as unknown as Partial<AppState>) + + store.getState().openTaskPage({ + taskSource: 'linear', + openLinearIssue: linearIssue, + openLinearSourceContext: sourceContext + }) + + expect(prefetchLinearIssues).toHaveBeenCalledWith( + { kind: 'list', filter: 'all', limit: expect.any(Number) }, + { sourceContext } + ) + }) + it('returns to the tasks page after visiting settings from an in-progress draft', () => { const store = createUIStore() @@ -1394,7 +1623,13 @@ describe('createUISlice page navigation history', () => { expect(store.getState().worktreeNavHistory).toEqual([ 'a', 'tasks', - { kind: 'task-detail', source: 'github', workItem, initialTab: undefined } + { + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: undefined, + initialTab: undefined + } ]) expect(store.getState().worktreeNavHistoryIndex).toBe(2) @@ -1411,13 +1646,122 @@ describe('createUISlice page navigation history', () => { store.setState({ recordFeatureInteraction } as Partial<AppState>) const workItem = makeGitHubWorkItem() const linearIssue = makeLinearIssue() + const jiraIssue = makeJiraIssue() store.getState().openTaskPage({ taskSource: 'github', openGitHubWorkItem: workItem }) store.getState().openTaskPage({ taskSource: 'linear', openLinearIssue: linearIssue }) + store.getState().openTaskPage({ taskSource: 'jira', openJiraIssue: jiraIssue }) expect(recordFeatureInteraction).toHaveBeenCalledWith('tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks') + expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-tasks') + }) + + it('preserves GitHub task detail source context in navigation history', () => { + const store = createUIStore() + const workItem = makeGitHubWorkItem({ repoId: 'repo-remote' }) + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-remote', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + + store.getState().openTaskPage({ + taskSource: 'github', + openGitHubWorkItem: workItem, + openGitHubSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext, + initialTab: undefined + }) + }) + + it('preserves Linear task detail source context in navigation history', () => { + const store = createUIStore() + const linearIssue = makeLinearIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1' } + } + + store.getState().openTaskPage({ + taskSource: 'linear', + openLinearIssue: linearIssue, + openLinearSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'linear', + issue: linearIssue, + sourceContext + }) + }) + + it('preserves GitLab task detail source context in navigation history', () => { + const store = createUIStore() + const workItem = makeGitLabWorkItem({ repoId: 'repo-remote' }) + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-remote', + providerIdentity: { provider: 'gitlab', projectId: '1234' } + } + + store.getState().openTaskPage({ + taskSource: 'gitlab', + openGitLabWorkItem: workItem, + openGitLabSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext + }) + }) + + it('preserves Jira task detail source context in navigation history', () => { + const store = createUIStore() + const issue = makeJiraIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'jira', siteId: 'site-1' }, + accountLabel: 'Example Jira' + } + + store.getState().openTaskPage({ + taskSource: 'jira', + openJiraIssue: issue, + openJiraSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext + }) }) it('can suppress the Tasks surface interaction for in-page provider navigation', () => { @@ -1426,6 +1770,7 @@ describe('createUISlice page navigation history', () => { store.setState({ recordFeatureInteraction } as Partial<AppState>) const workItem = makeGitHubWorkItem() const linearIssue = makeLinearIssue() + const jiraIssue = makeJiraIssue() store .getState() @@ -1439,10 +1784,17 @@ describe('createUISlice page navigation history', () => { { taskSource: 'linear', openLinearIssue: linearIssue }, { recordTasksInteraction: false } ) + store + .getState() + .openTaskPage( + { taskSource: 'jira', openJiraIssue: jiraIssue }, + { recordTasksInteraction: false } + ) expect(recordFeatureInteraction).not.toHaveBeenCalledWith('tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks') + expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-tasks') }) it('skips the whole Tasks detail stack on close', () => { @@ -1456,7 +1808,13 @@ describe('createUISlice page navigation history', () => { expect(store.getState().worktreeNavHistory).toEqual([ 'a', 'tasks', - { kind: 'task-detail', source: 'github', workItem, initialTab: undefined }, + { + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: undefined, + initialTab: undefined + }, 'tasks' ]) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index b03ea7cd3e2..de7aab3cdea 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -10,6 +10,7 @@ import type { ChangelogData, CustomPet, GitHubWorkItem, + JiraIssue, LinearIssue, PersistedTrustedOrcaHooks, PersistedUIState, @@ -22,9 +23,14 @@ import type { WorkspaceStatusDefinition, AgentActivityDisplayMode, ProjectOrderBy, - WorktreeCardProperty + WorktreeCardProperty, + WorkspaceHostOrder, + WorkspaceHostScope, + VisibleWorkspaceHostIds } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' import { @@ -62,6 +68,12 @@ import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, normalizeBrowserPageZoomLevel } from '../../../../shared/browser-page-zoom' +import { + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds, + type ExecutionHostId +} from '../../../../shared/execution-host' import { WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, clampWorkspaceBoardColumnWidth, @@ -243,6 +255,15 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { + const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) + if (visibleHostIds) { + return visibleHostIds + } + const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) + return legacyScope === 'all' ? null : [legacyScope] +} + const MIN_SIDEBAR_WIDTH = 220 const MAX_LEFT_SIDEBAR_WIDTH = 500 // Why: the right sidebar drag-resize is window-relative (see right-sidebar @@ -584,8 +605,14 @@ export type UISlice = { prefilledName?: string taskSource?: TaskProvider openGitHubWorkItem?: GitHubWorkItem + openGitHubSourceContext?: TaskSourceContext | null openGitHubInitialTab?: 'conversation' | 'checks' | 'files' + openGitLabWorkItem?: GitLabWorkItem + openGitLabSourceContext?: TaskSourceContext | null openLinearIssue?: LinearIssue + openLinearSourceContext?: TaskSourceContext | null + openJiraIssue?: JiraIssue + openJiraSourceContext?: TaskSourceContext | null } taskResumeState: TaskResumeState | undefined setTaskResumeState: (updates: Partial<TaskResumeState>) => void @@ -593,6 +620,11 @@ export type UISlice = { setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void newWorkspaceDraft: { repoId: string | null + // Why: project-first workspace creation resolves through these when present, + // while old drafts can keep using only repoId during the additive migration. + projectId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null name: string prompt: string note: string @@ -604,6 +636,9 @@ export type UISlice = { url: string linearIdentifier?: string } | null + /** Why: starting from a task must preserve where provider data came from + * separately from the host selected to run the workspace. */ + taskSourceContext?: TaskSourceContext | null agent: TuiAgent linkedIssue: string linkedPR: number | null @@ -731,6 +766,12 @@ export type UISlice = { setShowActiveOnly: (v: boolean) => void showSleepingWorkspaces: boolean setShowSleepingWorkspaces: (v: boolean) => void + workspaceHostScope: WorkspaceHostScope + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void + workspaceHostOrder: WorkspaceHostOrder + setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record<string, boolean> @@ -755,8 +796,10 @@ export type UISlice = { statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null + workspacePortScansByKey: Record<string, WorkspacePortScanResult> workspacePortScanRefreshing: boolean setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void + setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void setWorkspacePortScanRefreshing: (refreshing: boolean) => void /** Whether the experimental pet overlay is currently visible. Persisted * so "Hide pet" from the status-bar menu survives reload. Independent @@ -1051,9 +1094,15 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) if (data.openGitHubWorkItem) { get().recordFeatureInteraction?.('github-tasks') } + if (data.openGitLabWorkItem) { + get().recordFeatureInteraction?.('gitlab-tasks') + } if (data.openLinearIssue) { get().recordFeatureInteraction?.('linear-tasks') } + if (data.openJiraIssue) { + get().recordFeatureInteraction?.('jira-tasks') + } // Why: record a Tasks visit in the shared back/forward history so the // titlebar Back/Forward buttons can return to Tasks. All task-source // variants (github/linear presets) collapse to a single 'tasks' entry; @@ -1065,15 +1114,31 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) kind: 'task-detail', source: 'github', workItem: data.openGitHubWorkItem, + sourceContext: data.openGitHubSourceContext, initialTab: data.openGitHubInitialTab } as const) - : data.openLinearIssue + : data.openGitLabWorkItem ? ({ kind: 'task-detail', - source: 'linear', - issue: data.openLinearIssue + source: 'gitlab', + workItem: data.openGitLabWorkItem, + sourceContext: data.openGitLabSourceContext } as const) - : null + : data.openLinearIssue + ? ({ + kind: 'task-detail', + source: 'linear', + issue: data.openLinearIssue, + sourceContext: data.openLinearSourceContext + } as const) + : data.openJiraIssue + ? ({ + kind: 'task-detail', + source: 'jira', + issue: data.openJiraIssue, + sourceContext: data.openJiraSourceContext + } as const) + : null const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] const currentIsTaskStack = currentEntry === 'tasks' || @@ -1142,22 +1207,36 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) ? (resume.githubItemsQuery ?? '').trim() : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) for (const repo of selectedRepos) { - state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query) + state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { + sourceContext: + data.openGitHubSourceContext?.provider === 'github' && + data.openGitHubSourceContext.repoId === repo.id + ? data.openGitHubSourceContext + : null + }) } } if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { const resume = state.taskResumeState const query = (resume?.linearQuery ?? '').trim() + const sourceContext = + data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null if (query) { - state.prefetchLinearIssues({ kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }) + state.prefetchLinearIssues( + { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, + { sourceContext } + ) } else { // Why: TaskPage no longer exposes Linear preset filters; keep warm // prefetch aligned with the default unsearched issue list. - state.prefetchLinearIssues({ - kind: 'list', - filter: 'all', - limit: LINEAR_TASK_PREFETCH_LIMIT - }) + state.prefetchLinearIssues( + { + kind: 'list', + filter: 'all', + limit: LINEAR_TASK_PREFETCH_LIMIT + }, + { sourceContext } + ) } } }, @@ -1738,6 +1817,40 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), + workspaceHostScope: 'all', + // Why (multi-host design): host scope is presentation/filtering only — it must + // never trigger resource teardown (terminals, browser pages, etc.). + setWorkspaceHostScope: (scope) => { + const normalized = normalizeExecutionHostScope(scope) + const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] + set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + window.api.ui + .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + .catch(console.error) + }, + visibleWorkspaceHostIds: null, + setVisibleWorkspaceHostIds: (ids) => { + const normalized = normalizeVisibleExecutionHostIds(ids) + // Why: workspaceHostScope remains the compatibility/default-host signal + // for creation flows while visibility can now be multi-select. + let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope + if (normalized === null) { + workspaceHostScope = 'all' + } else if (normalized.length === 1) { + workspaceHostScope = normalized[0] + } + set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + window.api.ui + .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + .catch(console.error) + }, + workspaceHostOrder: [], + setWorkspaceHostOrder: (ids) => { + const workspaceHostOrder = normalizeExecutionHostOrder(ids) + set({ workspaceHostOrder }) + window.api.ui.set({ workspaceHostOrder }).catch(console.error) + }, + hideDefaultBranchWorkspace: false, setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), @@ -1847,8 +1960,36 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) set({ statusBarVisible: v }) }, workspacePortScan: null, + workspacePortScansByKey: {}, workspacePortScanRefreshing: false, - setWorkspacePortScan: (scan) => set({ workspacePortScan: scan }), + setWorkspacePortScan: (scan) => + set((state) => { + if (!scan) { + return { workspacePortScan: null, workspacePortScansByKey: {} } + } + return { + workspacePortScan: scan, + workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } + } + }), + setWorkspacePortScanForKey: (key, result) => + set((state) => { + const nextScansByKey = { ...state.workspacePortScansByKey } + if (result) { + nextScansByKey[key] = result + } else { + delete nextScansByKey[key] + } + return { + workspacePortScansByKey: nextScansByKey, + workspacePortScan: + state.workspacePortScan?.key === key + ? result + ? { key, result } + : null + : state.workspacePortScan + } + }), setWorkspacePortScanRefreshing: (refreshing) => set({ workspacePortScanRefreshing: refreshing }), // Why: default true so a user who enables experimentalPet sees the @@ -2009,6 +2150,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) // Older positive-form keys are intentionally ignored so old profiles // start from the new default: sleeping workspaces visible. showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), + workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), + visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), + workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), diff --git a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts index c6c6e21a67c..70d4475ba3a 100644 --- a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts +++ b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts @@ -1,7 +1,9 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { afterEach, describe, expect, it } from 'vitest' import type { AppState } from '../types' -import type { GitHubWorkItem, Worktree } from '../../../../shared/types' +import type { GitHubWorkItem, JiraIssue, Worktree } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { createWorktreeNavHistorySlice, findPrevLiveWorktreeHistoryIndex, @@ -61,6 +63,40 @@ function makeGitHubWorkItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWork } } +function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem { + return { + id: 'mr-12', + type: 'mr', + number: 12, + title: 'Fix runner routing', + state: 'opened', + url: 'https://gitlab.com/acme/repo/-/merge_requests/12', + labels: [], + updatedAt: '2026-05-20T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1', + ...overrides + } +} + +function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue { + return { + id: 'ORC-1', + key: 'ORC-1', + title: 'Fix task source context', + url: 'https://example.atlassian.net/browse/ORC-1', + siteId: 'site-1', + siteName: 'Example Jira', + project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + ...overrides + } +} + describe('worktree-nav-history slice: view entries', () => { afterEach(() => { setWorktreeNavActivator(null) @@ -173,4 +209,104 @@ describe('worktree-nav-history slice: view entries', () => { expect(viewed).toEqual(['tasks', detail]) expect(store.getState().worktreeNavHistoryIndex).toBe(2) }) + + it('keeps same GitHub item details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const workItem = makeGitHubWorkItem() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' } + } + const sshSource: TaskSourceContext = { + ...localSource, + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-ssh' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: sshSource + }) + + expect(store.getState().worktreeNavHistory).toHaveLength(2) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('keeps same GitLab item details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const workItem = makeGitLabWorkItem() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'gitlab', projectId: '1234' } + } + const sshSource: TaskSourceContext = { + ...localSource, + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-ssh' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext: sshSource + }) + + expect(store.getState().worktreeNavHistory).toHaveLength(2) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('keeps same Jira issue details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const issue = makeJiraIssue() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'local', + providerIdentity: { provider: 'jira', siteId: 'site-1' } + } + const remoteSource: TaskSourceContext = { + ...localSource, + hostId: 'runtime:remote-server' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext: remoteSource + }) + + expect(store.getState().worktreeNavHistory).toHaveLength(2) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) }) diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index 50509320492..38bd57a9870 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -1,7 +1,12 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { findWorktreeById } from './worktree-helpers' -import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' +import type { GitHubWorkItem, JiraIssue, LinearIssue } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' // Why: cap the per-session history so a long-lived workspace with many // worktree jumps cannot grow the array unbounded. 50 is generous enough @@ -20,9 +25,27 @@ export type WorktreeNavHistoryTaskDetailEntry = kind: 'task-detail' source: 'github' workItem: GitHubWorkItem + sourceContext?: TaskSourceContext | null initialTab?: 'conversation' | 'checks' | 'files' } - | { kind: 'task-detail'; source: 'linear'; issue: LinearIssue } + | { + kind: 'task-detail' + source: 'linear' + issue: LinearIssue + sourceContext?: TaskSourceContext | null + } + | { + kind: 'task-detail' + source: 'gitlab' + workItem: GitLabWorkItem + sourceContext?: TaskSourceContext | null + } + | { + kind: 'task-detail' + source: 'jira' + issue: JiraIssue + sourceContext?: TaskSourceContext | null + } export type WorktreeNavHistoryViewEntry = | WorktreeNavHistorySimpleViewEntry | WorktreeNavHistoryTaskDetailEntry @@ -82,9 +105,31 @@ function getHistoryEntryKey(entry: WorktreeNavHistoryEntry): string { return entry === 'tasks' || entry === 'automations' ? `view:${entry}` : `worktree:${entry}` } if (entry.source === 'github') { - return `view:task-detail:github:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}:${entry.initialTab ?? 'conversation'}` + const sourceScope = + entry.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:github:${sourceScope}:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}:${entry.initialTab ?? 'conversation'}` } - return `view:task-detail:linear:${entry.issue.workspaceId ?? 'selected'}:${entry.issue.id}` + if (entry.source === 'gitlab') { + const sourceScope = + entry.sourceContext?.provider === 'gitlab' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:gitlab:${sourceScope}:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}` + } + if (entry.source === 'jira') { + const sourceScope = + entry.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:jira:${sourceScope}:${entry.issue.siteId ?? 'selected'}:${entry.issue.key}` + } + const sourceScope = + entry.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:linear:${sourceScope}:${entry.issue.workspaceId ?? 'selected'}:${entry.issue.id}` } function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 53ef24c0fa5..e48b2fad849 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -685,6 +685,38 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) + it('fetches SSH repo worktrees through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1', + branch: 'refs/heads/ssh' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ] + } as Partial<AppState>) + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' }) + ) + + await store.getState().fetchWorktrees('repo-ssh') + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([sshWorktree]) + }) + it('falls back to legacy remote worktree.list when detectedList is unavailable', async () => { const store = createTestStore() const remote = makeWorktree({ @@ -2314,6 +2346,40 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([]) }) + it('removes SSH-owned worktrees through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [wt] } + } as Partial<AppState>) + + const result = await store.getState().removeWorktree(wt.id) + + expect(result).toEqual({ ok: true }) + expect(mockApi.worktrees.remove).toHaveBeenCalledWith({ + worktreeId: wt.id, + force: undefined, + skipArchive: false + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) + }) + it('persists worktree metadata through the active remote runtime environment', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) @@ -2340,6 +2406,38 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') }) + it('persists SSH-owned worktree metadata through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [wt] } + } as Partial<AppState>) + + await store.getState().updateWorktreeMeta(wt.id, { comment: 'ssh note' }) + + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: expect.objectContaining({ comment: 'ssh note' }) + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh'][0]?.comment).toBe('ssh note') + }) + it('clears pending first-agent rename when the title is updated', async () => { const store = createTestStore() const wt = makeWorktree({ @@ -3180,6 +3278,54 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined() }) + // Why: multi-host regression — once hydration has fired, a mid-session + // fetchAllWorktrees (e.g. triggered by switching focus) must NEVER purge + // terminal state, even if a host transiently reports zero worktrees. The + // hydration-time purge is the only purge path here; it is gated to boot. + it('does not purge another host tab state when hasHydratedWorktreePurge is already true and a host reports zero worktrees', async () => { + const store = createTestStore() + const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) + + // repoB reports zero worktrees this round (host briefly empty), repoA fine. + mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + repoId === 'repoA' ? [wtA] : [] + ) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [repoA, repoB], + tabsByWorktree: { + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }, + ptyIdsByTabId: { 'tab-B': ['remote:env-b@@terminal-b'] }, + terminalLayoutsByTabId: { + 'tab-B': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-b@@terminal-b' } + } + } + } as unknown as Partial<AppState>) + + await store.getState().fetchAllWorktrees() + + // The zero-worktree host's live tab/terminal state is untouched. + expect(store.getState().tabsByWorktree).toEqual({ + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ 'tab-B': ['remote:env-b@@terminal-b'] }) + expect(store.getState().terminalLayoutsByTabId).toEqual({ + 'tab-B': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-b@@terminal-b' } + } + }) + expect(store.getState().hasHydratedWorktreePurge).toBe(true) + }) + it('preserves floating workspace state while purging a real stale worktree', async () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) @@ -3776,6 +3922,56 @@ describe('pending worktree creation state', () => { expect(store.getState().activePendingCreationId).toBe('c1') }) + it('keeps source and run context on the retryable request', () => { + const store = createTestStore() + const entry = makePendingCreation('c1', { + request: { + repoId: 'repo-ssh', + taskSourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'setup-local', + repoId: 'repo-local', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }, + workspaceRunContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-1', + projectHostSetupId: 'setup-ssh', + repoId: 'repo-ssh', + path: '/home/orca/orca' + }, + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null + } + }) + + store.getState().beginPendingWorktreeCreation(entry) + + expect(store.getState().pendingWorktreeCreations.c1.request).toMatchObject({ + repoId: 'repo-ssh', + taskSourceContext: { + provider: 'github', + hostId: 'local', + repoId: 'repo-local' + }, + workspaceRunContext: { + hostId: 'ssh:ssh-1', + projectHostSetupId: 'setup-ssh', + repoId: 'repo-ssh' + } + }) + }) + it('updatePendingWorktreeCreation skips the write when the patch changes nothing', () => { const store = createTestStore() store.getState().beginPendingWorktreeCreation(makePendingCreation('c1')) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index fbd74c5f7fb..8f7a173e900 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -40,6 +40,12 @@ import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, @@ -197,6 +203,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b worktree.id === candidate.id && worktree.instanceId === candidate.instanceId && worktree.repoId === candidate.repoId && + worktree.projectId === candidate.projectId && + worktree.hostId === candidate.hostId && + worktree.projectHostSetupId === candidate.projectHostSetupId && worktree.path === candidate.path && worktree.head === candidate.head && worktree.branch === candidate.branch && @@ -375,28 +384,23 @@ function applyDetectedWorktreeUpdates( worktreeId: string, updates: Partial<WorktreeMeta> ): AppState['detectedWorktreesByRepo'] { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const result = detectedWorktreesByRepo[repoId] - if (!result) { - return detectedWorktreesByRepo - } - let changed = false - const nextWorktrees = result.worktrees.map((worktree) => { - if (worktree.id !== worktreeId) { - return worktree - } - changed = true - return { ...worktree, ...updates } - }) - if (!changed) { - return detectedWorktreesByRepo + const nextByRepo: AppState['detectedWorktreesByRepo'] = {} + + for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) { + let repoChanged = false + const nextWorktrees = result.worktrees.map((worktree) => { + if (worktree.id !== worktreeId) { + return worktree + } + repoChanged = true + changed = true + return { ...worktree, ...updates } + }) + nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result } - return { - ...detectedWorktreesByRepo, - [repoId]: { ...result, worktrees: nextWorktrees } - } + return changed ? nextByRepo : detectedWorktreesByRepo } function findKnownWorktreeById( @@ -570,6 +574,37 @@ function replaceWorktreeInRepoLists( } } +function settingsForRepoOwner(state: Pick<AppState, 'repos' | 'settings'>, repoId: string) { + const repo = state.repos.find((entry) => entry.id === repoId) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + if (parsed?.kind !== 'ssh') { + return state.settings + } + // Why: SSH repos are owned by the desktop client/SSH provider, not the + // currently focused runtime server. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +function settingsForWorktreeOwner(state: Pick<AppState, 'repos' | 'settings'>, worktreeId: string) { + return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) +} + async function listDetectedWorktreesForRepo( settings: AppState['settings'], repoId: string @@ -625,13 +660,21 @@ async function listWorktreeLineageForRuntime( async function refreshRemoteWorktreeLineageBestEffort( settings: AppState['settings'], + get: () => AppState, set: (partial: Partial<AppState>) => void ): Promise<void> { if (getActiveRuntimeTarget(settings).kind === 'local') { return } try { - set({ worktreeLineageById: await listWorktreeLineageForRuntime(settings) }) + const lineage = await listWorktreeLineageForRuntime(settings) + set({ + worktreeLineageById: mergeLineageForHost( + get(), + getSettingsFocusedExecutionHostId(settings), + lineage + ) + }) } catch (err) { // Why: lineage is supplemental to the worktree list. A remote timeout here // must not discard a successful worktree refresh. @@ -639,6 +682,29 @@ async function refreshRemoteWorktreeLineageBestEffort( } } +function getWorktreeHostId( + state: Pick<AppState, 'repos'>, + worktreeId: string +): ExecutionHostId | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((entry) => entry.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + +function mergeLineageForHost( + state: Pick<AppState, 'repos' | 'worktreeLineageById'>, + hostId: ExecutionHostId, + lineage: Record<string, WorktreeLineage> +): Record<string, WorktreeLineage> { + const next: Record<string, WorktreeLineage> = {} + for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { + if (getWorktreeHostId(state, worktreeId) !== hostId) { + next[worktreeId] = existing + } + } + return { ...next, ...lineage } +} + async function persistWorktreeMeta( settings: AppState['settings'], worktreeId: string, @@ -879,7 +945,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchDetectedWorktrees: async (repoId) => { try { - const result = await listDetectedWorktreesForRepo(get().settings, repoId) + const result = await listDetectedWorktreesForRepo(settingsForRepoOwner(get(), repoId), repoId) set((s) => areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], result) ? s @@ -894,7 +960,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchWorktrees: async (repoId, options) => { try { - const settings = get().settings + const settings = settingsForRepoOwner(get(), repoId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false @@ -915,7 +981,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) + await refreshRemoteWorktreeLineageBestEffort(settings, get, set) return detected.authoritative } @@ -949,7 +1015,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) + await refreshRemoteWorktreeLineageBestEffort(settings, get, set) return detected.authoritative } catch (err) { console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) @@ -982,7 +1048,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> const results = await Promise.all( repos.map(async (r) => { try { - const detected = await listDetectedWorktreesForRepo(get().settings, r.id) + const detected = await listDetectedWorktreesForRepo( + settingsForRepoOwner(get(), r.id), + r.id + ) const list = toVisibleWorktrees(detected) const current = get().worktreesByRepo[r.id] if ( @@ -1040,7 +1109,17 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchWorktreeLineage: async () => { try { - set({ worktreeLineageById: await listWorktreeLineageForRuntime(get().settings) }) + // Why: lineage is a focused-host refresh — fetch from the focused host and + // host-merge so other hosts' previously fetched lineage is preserved. + const settings = get().settings + const lineage = await listWorktreeLineageForRuntime(settings) + set((s) => ({ + worktreeLineageById: mergeLineageForHost( + s, + getSettingsFocusedExecutionHostId(settings), + lineage + ) + })) } catch (err) { console.error('Failed to fetch worktree lineage:', err) } @@ -1048,7 +1127,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> updateWorktreeLineage: async (worktreeId, args) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) let updatedRemoteWorktree: WorktreeWithLineage | undefined const lineage = target.kind === 'local' @@ -1160,7 +1239,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> prefetchWorktreeCreateBase: async (repoId, baseBranch) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) if (target.kind === 'local') { await window.api.worktrees.prefetchCreateBase({ repoId, @@ -1255,7 +1334,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(startup ? { startup } : {}), ...(creationId ? { creationId } : {}) } - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) const result = target.kind === 'local' ? await window.api.worktrees.create(createArgs) @@ -1434,7 +1513,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> const worktreeBeforeRemoval = get() .allWorktrees() .find((entry) => entry.id === worktreeId) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const removalResult = await (target.kind === 'local' ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) : callRuntimeRpc<RemoveWorktreeResult>( @@ -1730,7 +1809,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const result = await (target.kind === 'local' ? window.api.worktrees.forceDeletePreservedBranch({ worktreeId, @@ -1797,7 +1876,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> existingWorktree.linkedPR !== linkedPrForPushTarget && !existingWorktree.pushTarget ? await resolveLinkedPrPushTarget( - get().settings, + settingsForRepoOwner(get(), existingWorktree.repoId), existingWorktree.repoId, linkedPrForPushTarget ) @@ -1851,7 +1930,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> reviewBranch, s.settings, reviewRepo.id, - reviewRepo.connectionId + reviewRepo.connectionId, + reviewRepo.executionHostId ) : null const prCacheKey = @@ -1861,7 +1941,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> reviewRepo.id, reviewBranch, s.settings, - reviewRepo.connectionId + reviewRepo.connectionId, + reviewRepo.executionHostId ) : null const prCacheKeys = @@ -1919,7 +2000,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> } try { - await persistWorktreeMeta(get().settings, worktreeId, enriched) + await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) if (reviewRepo && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function') { // Why: the old cache entry may have been populated solely by linkedPR. // Force a no-linked refetch so an in-flight linked lookup cannot keep @@ -1970,11 +2051,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> } }) - const settings = get().settings await Promise.all( Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { try { - await persistWorktreeMeta(settings, worktreeId, updates) + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId), + worktreeId, + updates + ) } catch (err) { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) @@ -2057,7 +2141,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { isUnread: true, lastActivityAt: now }).catch((err) => { @@ -2168,7 +2252,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { isUnread: false }).catch((err) => { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { + isUnread: false + }).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) return @@ -2224,7 +2310,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { lastActivityAt: now }).catch((err) => { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { + lastActivityAt: now + }).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { return } @@ -2642,7 +2730,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> isUnread: false } - void persistWorktreeMeta(get().settings, worktreeId, updates).catch((err) => { + void persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId), + worktreeId, + updates + ).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) return diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index e6152999f36..39d264fb11f 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -27,6 +27,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' import type { DictationSlice } from './slices/dictation' import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup' +import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' @@ -59,5 +60,6 @@ export type AppState = RepoSlice & WorktreeNavHistorySlice & DictationSlice & WorkspaceCleanupSlice & + RuntimeStatusSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 541393b26b4..0501812d2c0 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PreloadApi } from '../../../preload/api-types' import type { FeatureInteractionState } from '../../../shared/feature-interactions' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { TaskSourceContext } from '../../../shared/task-source-context' class MemoryStorage implements Storage { private readonly values = new Map<string, string>() @@ -1143,6 +1144,18 @@ describe('web file preload API', () => { ).rejects.toThrow('Remote file download is unavailable in paired web clients.') }) + it('rejects SSH clone requests in paired web clients', async () => { + const { api } = await installApi('Linux') + + await expect( + api.repos.cloneRemote({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + ).rejects.toThrow('SSH clone is unavailable in paired web clients.') + }) + it('returns false for runtime missing-path errors from fs.pathExists', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] const worktree = { @@ -1215,42 +1228,6 @@ describe('web file preload API', () => { }) }) -describe('web star nag preload API', () => { - beforeEach(() => { - vi.resetModules() - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('keeps the browser-paired star nag API safe and in parity with the preload contract', async () => { - const { api } = await installApi('Linux') - - expect(Object.keys(api.starNag).sort()).toEqual([ - 'complete', - 'disable', - 'dismiss', - 'forceShow', - 'onShow', - 'openWeb', - 'starOrca' - ]) - - const listener = vi.fn() - const unsubscribe = api.starNag.onShow(listener) - unsubscribe() - - await expect(api.starNag.dismiss()).resolves.toBeUndefined() - await expect(api.starNag.complete()).resolves.toBeUndefined() - await expect(api.starNag.disable()).resolves.toBeUndefined() - await expect(api.starNag.openWeb()).resolves.toBeUndefined() - await expect(api.starNag.forceShow()).resolves.toBeUndefined() - await expect(api.starNag.starOrca()).resolves.toBe(false) - expect(listener).not.toHaveBeenCalled() - }) -}) - describe('web GitHub preload API', () => { beforeEach(() => { vi.resetModules() @@ -1998,6 +1975,101 @@ describe('web GitLab preload API', () => { ) }) + it('routes GitLab repo selectors through repo id when provided', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: method === 'gitlab.workItemDetails' ? null : { ok: true, items: [] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const api = globals.window.api + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:gitlab.example.com/group/project', + hostId: 'runtime:web-env-1', + repoId: 'repo-gitlab-runtime', + providerIdentity: { + provider: 'gitlab', + projectId: '42', + namespace: 'group', + project: 'project', + webUrl: 'https://gitlab.example.com/group/project' + } + } + + await api.gl.listIssues({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + state: 'opened' + }) + await api.gl.updateMR({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + iid: 9, + updates: { title: 'New title' } + }) + await api.gl.workItemDetails({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + iid: 9, + type: 'mr' + }) + + expect(runtimeCalls).toEqual([ + { + method: 'gitlab.listIssues', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + state: 'opened' + } + }, + { + method: 'gitlab.updateMR', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + iid: 9, + updates: { title: 'New title' } + } + }, + { + method: 'gitlab.workItemDetails', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + iid: 9, + type: 'mr' + } + } + ]) + }) + it('exposes the GitLab task methods used by the shared Tasks page', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d7d422f6b59..1f51bfa2804 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -36,6 +36,7 @@ import { import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { @@ -170,7 +171,6 @@ function invalidateRuntimeWorktreeCaches(): void { type WebSettingsApi = NonNullable<PreloadApi['settings']> type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']> type WebGitHubApi = NonNullable<PreloadApi['gh']> -type WebStarNagApi = NonNullable<PreloadApi['starNag']> type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>> type WebGitHubRouteKey = | 'repoSlug' @@ -500,22 +500,25 @@ function createWebPreloadApi(): Partial<PreloadApi> { deleteBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')) }, session: { - get: () => Promise.resolve(getStoredWorkspaceSession()), - set: async (session) => { - writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session)) + // hostId mirrors the desktop bridge: omitted/'local' targets the existing + // storage key; non-local hosts persist under a host-suffixed key so their + // sessions stay isolated from the local one. + get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), + set: async (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) }, - patch: async (patch: WorkspaceSessionPatch) => { + patch: async (patch: WorkspaceSessionPatch, hostId) => { writeJson( - SESSION_STORAGE_KEY, + sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession({ - ...getStoredWorkspaceSession(), + ...getStoredWorkspaceSession(hostId), ...patch }) ) }, readTerminalScrollback: () => null, - setSync: (session) => { - writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session)) + setSync: (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) } }, onboarding: { @@ -556,7 +559,6 @@ function createWebPreloadApi(): Partial<PreloadApi> { browser: createBrowserApi(), emulator: createEmulatorApi(), gh: createGitHubApi(), - starNag: createStarNagApi(), gl: createGitLabApi(), hostedReview: createRuntimeNamespaceApi('hostedReview'), linear: createRuntimeNamespaceApi('linear'), @@ -975,6 +977,13 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim } return { removed: redactStoredWebRuntimeEnvironment(environment) } }, + disconnect: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (activeEnvironment?.id === environment.id) { + disconnectActiveRuntimeEnvironment() + } + return { disconnected: redactStoredWebRuntimeEnvironment(environment) } + }, getStatus: ({ selector, timeoutMs }) => callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs), call: ({ selector, method, params, timeoutMs }) => @@ -1009,6 +1018,16 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> { await callRuntimeResult<{ repo: Repo }>('repo.clone', { url, destination }, 10 * 60_000) ).repo }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web + // clients must not pretend they can run that local IPC path directly. + throw new Error('SSH clone is unavailable in paired web clients.') + }, + createRemote: async () => { + // Why: SSH relay project creation is owned by the desktop main process; + // paired web clients cannot create folders through local SSH IPC. + throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') + }, cloneAbort: () => Promise.resolve(), addRemote: async ({ remotePath, displayName, kind }) => { invalidateRuntimeWorktreeCaches() @@ -1575,18 +1594,6 @@ function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> { } as unknown as NonNullable<Partial<PreloadApi>['emulator']> } -function createStarNagApi(): WebStarNagApi { - return { - onShow: () => noopUnsubscribe, - dismiss: () => Promise.resolve(), - complete: () => Promise.resolve(), - disable: () => Promise.resolve(), - openWeb: () => Promise.resolve(), - starOrca: () => Promise.resolve(false), - forceShow: () => Promise.resolve() - } -} - function createGitHubApi(): WebGitHubApi { const route = <Result>(method: WebGitHubRuntimeMethod, args?: unknown): Promise<Result> => callRuntimeResult<Result>(method, mapRepoPathArg(args)) @@ -2088,9 +2095,9 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> { getInstallStatus: () => Promise.resolve(status), install: () => Promise.resolve(status), remove: () => Promise.resolve(status), - getWslInstallStatus: () => Promise.resolve(status), - installWsl: () => Promise.resolve(status), - removeWsl: () => Promise.resolve(status) + getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), + installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), + removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) } as NonNullable<Partial<PreloadApi>['cli']> } @@ -2592,7 +2599,22 @@ function getStoredOnboarding(): OnboardingState { return closed } -function getStoredWorkspaceSession(): WorkspaceSessionState { +/** Resolve the localStorage key for a session partition. Non-'local' hosts get + * a host-suffixed key so their sessions never clobber the local one. */ +function sessionStorageKeyForHost(hostId?: string | null): string { + const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + return resolved === LOCAL_EXECUTION_HOST_ID + ? SESSION_STORAGE_KEY + : `${SESSION_STORAGE_KEY}.${resolved}` +} + +function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { + const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + return sanitizeWebRuntimeWorkspaceSession( + readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) + ) + } const localSession = sanitizeWebRuntimeWorkspaceSession( readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) ) diff --git a/src/shared/automation-run-identity.test.ts b/src/shared/automation-run-identity.test.ts new file mode 100644 index 00000000000..1738cba3fce --- /dev/null +++ b/src/shared/automation-run-identity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { + getAutomationLegacyRepoId, + getAutomationRunProjectId, + getAutomationRunRepoId +} from './automation-run-identity' + +function automation(overrides: Partial<Automation> = {}): Automation { + return { + id: 'auto-1', + name: 'Automation', + prompt: 'Run this', + precheck: null, + agentId: 'claude', + runContext: null, + sourceContext: null, + projectId: 'legacy-repo', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 1, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('automation run identity', () => { + it('uses explicit run context identity when present', () => { + const value = automation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'remote-repo', + path: '/remote/orca' + } + }) + + expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunRepoId(value)).toBe('remote-repo') + expect(getAutomationRunProjectId(value)).toBe('github:stablyai/orca') + }) + + it('falls back to the legacy repo id for pre-host-context automations', () => { + const value = automation() + + expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunProjectId(value)).toBe('legacy-repo') + }) +}) diff --git a/src/shared/automation-run-identity.ts b/src/shared/automation-run-identity.ts new file mode 100644 index 00000000000..0497f300f89 --- /dev/null +++ b/src/shared/automation-run-identity.ts @@ -0,0 +1,15 @@ +import type { Automation } from './automations-types' + +type AutomationRunIdentityFields = Pick<Automation, 'projectId' | 'runContext'> + +export function getAutomationLegacyRepoId(automation: Pick<Automation, 'projectId'>): string { + return automation.projectId +} + +export function getAutomationRunRepoId(automation: AutomationRunIdentityFields): string { + return automation.runContext?.repoId ?? getAutomationLegacyRepoId(automation) +} + +export function getAutomationRunProjectId(automation: AutomationRunIdentityFields): string { + return automation.runContext?.projectId ?? getAutomationLegacyRepoId(automation) +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 71d7293a701..776085ab9c2 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from './types' +import type { TaskSourceContext, WorkspaceRunContext } from './task-source-context' export type AutomationWorkspaceMode = 'existing' | 'new_per_run' export type AutomationExecutionTargetType = 'local' | 'ssh' @@ -80,6 +81,17 @@ export type Automation = { prompt: string precheck: AutomationPrecheck | null agentId: TuiAgent + /** Why: runContext carries the logical project + host setup identity for + * multi-host projects; projectId remains only as the legacy repo-id storage + * field for pre-host-context automations. + * @deprecated Use runContext.projectId/runContext.repoId or + * getAutomationRunRepoId(). */ + runContext?: WorkspaceRunContext | null + /** Why: task/provider data can come from a different host/account than the + * workspace run target, so automations persist it separately. */ + sourceContext?: TaskSourceContext | null + /** @deprecated Legacy repo-id compatibility field. New code should persist + * runContext and use getAutomationRunRepoId() for fallback reads. */ projectId: string executionTargetType: AutomationExecutionTargetType executionTargetId: string @@ -103,6 +115,8 @@ export type Automation = { export type AutomationRun = { id: string automationId: string + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null title: string scheduledFor: number status: AutomationRunStatus @@ -128,6 +142,10 @@ export type AutomationCreateInput = { prompt: string precheck?: AutomationPrecheck | null agentId: TuiAgent + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null + /** @deprecated Legacy repo-id compatibility field required for older stored + * automations and clients. Pair it with runContext for new writes. */ projectId: string workspaceMode: AutomationWorkspaceMode workspaceId?: string | null @@ -147,6 +165,8 @@ export type AutomationUpdateInput = Partial< | 'prompt' | 'precheck' | 'agentId' + | 'runContext' + | 'sourceContext' | 'projectId' | 'workspaceMode' | 'workspaceId' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 19d0f8e70b2..d1ec7141772 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -389,6 +389,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState { return { schemaVersion: SCHEMA_VERSION, repos: [], + projects: [], + projectHostSetups: [], projectGroups: [], folderWorkspaces: [], sparsePresetsByRepo: {}, @@ -398,6 +400,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { ui: getDefaultUIState(), githubCache: { pr: {}, issue: {} }, workspaceSession: getDefaultWorkspaceSession(), + workspaceSessionsByHostId: {}, sshTargets: [], sshRemotePtyLeases: [], migrationUnsupportedPtyEntries: [], @@ -423,6 +426,9 @@ export function getDefaultUIState(): PersistedUIState { projectOrderBy: 'manual', showActiveOnly: false, hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, + workspaceHostScope: 'all', + visibleWorkspaceHostIds: null, + workspaceHostOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, showDotfilesByWorktree: {}, diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts new file mode 100644 index 00000000000..10c3aed194c --- /dev/null +++ b/src/shared/execution-host-registry.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import { buildExecutionHostRegistry } from './execution-host-registry' + +describe('execution host registry', () => { + it('returns only the local host for local-only state', () => { + expect( + buildExecutionHostRegistry({ + repos: [{ connectionId: null }], + settings: { activeRuntimeEnvironmentId: null } + }) + ).toEqual([ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + } + ]) + }) + + it('includes saved and repo-derived SSH hosts with connection health', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]), + sshConnectionStates: new Map([ + [ + 'repo-ssh', + { + targetId: 'repo-ssh', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ], + [ + 'saved-ssh', + { + targetId: 'saved-ssh', + status: 'auth-failed', + error: 'Permission denied', + reconnectAttempt: 1 + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { id: 'ssh:saved-ssh', label: 'Saved SSH', health: 'error', connectionStatus: 'auth-failed' }, + { id: 'ssh:repo-ssh', label: 'repo-ssh', health: 'available', connectionStatus: 'connected' } + ]) + }) + + it('adds saved runtime environments and preserves compatibility state per host', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: { activeRuntimeEnvironmentId: 'old-server' }, + runtimeEnvironments: [{ id: 'builder', name: 'Linux Builder' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'builder', + { + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-builder', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: ['terminal.binary-stream.v1'], + hostPlatform: 'linux' + } + } + ], + [ + 'old-server', + { + appVersion: '1.6.0', + status: { + runtimeId: 'runtime-old', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + minCompatibleRuntimeClientVersion: 1, + capabilities: [] + } + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { + id: 'runtime:builder', + label: 'Linux Builder', + health: 'available', + appVersion: '1.8.0', + protocolVersion: RUNTIME_PROTOCOL_VERSION, + capabilities: ['terminal.binary-stream.v1'], + platform: 'linux', + compatibility: { kind: 'ok' } + }, + { + id: 'runtime:old-server', + label: 'old-server', + health: 'blocked', + appVersion: '1.6.0', + compatibility: { kind: 'blocked', reason: 'server-too-old' } + } + ]) + }) + + it('applies per-host display-label overrides to derived labels', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]), + hostLabelOverrides: new Map([ + ['ssh:repo-ssh', 'Renamed Box'], + ['local', 'My Laptop'] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', label: 'My Laptop' }, + { id: 'ssh:repo-ssh', label: 'Renamed Box' } + ]) + }) + + it('keeps derived labels for hosts without an override', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]), + hostLabelOverrides: new Map([['ssh:other', 'Unrelated']]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', label: 'Local Mac' }, + { id: 'ssh:repo-ssh', label: 'Derived SSH' } + ]) + }) + + it('includes runtime hosts from repo ownership even when they are not focused', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'available' } + ]) + }) + + it('includes runtime hosts from hydrated status even when they are not focused', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: { activeRuntimeEnvironmentId: null }, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: ['project-host-setup.v1'], + hostPlatform: 'linux' + } + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { + id: 'runtime:gpu', + kind: 'runtime', + label: 'gpu', + health: 'available', + capabilities: ['project-host-setup.v1'], + platform: 'linux' + } + ]) + }) +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts new file mode 100644 index 00000000000..bfe12e0dbf7 --- /dev/null +++ b/src/shared/execution-host-registry.ts @@ -0,0 +1,227 @@ +import { + LOCAL_EXECUTION_HOST_ID, + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId, + type ExecutionHostKind +} from './execution-host' +import { evaluateRuntimeCompat, type RuntimeCompatVerdict } from './protocol-compat' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import type { RuntimeStatus } from './runtime-types' +import type { SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { GlobalSettings, Repo } from './types' + +export type ExecutionHostHealth = + | 'local' + | 'available' + | 'connecting' + | 'blocked' + | 'disconnected' + | 'error' + +export type ExecutionHostRegistryEntry = { + id: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + connectionStatus?: SshConnectionStatus + compatibility?: RuntimeCompatVerdict + capabilities?: readonly string[] + appVersion?: string | null + protocolVersion?: number | null + minCompatibleClientVersion?: number | null + platform?: NodeJS.Platform | null +} + +type RuntimeEnvironmentSummary = { + id: string + name?: string | null +} + +type RuntimeHostStatus = { + status?: RuntimeStatus | null + appVersion?: string | null +} + +type RuntimeStatusByEnvironmentId = ReadonlyMap<string, RuntimeHostStatus> + +function normalizeHostPart(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function runtimeCompatibility( + status: RuntimeStatus | null | undefined +): RuntimeCompatVerdict | null { + if (!status) { + return null + } + return evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) +} + +function runtimeHealth(compatibility: RuntimeCompatVerdict | null): ExecutionHostHealth { + if (!compatibility) { + return 'available' + } + return compatibility.kind === 'blocked' ? 'blocked' : 'available' +} + +function sshHealth(state: SshConnectionState | undefined): ExecutionHostHealth { + switch (state?.status) { + case 'connected': + return 'available' + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return 'connecting' + case 'auth-failed': + case 'error': + case 'reconnection-failed': + return 'error' + case 'disconnected': + case undefined: + return 'disconnected' + } +} + +function setHost( + hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>, + entry: ExecutionHostRegistryEntry +): void { + const existing = hosts.get(entry.id) + if (!existing || existing.health === 'disconnected') { + hosts.set(entry.id, entry) + } +} + +function addRuntimeHost( + hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>, + environmentId: string, + label: string, + statusByEnvironmentId: RuntimeStatusByEnvironmentId | undefined +): void { + const hostId = toRuntimeExecutionHostId(environmentId) + const runtimeStatus = statusByEnvironmentId?.get(environmentId) + const status = runtimeStatus?.status + const compatibility = runtimeCompatibility(status) + setHost(hosts, { + id: hostId, + kind: 'runtime', + label, + detail: 'Orca server', + health: runtimeHealth(compatibility), + compatibility: compatibility ?? undefined, + capabilities: status?.capabilities, + appVersion: runtimeStatus?.appVersion ?? null, + protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + minCompatibleClientVersion: + status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, + platform: status?.hostPlatform ?? null + }) +} + +export function buildExecutionHostRegistry(args: { + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + sshTargetLabels?: ReadonlyMap<string, string> + sshConnectionStates?: ReadonlyMap<string, SshConnectionState> + runtimeEnvironments?: readonly RuntimeEnvironmentSummary[] + runtimeStatusByEnvironmentId?: RuntimeStatusByEnvironmentId + // Why: user-chosen per-host display labels override the derived label so a + // rename in the host menu/settings shows everywhere the registry feeds. + hostLabelOverrides?: ReadonlyMap<ExecutionHostId, string> +}): ExecutionHostRegistryEntry[] { + const hosts = new Map<ExecutionHostId, ExecutionHostRegistryEntry>() + hosts.set(LOCAL_EXECUTION_HOST_ID, { + id: LOCAL_EXECUTION_HOST_ID, + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }) + + for (const environment of args.runtimeEnvironments ?? []) { + const environmentId = normalizeHostPart(environment.id) + if (!environmentId) { + continue + } + addRuntimeHost( + hosts, + environmentId, + normalizeHostPart(environment.name) ?? environmentId, + args.runtimeStatusByEnvironmentId + ) + } + for (const environmentId of args.runtimeStatusByEnvironmentId?.keys() ?? []) { + addRuntimeHost(hosts, environmentId, environmentId, args.runtimeStatusByEnvironmentId) + } + + const focusedHost = getSettingsFocusedExecutionHostId(args.settings) + const parsedFocusedHost = parseExecutionHostId(focusedHost) + if (parsedFocusedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedFocusedHost.environmentId, + parsedFocusedHost.environmentId, + args.runtimeStatusByEnvironmentId + ) + } + + const sshTargetIds = new Set<string>() + for (const repo of args.repos) { + const parsedHost = parseExecutionHostId(repo.executionHostId) + if (parsedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedHost.environmentId, + parsedHost.environmentId, + args.runtimeStatusByEnvironmentId + ) + } + if (parsedHost?.kind === 'ssh') { + sshTargetIds.add(parsedHost.targetId) + } + } + for (const targetId of args.sshTargetLabels?.keys() ?? []) { + const normalized = normalizeHostPart(targetId) + if (normalized) { + sshTargetIds.add(normalized) + } + } + for (const repo of args.repos) { + const targetId = normalizeHostPart(repo.connectionId) + if (targetId) { + sshTargetIds.add(targetId) + } + } + + for (const targetId of sshTargetIds) { + const state = args.sshConnectionStates?.get(targetId) + setHost(hosts, { + id: toSshExecutionHostId(targetId), + kind: 'ssh', + label: args.sshTargetLabels?.get(targetId) || targetId, + detail: 'SSH', + health: sshHealth(state), + connectionStatus: state?.status + }) + } + + const overrides = args.hostLabelOverrides + if (!overrides || overrides.size === 0) { + return [...hosts.values()] + } + return [...hosts.values()].map((host) => { + const label = overrides.get(host.id) + return label ? { ...host, label } : host + }) +} diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts new file mode 100644 index 00000000000..657396da971 --- /dev/null +++ b/src/shared/execution-host.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from './execution-host' + +describe('execution host identity', () => { + it('normalizes local, SSH, and runtime host ids', () => { + expect(parseExecutionHostId('local')).toEqual({ kind: 'local', id: 'local' }) + expect(parseExecutionHostId(toSshExecutionHostId('win vm'))).toEqual({ + kind: 'ssh', + id: 'ssh:win%20vm', + targetId: 'win vm' + }) + expect(parseExecutionHostId(toRuntimeExecutionHostId('prod/server'))).toEqual({ + kind: 'runtime', + id: 'runtime:prod%2Fserver', + environmentId: 'prod/server' + }) + }) + + it('falls back invalid scopes to all hosts', () => { + expect(normalizeExecutionHostScope(null)).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('bogus')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('ssh:')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('all')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + }) + + it('normalizes visible host id arrays', () => { + expect(normalizeVisibleExecutionHostIds(null)).toBeNull() + expect(normalizeVisibleExecutionHostIds([])).toBeNull() + expect(normalizeVisibleExecutionHostIds(['local', 'bogus', 'ssh:win%20vm', 'local'])).toEqual([ + 'local', + 'ssh:win%20vm' + ]) + }) + + it('normalizes host order arrays', () => { + expect(normalizeExecutionHostOrder(null)).toEqual([]) + expect(normalizeExecutionHostOrder([])).toEqual([]) + expect(normalizeExecutionHostOrder(['ssh:win%20vm', 'bogus', 'local', 'ssh:win%20vm'])).toEqual( + ['ssh:win%20vm', 'local'] + ) + }) + + it('derives repo ownership from SSH connection ids', () => { + expect(getRepoExecutionHostId({ connectionId: null })).toBe(LOCAL_EXECUTION_HOST_ID) + expect(getRepoExecutionHostId({ connectionId: 'ssh-target-1' })).toBe('ssh:ssh-target-1') + }) + + it('derives focused host compatibility from active runtime settings', () => { + expect(getSettingsFocusedExecutionHostId(null)).toBe(LOCAL_EXECUTION_HOST_ID) + expect(getSettingsFocusedExecutionHostId({ activeRuntimeEnvironmentId: 'runtime-1' })).toBe( + 'runtime:runtime-1' + ) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts new file mode 100644 index 00000000000..f447f28e880 --- /dev/null +++ b/src/shared/execution-host.ts @@ -0,0 +1,138 @@ +import type { GlobalSettings, Repo } from './types' + +export const LOCAL_EXECUTION_HOST_ID = 'local' +export const ALL_EXECUTION_HOSTS_SCOPE = 'all' + +export type ExecutionHostKind = 'local' | 'ssh' | 'runtime' +export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}` + +export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId + +export type ParsedExecutionHost = + | { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID } + | { kind: 'ssh'; id: `ssh:${string}`; targetId: string } + | { kind: 'runtime'; id: `runtime:${string}`; environmentId: string } + +function normalizeHostPart(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function toSshExecutionHostId(targetId: string): `ssh:${string}` { + return `ssh:${encodeURIComponent(targetId)}` +} + +export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` { + return `runtime:${encodeURIComponent(environmentId)}` +} + +export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null { + const normalized = normalizeHostPart(value) + if (!normalized) { + return null + } + if (normalized === LOCAL_EXECUTION_HOST_ID) { + return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID } + } + if (normalized.startsWith('ssh:')) { + const encoded = normalized.slice('ssh:'.length) + if (!encoded) { + return null + } + try { + const targetId = decodeURIComponent(encoded) + return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null + } catch { + return null + } + } + if (normalized.startsWith('runtime:')) { + const encoded = normalized.slice('runtime:'.length) + if (!encoded) { + return null + } + try { + const environmentId = decodeURIComponent(encoded) + return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null + } catch { + return null + } + } + return null +} + +export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null { + return parseExecutionHostId(value)?.id ?? null +} + +export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope { + const normalized = normalizeHostPart(value) + if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) { + return ALL_EXECUTION_HOSTS_SCOPE + } + return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE +} + +export function normalizeVisibleExecutionHostIds( + value: readonly string[] | null | undefined +): ExecutionHostId[] | null { + if (!Array.isArray(value)) { + return null + } + const ids: ExecutionHostId[] = [] + const seen = new Set<ExecutionHostId>() + for (const raw of value) { + const id = normalizeExecutionHostId(raw) + if (!id || seen.has(id)) { + continue + } + seen.add(id) + ids.push(id) + } + return ids.length > 0 ? ids : null +} + +export function normalizeExecutionHostOrder( + value: readonly string[] | null | undefined +): ExecutionHostId[] { + const normalized = normalizeVisibleExecutionHostIds(value) + return normalized ?? [] +} + +export function getRepoExecutionHostId( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> +): ExecutionHostId { + const executionHostId = normalizeExecutionHostId(repo.executionHostId) + if (executionHostId) { + return executionHostId + } + const connectionId = normalizeHostPart(repo.connectionId) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +export function getSettingsFocusedExecutionHostId( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): ExecutionHostId { + const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId) + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID +} + +export function getExecutionHostLabel(id: ExecutionHostScope): string { + if (id === ALL_EXECUTION_HOSTS_SCOPE) { + return 'All hosts' + } + const parsed = parseExecutionHostId(id) + if (!parsed) { + return 'All hosts' + } + switch (parsed.kind) { + case 'local': + return 'Local Mac' + case 'ssh': + return parsed.targetId + case 'runtime': + return parsed.environmentId + } +} diff --git a/src/shared/feature-interaction-catalog.ts b/src/shared/feature-interaction-catalog.ts index 917a9295b65..132c8af149a 100644 --- a/src/shared/feature-interaction-catalog.ts +++ b/src/shared/feature-interaction-catalog.ts @@ -14,6 +14,7 @@ export type FeatureInteractionId = | 'github-tasks' | 'gitlab-tasks' | 'linear-tasks' + | 'jira-tasks' | 'automations' | 'automation-created' | 'automation-run' @@ -80,6 +81,7 @@ export const FEATURE_INTERACTIONS = [ { id: 'github-tasks', interaction: 'GitHub task item workflow used' }, { id: 'gitlab-tasks', interaction: 'GitLab task item workflow used' }, { id: 'linear-tasks', interaction: 'Linear task item workflow used' }, + { id: 'jira-tasks', interaction: 'Jira task item workflow used' }, { id: 'automations', interaction: 'Automations page opened' }, { id: 'automation-created', interaction: 'automation created' }, { id: 'automation-run', interaction: 'automation run queued' }, diff --git a/src/shared/feature-interaction-categories.ts b/src/shared/feature-interaction-categories.ts index 72b05ceca7e..062b8821432 100644 --- a/src/shared/feature-interaction-categories.ts +++ b/src/shared/feature-interaction-categories.ts @@ -35,6 +35,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = { 'github-tasks': 'task_management', 'gitlab-tasks': 'task_management', 'linear-tasks': 'task_management', + 'jira-tasks': 'task_management', automations: 'automation', 'automation-created': 'automation', 'automation-run': 'automation', diff --git a/src/shared/feature-interactions.test.ts b/src/shared/feature-interactions.test.ts index 3778f979925..f9c55aee557 100644 --- a/src/shared/feature-interactions.test.ts +++ b/src/shared/feature-interactions.test.ts @@ -46,6 +46,7 @@ describe('feature interactions', () => { 'github-tasks', 'gitlab-tasks', 'linear-tasks', + 'jira-tasks', 'automations', 'automation-created', 'automation-run', @@ -171,6 +172,7 @@ describe('feature interactions', () => { ) expect(FEATURE_INTERACTION_CATEGORY_BY_ID.tasks).toBe('task_management') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['github-tasks']).toBe('task_management') + expect(FEATURE_INTERACTION_CATEGORY_BY_ID['jira-tasks']).toBe('task_management') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['markdown-file-created']).toBe('notes') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['agent-browser-setup']).toBe('setup') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['terminal-tabs']).toBe('terminal') diff --git a/src/shared/git-clone-failure-message.test.ts b/src/shared/git-clone-failure-message.test.ts new file mode 100644 index 00000000000..c869ef49a1a --- /dev/null +++ b/src/shared/git-clone-failure-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { getGitCloneFailureMessage } from './git-clone-failure-message' + +describe('getGitCloneFailureMessage', () => { + it('turns an existing destination into an actionable message after progress output', () => { + expect( + getGitCloneFailureMessage( + [ + 'Cloning into \u001b[32morca\u001b[0m...\r', + "fatal: destination path 'orca' already exists and is not an empty directory.\n" + ].join(''), + { clonePath: '/work/orca' } + ) + ).toBe( + 'Destination already exists and is not empty: /work/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) + }) + + it('prefers the last fatal line over a trailing fragment', () => { + expect( + getGitCloneFailureMessage( + "fatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n" + ) + ).toBe( + 'Destination already exists and is not empty: orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) + }) + + it('uses the known clone path for relay destination fragments', () => { + expect( + getGitCloneFailureMessage('Clone failed: and the repository exists.', { + clonePath: '/srv/orca' + }) + ).toBe( + 'Destination already exists and is not empty: /srv/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) + }) + + it('falls back to the last non-empty line', () => { + expect(getGitCloneFailureMessage('warning: retrying\nnetwork vanished\n')).toBe( + 'network vanished' + ) + }) +}) diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts new file mode 100644 index 00000000000..fe19a72f131 --- /dev/null +++ b/src/shared/git-clone-failure-message.ts @@ -0,0 +1,40 @@ +export function getGitCloneFailureMessage( + stderr: string, + options: { clonePath?: string | null } = {} +): string { + const lines = stderr + .replace(/\r/g, '\n') + .split('\n') + .map((line) => stripAnsi(line).trim()) + .filter(Boolean) + + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index] + const fatalIndex = line.indexOf('fatal:') + if (fatalIndex !== -1) { + return formatGitCloneFailureLine(line.slice(fatalIndex), options) + } + const errorIndex = line.indexOf('error:') + if (errorIndex !== -1) { + return formatGitCloneFailureLine(line.slice(errorIndex), options) + } + } + + return formatGitCloneFailureLine(lines.at(-1) ?? 'unknown error', options) +} + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') +} + +function formatGitCloneFailureLine(line: string, options: { clonePath?: string | null }): string { + const destinationMatch = line.match( + /^fatal:\s+destination path '([^']+)' already exists and is not an empty directory\.$/ + ) + if (destinationMatch || /repository exists/i.test(line)) { + const destination = options.clonePath?.trim() || destinationMatch?.[1] || null + const target = destination ? `: ${destination}` : '' + return `Destination already exists and is not empty${target}. Choose a different parent folder, delete the existing folder, or add the existing repository instead.` + } + return line +} diff --git a/src/shared/host-setting-overrides.test.ts b/src/shared/host-setting-overrides.test.ts new file mode 100644 index 00000000000..58871e77892 --- /dev/null +++ b/src/shared/host-setting-overrides.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + clearHostSettingOverride, + getEffectiveHostSetting, + getHostDisplayLabelOverrides, + getHostSettingOverride, + setHostSettingOverride +} from './host-setting-overrides' +import type { GlobalSettings } from './types' + +function settingsWith( + overrides: GlobalSettings['hostSettingOverrides'] +): Pick<GlobalSettings, 'hostSettingOverrides'> { + return { hostSettingOverrides: overrides } +} + +describe('getEffectiveHostSetting', () => { + it('prefers a host override over the client default', () => { + const settings = settingsWith({ 'ssh:box': { defaultWorktreeLocation: '/remote/work' } }) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/remote/work') + }) + + it('falls back to the client default when no override exists', () => { + const settings = settingsWith({}) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('falls back for an unknown host', () => { + const settings = settingsWith({ 'ssh:other': { defaultWorktreeLocation: '/x' } }) + expect( + getEffectiveHostSetting(settings, 'runtime:env', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('treats a whitespace override as absent and falls back', () => { + const settings = settingsWith({ 'ssh:box': { defaultWorktreeLocation: ' ' } }) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('allows local-host overrides', () => { + const settings = settingsWith({ local: { defaultWorktreeLocation: '/local/override' } }) + expect( + getEffectiveHostSetting(settings, 'local', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/override') + }) + + it('falls back when settings are null/undefined', () => { + expect(getEffectiveHostSetting(null, 'ssh:box', 'displayLabel', 'Default')).toBe('Default') + expect(getEffectiveHostSetting(undefined, 'ssh:box', 'displayLabel', 'Default')).toBe('Default') + }) +}) + +describe('getHostSettingOverride', () => { + it('returns the override when present', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'My Box' } }) + expect(getHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toBe('My Box') + }) + + it('returns undefined when missing', () => { + expect(getHostSettingOverride(settingsWith({}), 'ssh:box', 'displayLabel')).toBeUndefined() + }) +}) + +describe('setHostSettingOverride', () => { + it('adds an override for a new host', () => { + const next = setHostSettingOverride(settingsWith({}), 'ssh:box', 'displayLabel', 'Box') + expect(next).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) + + it('merges into an existing host without clobbering other keys', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'Box' } }) + const next = setHostSettingOverride(settings, 'ssh:box', 'defaultWorktreeLocation', '/w') + expect(next).toEqual({ 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } }) + }) + + it('does not mutate the input map', () => { + const overrides = { 'ssh:box': { displayLabel: 'Box' } } + const settings = settingsWith(overrides) + setHostSettingOverride(settings, 'ssh:box', 'displayLabel', 'Renamed') + expect(overrides).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) + + it('clears the key when given an empty value', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + }) + const next = setHostSettingOverride(settings, 'ssh:box', 'displayLabel', ' ') + expect(next).toEqual({ 'ssh:box': { defaultWorktreeLocation: '/w' } }) + }) +}) + +describe('clearHostSettingOverride', () => { + it('removes a single key but keeps remaining overrides', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({ + 'ssh:box': { defaultWorktreeLocation: '/w' } + }) + }) + + it('drops the host entry when no overrides remain', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'Box' } }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({}) + }) + + it('is a no-op for an unknown host', () => { + const settings = settingsWith({ 'ssh:other': { displayLabel: 'Other' } }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({ + 'ssh:other': { displayLabel: 'Other' } + }) + }) + + it('does not mutate the input map', () => { + const overrides = { 'ssh:box': { displayLabel: 'Box' } } + const settings = settingsWith(overrides) + clearHostSettingOverride(settings, 'ssh:box', 'displayLabel') + expect(overrides).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) +}) + +describe('getHostDisplayLabelOverrides', () => { + it('collects non-empty display labels keyed by host id', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box' }, + 'runtime:env': { defaultWorktreeLocation: '/w' }, + local: { displayLabel: ' ' } + }) + const map = getHostDisplayLabelOverrides(settings) + expect(map.get('ssh:box')).toBe('Box') + expect(map.has('runtime:env')).toBe(false) + expect(map.has('local')).toBe(false) + }) + + it('returns an empty map when no overrides exist', () => { + expect(getHostDisplayLabelOverrides(null).size).toBe(0) + }) +}) diff --git a/src/shared/host-setting-overrides.ts b/src/shared/host-setting-overrides.ts new file mode 100644 index 00000000000..908da41fd0e --- /dev/null +++ b/src/shared/host-setting-overrides.ts @@ -0,0 +1,89 @@ +import type { ExecutionHostId } from './execution-host' +import type { GlobalSettings, HostSettingOverrides } from './types' + +// Why: per-host preferences follow `effective = host override ?? client default`. +// These pure helpers centralize that rule so the UI, registry, and tests share a +// single implementation instead of re-deriving the fallback at each call site. + +export type HostSettingOverrideKey = keyof HostSettingOverrides + +type HostSettingsSlice = Pick<GlobalSettings, 'hostSettingOverrides'> + +function normalize(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** Returns the host's override for `key` if present and non-empty, else `undefined`. */ +export function getHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey +): string | undefined { + return normalize(settings?.hostSettingOverrides?.[hostId]?.[key]) +} + +/** `host override ?? client default`. Unknown hosts and cleared overrides fall back. */ +export function getEffectiveHostSetting( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey, + clientDefault: string +): string { + return getHostSettingOverride(settings, hostId, key) ?? clientDefault +} + +/** Pure update: returns the next `hostSettingOverrides` map with the override set. + * An empty/whitespace value clears the key instead of persisting blank text. */ +export function setHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey, + value: string +): Partial<Record<ExecutionHostId, HostSettingOverrides>> { + const normalized = normalize(value) + if (normalized === undefined) { + return clearHostSettingOverride(settings, hostId, key) + } + const current = settings?.hostSettingOverrides ?? {} + return { + ...current, + [hostId]: { ...current[hostId], [key]: normalized } + } +} + +/** Pure update: returns the next map with the key removed, dropping the host + * entry entirely once it has no remaining overrides. */ +export function clearHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey +): Partial<Record<ExecutionHostId, HostSettingOverrides>> { + const current = settings?.hostSettingOverrides + const hostOverrides = current?.[hostId] + if (!current || !hostOverrides || !(key in hostOverrides)) { + return current ?? {} + } + const { [key]: _removed, ...remaining } = hostOverrides + const next = { ...current } + if (Object.keys(remaining).length === 0) { + delete next[hostId] + } else { + next[hostId] = remaining + } + return next +} + +/** Builds the `displayLabel` lookup map the host registry consumes. */ +export function getHostDisplayLabelOverrides( + settings: HostSettingsSlice | null | undefined +): ReadonlyMap<ExecutionHostId, string> { + const result = new Map<ExecutionHostId, string>() + for (const [hostId, overrides] of Object.entries(settings?.hostSettingOverrides ?? {})) { + const label = normalize(overrides?.displayLabel) + if (label) { + result.set(hostId as ExecutionHostId, label) + } + } + return result +} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index f7eecd24ce4..bb2c373579e 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -57,6 +57,7 @@ export type CreateHostedReviewInput = { export type CreateHostedReviewArgs = CreateHostedReviewInput & { repoPath: string + repoId?: string connectionId?: string | null } @@ -115,6 +116,7 @@ export type HostedReviewCreationEligibility = { export type HostedReviewCreationEligibilityArgs = { repoPath: string + repoId?: string worktreePath?: string connectionId?: string | null branch: string diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts new file mode 100644 index 00000000000..57f848afae7 --- /dev/null +++ b/src/shared/project-host-setup-projection.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { + projectHostSetupProjectionFromRepos, + getProjectHostSetupsForProject, + getProjectHostSetupWorktreeMeta +} from './project-host-setup-projection' +import type { Repo } from './types' + +function repo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path' | 'displayName'>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +describe('project host setup projection', () => { + it('projects a legacy local repo into one project and one ready local setup', () => { + const projection = projectHostSetupProjectionFromRepos( + [repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca' })], + 500 + ) + + expect(projection.projects).toEqual([ + { + id: 'repo:repo-1', + displayName: 'orca', + badgeColor: '#737373', + kind: 'git', + sourceRepoIds: ['repo-1'], + createdAt: 100, + updatedAt: 100 + } + ]) + expect(projection.setups).toEqual([ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + kind: 'git', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 100, + updatedAt: 100 + } + ]) + }) + + it('preserves host-local setup fields on SSH repos', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'openclaw 2', + worktreeBasePath: '../worktrees', + gitUsername: 'alice' + }) + ]) + + expect(projection.setups[0]).toMatchObject({ + id: 'remote-repo', + hostId: 'ssh:openclaw%202', + connectionId: 'openclaw 2', + worktreeBasePath: '../worktrees', + gitUsername: 'alice' + }) + }) + + it('preserves repo-backed setup method metadata', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + projectHostSetupMethod: 'cloned' + }) + ]) + + expect(projection.setups[0]?.setupMethod).toBe('cloned') + }) + + it('groups repo checkouts with the same provider identity under one project', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'gpu-vm', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ]) + + expect(projection.projects).toHaveLength(1) + expect(projection.projects[0]).toMatchObject({ + id: 'github:stablyai/orca', + sourceRepoIds: ['local-repo', 'remote-repo'], + providerIdentity: { provider: 'github', owner: 'StablyAI', repo: 'Orca' } + }) + expect(getProjectHostSetupsForProject(projection.setups, 'github:stablyai/orca')).toHaveLength( + 2 + ) + }) + + it('uses GitHub repo icon metadata as a provider identity fallback', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + } + }), + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'gpu-vm', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/Orca' + } + }) + ]) + + expect(projection.projects).toHaveLength(1) + expect(projection.projects[0]).toMatchObject({ + id: 'github:stablyai/orca', + sourceRepoIds: ['local-repo', 'remote-repo'], + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + expect(getProjectHostSetupsForProject(projection.setups, 'github:stablyai/orca')).toHaveLength( + 2 + ) + }) + + it('does not guess that same-named folders are the same project without identity', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ id: 'local-repo', path: '/Users/alice/app', displayName: 'app' }), + repo({ + id: 'remote-repo', + path: '/srv/app', + displayName: 'app', + connectionId: 'work-server' + }) + ]) + + expect(projection.projects.map((project) => project.id)).toEqual([ + 'repo:local-repo', + 'repo:remote-repo' + ]) + }) + + it('ignores malformed provider identity values', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + upstream: { owner: 'stablyai', repo: 42 } as never + }) + ]) + + expect(projection.projects[0]?.id).toBe('repo:repo-1') + expect(projection.projects[0]?.providerIdentity).toBeUndefined() + }) + + it('derives workspace ownership metadata from the repo setup', () => { + const targetRepo = repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'openclaw 2', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + const projection = projectHostSetupProjectionFromRepos([targetRepo]) + + expect(getProjectHostSetupWorktreeMeta(projection.setups, targetRepo)).toEqual({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + projectHostSetupId: 'remote-repo' + }) + }) +}) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts new file mode 100644 index 00000000000..f90a9aca77f --- /dev/null +++ b/src/shared/project-host-setup-projection.ts @@ -0,0 +1,161 @@ +import { getRepoExecutionHostId } from './execution-host' +import type { + Project, + ProjectHostSetup, + ProjectProviderIdentity, + Repo, + WorktreeMeta +} from './types' + +type ProjectAccumulator = { + project: Project +} + +export type ProjectHostSetupProjection = { + projects: Project[] + setups: ProjectHostSetup[] +} + +function normalizeIdentityPart(value: string): string { + return value.trim().toLowerCase() +} + +function getProjectProviderIdentity( + repo: Pick<Repo, 'upstream' | 'repoIcon'> +): ProjectProviderIdentity | null { + const owner = typeof repo.upstream?.owner === 'string' ? repo.upstream.owner.trim() : '' + const name = typeof repo.upstream?.repo === 'string' ? repo.upstream.repo.trim() : '' + if (owner && name) { + return { provider: 'github', owner, repo: name } + } + if (repo.repoIcon?.type !== 'image' || repo.repoIcon.source !== 'github') { + return null + } + const parts = (repo.repoIcon.label?.trim() ?? '').split('/') + const iconOwner = parts[0]?.trim() + const iconRepo = parts[1]?.trim() + // Why: repo auto-detect can know the GitHub slug through the generated + // avatar icon even when legacy `upstream` has not been backfilled yet. + return iconOwner && iconRepo && parts.length === 2 + ? { provider: 'github', owner: iconOwner, repo: iconRepo } + : null +} + +export function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { + const identity = getProjectProviderIdentity(repo) + if (!identity) { + return `repo:${repo.id}` + } + return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}` +} + +function getProjectId(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { + return getProjectIdentityKey(repo) +} + +function createProjectFromRepo(repo: Repo, now: number): Project { + const identity = getProjectProviderIdentity(repo) + return { + id: getProjectId(repo), + displayName: repo.displayName, + badgeColor: repo.badgeColor, + ...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}), + ...(repo.kind ? { kind: repo.kind } : {}), + ...(identity ? { providerIdentity: identity } : {}), + sourceRepoIds: [repo.id], + createdAt: repo.addedAt || now, + updatedAt: repo.addedAt || now + } +} + +function mergeProjectRepo(project: Project, repo: Repo): Project { + const sourceRepoIds = project.sourceRepoIds.includes(repo.id) + ? project.sourceRepoIds + : [...project.sourceRepoIds, repo.id] + return { + ...project, + sourceRepoIds, + createdAt: Math.min(project.createdAt, repo.addedAt || project.createdAt), + updatedAt: Math.max(project.updatedAt, repo.addedAt || project.updatedAt) + } +} + +function createSetupFromRepo(repo: Repo, projectId: string, now: number): ProjectHostSetup { + const hostId = getRepoExecutionHostId(repo) + const createdAt = repo.addedAt || now + const setupMethod = repo.projectHostSetupMethod ?? 'legacy-repo' + return { + id: repo.id, + projectId, + hostId, + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + ...(repo.kind ? { kind: repo.kind } : {}), + ...(repo.connectionId !== undefined ? { connectionId: repo.connectionId } : {}), + ...(repo.executionHostId !== undefined ? { executionHostId: repo.executionHostId } : {}), + ...(repo.worktreeBasePath ? { worktreeBasePath: repo.worktreeBasePath } : {}), + ...(repo.hookSettings ? { hookSettings: repo.hookSettings } : {}), + ...(repo.gitUsername ? { gitUsername: repo.gitUsername } : {}), + ...(repo.sourceControlAi ? { sourceControlAi: repo.sourceControlAi } : {}), + setupState: 'ready', + setupMethod, + createdAt, + updatedAt: createdAt + } +} + +export function projectHostSetupProjectionFromRepos( + repos: readonly Repo[], + now = Date.now() +): ProjectHostSetupProjection { + const projectById = new Map<string, ProjectAccumulator>() + const setups: ProjectHostSetup[] = [] + + for (const repo of repos) { + const projectId = getProjectId(repo) + const existing = projectById.get(projectId) + const project = existing + ? mergeProjectRepo(existing.project, repo) + : createProjectFromRepo(repo, now) + const setup = createSetupFromRepo(repo, projectId, now) + projectById.set(projectId, { + project + }) + setups.push(setup) + } + + return { + projects: [...projectById.values()].map((entry) => entry.project), + setups + } +} + +export function getProjectHostSetupsForProject( + setups: readonly ProjectHostSetup[], + projectId: string +): ProjectHostSetup[] { + return setups.filter((setup) => setup.projectId === projectId) +} + +export function getProjectHostSetupForRepo( + setups: readonly ProjectHostSetup[], + repo: Repo +): ProjectHostSetup { + return ( + setups.find((setup) => setup.repoId === repo.id) ?? + projectHostSetupProjectionFromRepos([repo]).setups[0] + ) +} + +export function getProjectHostSetupWorktreeMeta( + setups: readonly ProjectHostSetup[], + repo: Repo +): Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'> { + const setup = getProjectHostSetupForRepo(setups, repo) + return { + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 907e4259541..778196c1adc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -19,7 +19,11 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 -export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3 +export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 + +export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const +export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const +export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -28,7 +32,10 @@ export const RUNTIME_CAPABILITIES = [ 'terminal.binary-stream.v1', 'terminal.multiplex.v1', 'workspace-ports.v1', - 'mobile.tasks.v1' + 'mobile.tasks.v1', + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 64954fd6ead..f851a3ba6fc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -349,6 +349,7 @@ export type RuntimeTerminalSend = { export type RuntimeTerminalCreate = { handle: string + tabId?: string worktreeId: string title: string | null surface?: 'background' | 'visible' diff --git a/src/shared/task-source-context.test.ts b/src/shared/task-source-context.test.ts new file mode 100644 index 00000000000..5af28c763cb --- /dev/null +++ b/src/shared/task-source-context.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from './execution-host' +import { + buildTaskSourceContextFromRepo, + buildWorkspaceRunContext, + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + normalizeTaskSourceContext, + runtimeHostIdFromEnvironmentId +} from './task-source-context' + +describe('task source context', () => { + it('defaults source context to the local host', () => { + expect( + normalizeTaskSourceContext({ + provider: 'github', + projectId: ' project-1 ', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + ).toEqual({ + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: null, + repoId: null, + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: null + }) + }) + + it('uses repo execution ownership when building a source context', () => { + expect( + buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: 'project-1', + repo: { + id: 'repo-1', + connectionId: 'ssh target', + executionHostId: null + } + })?.hostId + ).toBe(toSshExecutionHostId('ssh target')) + + expect( + buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: 'project-1', + repo: { + id: 'repo-1', + connectionId: 'ssh target', + executionHostId: toRuntimeExecutionHostId('remote-runtime') + } + })?.hostId + ).toBe(toRuntimeExecutionHostId('remote-runtime')) + }) + + it('derives runtime settings only for runtime-owned task sources', () => { + expect( + getTaskSourceRuntimeSettings({ + hostId: toRuntimeExecutionHostId('remote-runtime') + }) + ).toEqual({ activeRuntimeEnvironmentId: 'remote-runtime' }) + + expect( + getTaskSourceRuntimeSettings({ + hostId: toSshExecutionHostId('ssh-target') + }) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) + + it('keeps provider cache scopes separate by host and provider identity', () => { + const local = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + const ssh = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + const differentRepo = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'other', repo: 'orca' } + }) + + expect(local).not.toBe(ssh) + expect(local).not.toBe(differentRepo) + }) + + it('serializes provider identities for GitLab, Linear, and Jira cache scopes', () => { + const base = { + projectId: 'project-1', + hostId: LOCAL_EXECUTION_HOST_ID, + repoId: 'repo-1' + } as const + + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'gitlab', + providerIdentity: { provider: 'gitlab', namespace: 'stably', project: 'orca' } + }) + ).toContain(encodeURIComponent('stably/orca')) + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'linear', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1', teamKey: 'ENG' } + }) + ).toContain(encodeURIComponent('workspace-1/ENG')) + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'jira', + providerIdentity: { + provider: 'jira', + siteUrl: 'https://example.atlassian.net', + projectKey: 'OPS' + } + }) + ).toContain(encodeURIComponent('https://example.atlassian.net/OPS')) + }) + + it('drops provider identities that do not match the source provider', () => { + expect( + normalizeTaskSourceContext({ + provider: 'gitlab', + projectId: 'project-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + })?.providerIdentity + ).toBeNull() + }) + + it('builds workspace run context from an explicit project host setup', () => { + expect( + buildWorkspaceRunContext({ + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + }) + ).toEqual({ + kind: 'workspace-run', + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + }) + }) + + it('normalizes focused runtime ids to host ids', () => { + expect(runtimeHostIdFromEnvironmentId(' remote ')).toBe(toRuntimeExecutionHostId('remote')) + expect(runtimeHostIdFromEnvironmentId(' ')).toBe('local') + }) +}) diff --git a/src/shared/task-source-context.ts b/src/shared/task-source-context.ts new file mode 100644 index 00000000000..890e51893ae --- /dev/null +++ b/src/shared/task-source-context.ts @@ -0,0 +1,231 @@ +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from './execution-host' +import type { GlobalSettings, ProjectProviderIdentity, Repo } from './types' + +export type TaskProvider = 'github' | 'gitlab' | 'linear' | 'jira' + +export type GitHubTaskProviderIdentity = ProjectProviderIdentity & { + provider: 'github' +} + +export type GitLabTaskProviderIdentity = { + provider: 'gitlab' + projectId?: string | null + namespace?: string | null + project?: string | null + webUrl?: string | null +} + +export type LinearTaskProviderIdentity = { + provider: 'linear' + workspaceId?: string | null + workspaceName?: string | null + teamId?: string | null + teamKey?: string | null +} + +export type JiraTaskProviderIdentity = { + provider: 'jira' + siteId?: string | null + siteUrl?: string | null + projectKey?: string | null +} + +export type TaskProviderIdentity = + | GitHubTaskProviderIdentity + | GitLabTaskProviderIdentity + | LinearTaskProviderIdentity + | JiraTaskProviderIdentity + +export type TaskSourceContext = { + kind: 'task-source' + provider: TaskProvider + projectId: string + hostId: ExecutionHostId + projectHostSetupId?: string | null + repoId?: string | null + providerIdentity?: TaskProviderIdentity | null + accountLabel?: string | null +} + +export type WorkspaceRunContext = { + kind: 'workspace-run' + projectId: string + hostId: ExecutionHostId + projectHostSetupId: string + repoId: string + path: string +} + +export type TaskSourceContextInput = Omit<TaskSourceContext, 'kind' | 'hostId'> & { + kind?: 'task-source' + hostId?: string | null +} + +export function normalizeTaskSourceContext( + input: TaskSourceContextInput +): TaskSourceContext | null { + const projectId = normalizeNonEmptyString(input.projectId) + if (!projectId) { + return null + } + const provider = normalizeTaskProvider(input.provider) + if (!provider) { + return null + } + return { + kind: 'task-source', + provider, + projectId, + hostId: normalizeExecutionHostId(input.hostId) ?? LOCAL_EXECUTION_HOST_ID, + projectHostSetupId: normalizeNonEmptyString(input.projectHostSetupId), + repoId: normalizeNonEmptyString(input.repoId), + providerIdentity: normalizeTaskProviderIdentity(provider, input.providerIdentity), + accountLabel: normalizeNonEmptyString(input.accountLabel) + } +} + +export function buildTaskSourceContextFromRepo(args: { + provider: TaskProvider + projectId: string + repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> + projectHostSetupId?: string | null + providerIdentity?: TaskProviderIdentity | null + accountLabel?: string | null +}): TaskSourceContext | null { + return normalizeTaskSourceContext({ + provider: args.provider, + projectId: args.projectId, + hostId: getRepoHostId(args.repo), + repoId: args.repo.id, + projectHostSetupId: args.projectHostSetupId, + providerIdentity: args.providerIdentity, + accountLabel: args.accountLabel + }) +} + +export function getTaskSourceRuntimeSettings( + context: Pick<TaskSourceContext, 'hostId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const parsed = parseExecutionHostId(context?.hostId) + return { + activeRuntimeEnvironmentId: parsed?.kind === 'runtime' ? parsed.environmentId : null + } +} + +export function getTaskSourceCacheScope( + context: Pick<TaskSourceContext, 'provider' | 'hostId' | 'projectId' | 'projectHostSetupId'> & { + providerIdentity?: TaskProviderIdentity | null + repoId?: string | null + } +): string { + return [ + context.provider, + context.hostId, + context.projectId, + context.projectHostSetupId ?? '', + context.repoId ?? '', + providerIdentityCachePart(context.providerIdentity) + ] + .map(encodeCachePart) + .join(':') +} + +export function buildWorkspaceRunContext(args: { + projectId: string + hostId: string | null | undefined + projectHostSetupId: string + repoId: string + path: string +}): WorkspaceRunContext | null { + const projectId = normalizeNonEmptyString(args.projectId) + const projectHostSetupId = normalizeNonEmptyString(args.projectHostSetupId) + const repoId = normalizeNonEmptyString(args.repoId) + const repoPath = normalizeNonEmptyString(args.path) + if (!projectId || !projectHostSetupId || !repoId || !repoPath) { + return null + } + return { + kind: 'workspace-run', + projectId, + hostId: normalizeExecutionHostId(args.hostId) ?? LOCAL_EXECUTION_HOST_ID, + projectHostSetupId, + repoId, + path: repoPath + } +} + +export function getWorkspaceRunRuntimeSettings( + context: Pick<WorkspaceRunContext, 'hostId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return getTaskSourceRuntimeSettings(context ? { hostId: context.hostId } : null) +} + +function getRepoHostId(repo: Pick<Repo, 'connectionId' | 'executionHostId'>): ExecutionHostId { + const explicit = normalizeExecutionHostId(repo.executionHostId) + if (explicit) { + return explicit + } + const connectionId = normalizeNonEmptyString(repo.connectionId) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +function normalizeTaskProvider(value: string): TaskProvider | null { + switch (value) { + case 'github': + case 'gitlab': + case 'linear': + case 'jira': + return value + default: + return null + } +} + +function normalizeTaskProviderIdentity( + provider: TaskProvider, + identity: TaskProviderIdentity | null | undefined +): TaskProviderIdentity | null { + if (!identity || identity.provider !== provider) { + return null + } + return identity +} + +function normalizeNonEmptyString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function providerIdentityCachePart(identity: TaskProviderIdentity | null | undefined): string { + if (!identity) { + return '' + } + switch (identity.provider) { + case 'github': + return [identity.owner, identity.repo].join('/') + case 'gitlab': + return identity.projectId ?? [identity.namespace, identity.project].filter(Boolean).join('/') + case 'linear': + return [identity.workspaceId, identity.teamId ?? identity.teamKey].filter(Boolean).join('/') + case 'jira': + return [identity.siteId ?? identity.siteUrl, identity.projectKey].filter(Boolean).join('/') + } +} + +function encodeCachePart(value: string): string { + return encodeURIComponent(value) +} + +export function runtimeHostIdFromEnvironmentId( + environmentId: string | null | undefined +): ExecutionHostId { + const trimmed = normalizeNonEmptyString(environmentId) + return trimmed ? toRuntimeExecutionHostId(trimmed) : LOCAL_EXECUTION_HOST_ID +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3678838a91d..43228342e2c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { ExecutionHostId } from './execution-host' import type { SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' @@ -87,6 +88,128 @@ export type RepoKind = 'git' | 'folder' export type IssueSourcePreference = 'upstream' | 'origin' | 'auto' export type ExternalWorktreeVisibility = 'hide' | 'show' +export type ProjectProviderIdentity = { + provider: 'github' + owner: string + repo: string +} + +export type Project = { + id: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + kind?: RepoKind + providerIdentity?: ProjectProviderIdentity + sourceRepoIds: string[] + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' +export type ProjectHostSetupMethod = + | 'legacy-repo' + | 'imported-existing-folder' + | 'cloned' + | 'provisioned' +export type RepoProjectHostSetupMethod = Extract< + ProjectHostSetupMethod, + 'imported-existing-folder' | 'cloned' +> + +export type ProjectHostSetup = { + id: string + projectId: string + hostId: ExecutionHostId + repoId: string + path: string + displayName: string + kind?: RepoKind + connectionId?: string | null + executionHostId?: ExecutionHostId | null + worktreeBasePath?: string + hookSettings?: RepoHookSettings + gitUsername?: string + setupState: ProjectHostSetupState + setupMethod: ProjectHostSetupMethod + sourceControlAi?: RepoSourceControlAiOverrides + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupExistingFolderArgs = { + projectId: string + hostId: ExecutionHostId + path: string + kind?: RepoKind + displayName?: string + setupMethod?: RepoProjectHostSetupMethod +} + +export type ProjectHostSetupCreateArgs = { + projectId: string + hostId: ExecutionHostId + setupId?: string + path?: string + kind?: RepoKind + displayName?: string + worktreeBasePath?: string + gitUsername?: string + setupState?: ProjectHostSetupState + setupMethod?: Exclude<ProjectHostSetupMethod, 'legacy-repo'> +} + +export type ProjectHostSetupCloneArgs = { + projectId: string + hostId: ExecutionHostId + url: string + destination: string + displayName?: string +} + +export type ProjectHostSetupUpdateArgs = { + setupId: string + updates: Partial< + Pick< + ProjectHostSetup, + | 'displayName' + | 'path' + | 'worktreeBasePath' + | 'setupState' + | 'setupMethod' + | 'gitUsername' + | 'kind' + > + > +} + +export type ProjectHostSetupDeleteArgs = { + setupId: string +} + +export type ProjectHostSetupResult = { + project: Project + setup: ProjectHostSetup + repo: Repo +} + +export type ProjectHostSetupCreateResult = { + project: Project + setup: ProjectHostSetup +} + +export type ProjectHostSetupUpdateResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + +export type ProjectHostSetupDeleteResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + export type Repo = { id: string path: string @@ -106,6 +229,11 @@ export type Repo = { hookSettings?: RepoHookSettings /** SSH target ID for remote repos. null/undefined = local. */ connectionId?: string | null + /** + * Explicit execution owner for this repo. Runtime-host repos need this + * because they otherwise look identical to local repos (`connectionId: null`). + */ + executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null /** Per-repo override for issue-source resolution. `undefined` is treated * identically to `'auto'`; writers leave it undefined on creation so * existing persisted records stay forward-compatible. */ @@ -128,6 +256,8 @@ export type Repo = { projectGroupOrder?: number /** Repo-specific source-control AI overrides. Missing fields inherit global settings. */ sourceControlAi?: RepoSourceControlAiOverrides + /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ + projectHostSetupMethod?: RepoProjectHostSetupMethod } export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' @@ -286,6 +416,12 @@ export type Worktree = { id: string // `${repoId}::${path}` instanceId?: string repoId: string + /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ + projectId?: string + /** Execution host that owns the workspace. Optional for pre-project-host metadata. */ + hostId?: ExecutionHostId + /** Host-specific setup used to create/run this workspace. */ + projectHostSetupId?: string displayName: string comment: string linkedIssue: number | null @@ -363,6 +499,12 @@ export type GitHubPrStartPoint = { export type WorktreeMeta = { /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ instanceId?: string + /** See Worktree.projectId. Persisted for project-first workspace ownership. */ + projectId?: string + /** See Worktree.hostId. Persisted for project-first workspace ownership. */ + hostId?: ExecutionHostId + /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ + projectHostSetupId?: string displayName: string comment: string linkedIssue: number | null @@ -657,6 +799,9 @@ export type BrowserPage = { canGoForward: boolean loadError: BrowserLoadError | null createdAt: number + // Why: remote-owned worktrees can still host client-local fallback browser + // pages until headless remote runtimes support real browser panes. + browserRuntimeEnvironmentId?: string | null /** Active CDP viewport emulation preset. null = default (fill pane, no CDP override) */ viewportPresetId?: BrowserViewportPresetId | null } @@ -903,6 +1048,7 @@ export type GitHubPRRefreshAlias = { branch: string worktreeId?: string connectionId?: string | null + executionHostId?: string | null linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null @@ -914,6 +1060,7 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { isBare?: boolean isArchived?: boolean connectionId?: string | null + executionHostId?: string | null connectionState?: 'connected' | 'disconnected' | 'unknown' cachedFetchedAt?: number | null cachedHasPR?: boolean | null @@ -1763,6 +1910,8 @@ export type CreateWorktreeResult = { localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion startupTerminal?: { spawned: boolean + handle?: string + tabId?: string surface?: 'visible' | 'background' } timing?: WorktreeCreateTiming @@ -2077,8 +2226,23 @@ export type FloatingTerminalCwdRequest = { requireTrusted?: boolean } +/** Per-host overrides for client preferences that genuinely vary by execution + * host. NARROW by design: only settings whose value is meaningless to share + * across hosts belong here. + * - `displayLabel`: a client-side rename for the host shown in sidebar/pickers. + * - `defaultWorktreeLocation`: the host's root worktree directory; a remote + * SSH/runtime host has a different filesystem layout than the local Mac, so + * the client `workspaceDir` default cannot apply unchanged. */ +export type HostSettingOverrides = { + displayLabel?: string + defaultWorktreeLocation?: string +} + export type GlobalSettings = { workspaceDir: string + /** Per-host overrides keyed by ExecutionHostId. Effective value for a + * host-varying setting is `host override ?? client default`. */ + hostSettingOverrides?: Partial<Record<ExecutionHostId, HostSettingOverrides>> nestWorkspaces: boolean workspaceDirHistory?: OrcaWorkspaceLayout[] refreshLocalBaseRefOnWorktreeCreate: boolean @@ -2707,6 +2871,9 @@ export type ActiveRightSidebarTab = Exclude<RightSidebarTab, 'search'> export type RightSidebarExplorerView = 'files' | 'search' export type ProjectOrderBy = 'manual' | 'recent' +export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` +export type VisibleWorkspaceHostIds = Exclude<WorkspaceHostScope, 'all'>[] | null +export type WorkspaceHostOrder = Exclude<WorkspaceHostScope, 'all'>[] export type PersistedUIState = { lastActiveRepoId: string | null @@ -2727,6 +2894,16 @@ export type PersistedUIState = { showActiveOnly: boolean /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ hideSleepingWorkspaces?: boolean + /** Which execution hosts the workspace sidebar shows. `all` keeps the mixed + * command-center view; specific host IDs focus the sidebar without tearing + * down sessions owned by other hosts. */ + workspaceHostScope?: WorkspaceHostScope + /** Which execution hosts the workspace sidebar shows. `null` means sticky + * all-hosts so newly-added hosts appear automatically. */ + visibleWorkspaceHostIds?: VisibleWorkspaceHostIds + /** User-defined sidebar order for host sections. Missing/new hosts append in + * the discovered host order. */ + workspaceHostOrder?: WorkspaceHostOrder /** Deprecated legacy positive-form setting. Ignored on hydration. */ showSleepingWorkspaces?: boolean /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ @@ -2979,6 +3156,8 @@ export type LegacyPaneKeyAliasEntry = { export type PersistedState = { schemaVersion: number repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] folderWorkspaces: FolderWorkspace[] /** Sparse-checkout presets keyed by repoId. Empty record on first launch; @@ -2992,7 +3171,14 @@ export type PersistedState = { pr: Record<string, { data: PRInfo | null; fetchedAt: number }> issue: Record<string, { data: IssueInfo | null; fetchedAt: number }> } + /** Legacy single-blob session. Retained as the canonical 'local' execution + * host partition so an app downgrade still reads its workspace. Non-local + * hosts live in workspaceSessionsByHostId, keyed by ExecutionHostId. */ workspaceSession: WorkspaceSessionState + /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:). + * Mixed-host writes stay isolated here; 'local' stays in workspaceSession so + * pre-partition builds keep working. Optional/absent on legacy files. */ + workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, WorkspaceSessionState>> sshTargets: SshTarget[] sshRemotePtyLeases: SshRemotePtyLease[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 20fb3f84c0f..387728cefbb 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -245,6 +245,9 @@ const browserPageSchema = z.object({ canGoForward: z.boolean(), loadError: browserLoadErrorSchema.nullable(), createdAt: z.number(), + // Why: explicit null marks a browser page as client-local even when its + // worktree is remote-owned; older sessions omit it and keep inferred runtime. + browserRuntimeEnvironmentId: z.string().nullable().optional(), // Why: optional+nullable so sessions persisted before viewport presets were // added still validate; without this, zod would strip the field during // restore and reset the user's chosen preset on every app restart.