From 281edba14a04f5614a6af7a6cdba058320b3f101 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:49:27 -0700 Subject: [PATCH 01/16] Fix premature project header truncation --- .../sidebar/ProjectHeaderActions.test.tsx | 39 + .../sidebar/ProjectHeaderActions.tsx | 27 + .../src/components/sidebar/WorktreeList.tsx | 671 +++++++++--------- 3 files changed, 403 insertions(+), 334 deletions(-) create mode 100644 src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx create mode 100644 src/renderer/src/components/sidebar/ProjectHeaderActions.tsx diff --git a/src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx b/src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx new file mode 100644 index 00000000000..dc41dfef26e --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ProjectHeaderActions } from './ProjectHeaderActions' + +let root: Root | null = null + +describe('ProjectHeaderActions', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + root = null + } + document.body.replaceChildren() + }) + + it('overlays hover-only controls instead of reserving project title width', () => { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render() + }) + + const actions = container.querySelector('[data-repo-header-actions]') + + expect(actions?.className).toContain('can-hover:absolute') + expect(actions?.className).toContain('can-hover:pointer-events-none') + expect(actions?.className).toContain('group-hover:pointer-events-auto') + expect(actions?.className).toContain('has-[:focus-visible]:pointer-events-auto') + expect(actions?.className).toContain('has-[button[data-state=open]]:pointer-events-auto') + }) +}) diff --git a/src/renderer/src/components/sidebar/ProjectHeaderActions.tsx b/src/renderer/src/components/sidebar/ProjectHeaderActions.tsx new file mode 100644 index 00000000000..681f3cb2176 --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectHeaderActions.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { cn } from '@/lib/utils' + +// Why: desktop hover actions should not permanently reserve project-title width; +// touch devices keep them in normal flow because there is no hover reveal. +export const PROJECT_HEADER_ACTIONS_CLASS_NAME = cn( + 'flex shrink-0 items-center gap-0.5', + 'can-hover:absolute can-hover:right-1 can-hover:top-1/2 can-hover:z-10 can-hover:-translate-y-1/2', + 'can-hover:rounded-md can-hover:bg-worktree-sidebar can-hover:pl-1', + 'can-hover:pointer-events-none can-hover:opacity-0 can-hover:transition-opacity', + 'group-hover:pointer-events-auto group-hover:opacity-100', + 'has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100', + 'has-[button[data-state=open]]:pointer-events-auto has-[button[data-state=open]]:opacity-100' +) + +export function ProjectHeaderActions({ + className, + ...props +}: React.HTMLAttributes): React.JSX.Element { + return ( +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7ff256d9c1b..ae97817f49f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -226,6 +226,7 @@ import { orderHostSectionOptions } from './host-section-order' import { useHostHeaderDrag } from './host-header-drag' import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' +import { ProjectHeaderActions } from './ProjectHeaderActions' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' @@ -3511,7 +3512,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} className={cn( - 'group flex h-7 w-full items-center gap-1.5 pr-1 text-left transition-all', + 'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all', 'cursor-pointer', isDraggingThis && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', @@ -3600,355 +3601,357 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
-
- -
+ +
+ +
- {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( - - - - - - { - if (row.projectGroup?.id) { - handleRenameProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.4d7b73658c', - 'Rename group' - )} - - { - if (row.projectGroup?.id) { - handleDeleteProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.902115cdbe', - 'Delete group' - )} - - - - ) : null} - - {isProjectGroupHeader && - !row.repo && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath ? ( - - - - - - {projectGroupPathStatus?.exists === false - ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) - : translate( - 'auto.components.sidebar.WorktreeList.bd37a57ac8', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeList.2ef41bf9a7', - 'Project actions' - )} - - - - { - if (row.repo) { - handleOpenRepoSettings(row.repo.id) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.2cdffbc728', - 'Project Settings' - )} - - { - if (row.repo) { - handleOpenRepoSettings( - row.repo.id, - getRepositoryIconSectionId(row.repo.id) - ) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.e82d3589a1', - 'Change Project Icon' - )} - - {row.repo && isGitRepoKind(row.repo) ? ( - { - if (row.repo) { - handleOpenWorktreeVisibility(row.repo.id) - } - }} - > - - {getWorktreeVisibilityMenuLabel(row.repo)} - - ) : null} - { - if (row.repo) { - handleCreateGroupFromRepo(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.cbfd565f83', - 'New group from project' - )} - - {projectGroups.length > 0 ? ( - - - - {translate( - 'auto.components.sidebar.WorktreeList.4a08fb55f2', - 'Move to group' - )} - - - {projectGroups.map((group) => ( - { - if (row.repo) { - handleMoveProjectToGroup(row.repo, group.id) - } - }} - > - {group.name} - - ))} - - - ) : null} - {row.repo.projectGroupId ? ( - { - if (row.repo) { - handleRemoveProjectFromGroup(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.64e55f7f01', - 'Remove from group' - )} - - ) : null} - - { - if (row.repo) { - handleRemoveProject(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.c83968f87f', - 'Remove Project' - )} - - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - {createState?.disabled ? ( - event.stopPropagation()} - onPointerDown={handleRepoHeaderActionPointerDown} - > - - - ) : ( + {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( + + + + + { + if (row.projectGroup?.id) { + handleRenameProjectGroup(row.projectGroup.id, row.label) + } + }} + > + {translate( + 'auto.components.sidebar.WorktreeList.4d7b73658c', + 'Rename group' + )} + + { + if (row.projectGroup?.id) { + handleDeleteProjectGroup(row.projectGroup.id, row.label) + } + }} + > + {translate( + 'auto.components.sidebar.WorktreeList.902115cdbe', + 'Delete group' + )} + + + + ) : null} + + {isProjectGroupHeader && + !row.repo && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath ? ( + + + - )} - - - {createState?.tooltip ?? - translate( - 'auto.components.sidebar.WorktreeList.bb85cd86ba', - 'Create workspace for {{value0}}', - { value0: row.label } + + + {projectGroupPathStatus?.exists === false + ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) + : translate( + 'auto.components.sidebar.WorktreeList.bd37a57ac8', + 'Create workspace for {{value0}}', + { value0: row.label } + )} + + + ) : null} + + {row.repo && groupBy === 'repo' ? ( + + + + + + + + + {translate( + 'auto.components.sidebar.WorktreeList.2ef41bf9a7', + 'Project actions' + )} + + + + { + if (row.repo) { + handleOpenRepoSettings(row.repo.id) + } + }} + > + + {translate( + 'auto.components.sidebar.WorktreeList.2cdffbc728', + 'Project Settings' + )} + + { + if (row.repo) { + handleOpenRepoSettings( + row.repo.id, + getRepositoryIconSectionId(row.repo.id) + ) + } + }} + > + + {translate( + 'auto.components.sidebar.WorktreeList.e82d3589a1', + 'Change Project Icon' + )} + + {row.repo && isGitRepoKind(row.repo) ? ( + { + if (row.repo) { + handleOpenWorktreeVisibility(row.repo.id) + } + }} + > + + {getWorktreeVisibilityMenuLabel(row.repo)} + + ) : null} + { + if (row.repo) { + handleCreateGroupFromRepo(row.repo) + } + }} + > + + {translate( + 'auto.components.sidebar.WorktreeList.cbfd565f83', + 'New group from project' + )} + + {projectGroups.length > 0 ? ( + + + + {translate( + 'auto.components.sidebar.WorktreeList.4a08fb55f2', + 'Move to group' + )} + + + {projectGroups.map((group) => ( + { + if (row.repo) { + handleMoveProjectToGroup(row.repo, group.id) + } + }} + > + {group.name} + + ))} + + + ) : null} + {row.repo.projectGroupId ? ( + { + if (row.repo) { + handleRemoveProjectFromGroup(row.repo) + } + }} + > + + {translate( + 'auto.components.sidebar.WorktreeList.64e55f7f01', + 'Remove from group' + )} + + ) : null} + + { + if (row.repo) { + handleRemoveProject(row.repo) + } + }} + > + + {translate( + 'auto.components.sidebar.WorktreeList.c83968f87f', + 'Remove Project' + )} + + + + ) : null} + + {row.repo && groupBy === 'repo' ? ( + + + {createState?.disabled ? ( + event.stopPropagation()} + onPointerDown={handleRepoHeaderActionPointerDown} + > + + + ) : ( + )} - - - ) : null} + + + {createState?.tooltip ?? + translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: row.label } + )} + + + ) : null} +
) From 674639205cd9fb0efc7a635390e178d9a0fb211e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:05:57 -0700 Subject: [PATCH 02/16] Show and Filter Automation-Created Workspaces (#5697) Co-authored-by: Orca --- config/tsconfig.tc.web.json | 1 + src/main/automations/dispatch-tokens.ts | 87 ++ .../headless-workspace-create.test.ts | 91 ++ .../automations/headless-workspace-create.ts | 47 + src/main/automations/service.ts | 8 +- src/main/automations/workspace-provenance.ts | 103 +++ src/main/index.ts | 24 +- src/main/ipc/worktree-logic.test.ts | 30 +- src/main/ipc/worktree-logic.ts | 79 +- src/main/ipc/worktree-metadata-merge.ts | 71 ++ src/main/ipc/worktree-remote.ts | 11 +- src/main/ipc/worktrees.test.ts | 35 + src/main/ipc/worktrees.ts | 35 +- src/main/persistence.test.ts | 2 + src/main/runtime/orca-runtime.ts | 11 +- ...ime-request-connection.integration.test.ts | 2 + .../runtime/rpc/methods/client-ui.test.ts | 3 + src/main/runtime/rpc/methods/client-ui.ts | 1 + .../runtime/rpc/methods/worktree-schemas.ts | 10 +- src/main/runtime/rpc/methods/worktree.test.ts | 334 ++++++++ src/main/runtime/rpc/methods/worktree.ts | 119 +-- src/main/worktree-removal-authority.test.ts | 13 +- src/main/worktree-removal-safety.ts | 3 +- src/renderer/src/App.tsx | 3 + .../src/components/WorktreeJumpPalette.tsx | 10 +- .../automations/AutomationsPage.tsx | 137 ++- .../automations/automation-host-client.ts | 12 +- .../src/components/sidebar/SidebarFilter.tsx | 34 +- .../sidebar/SidebarWorkspaceFilterSection.tsx | 15 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 8 +- .../sidebar/WorktreeCard.pr-display.test.tsx | 92 ++ .../src/components/sidebar/WorktreeCard.tsx | 72 +- .../WorktreeCardAutomationDetailSection.tsx | 151 ++++ .../WorktreeCardDisplayMenuSection.tsx | 2 +- .../components/sidebar/WorktreeCardMeta.tsx | 39 +- .../src/components/sidebar/WorktreeList.tsx | 19 +- .../sidebar-workspace-option-items.test.ts | 5 + .../sidebar/sidebar-workspace-option-items.ts | 61 +- ...e-visible-workspace-kanban-worktree-ids.ts | 3 + .../sidebar/visible-worktrees.test.ts | 54 ++ .../components/sidebar/visible-worktrees.ts | 14 + .../worktree-card-display-property-options.ts | 62 ++ .../sidebar/worktree-card-meta-types.ts | 7 +- .../src/hooks/useAutomationDispatchEvents.ts | 804 +++++++++--------- src/renderer/src/i18n/locales/en.json | 20 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 20 +- src/renderer/src/i18n/locales/zh.json | 20 +- src/renderer/src/lib/startup-ui-hydration.ts | 1 + ...ktree-activation-automation-filter.test.ts | 83 ++ src/renderer/src/lib/worktree-activation.ts | 6 + src/renderer/src/store/slices/ui.ts | 16 + .../src/store/slices/worktree-helpers.ts | 6 +- src/renderer/src/store/slices/worktrees.ts | 8 +- src/renderer/src/web/web-preload-api.ts | 3 +- src/shared/automation-workspace-provenance.ts | 31 + src/shared/automations-types.ts | 1 + src/shared/constants.ts | 1 + src/shared/types.ts | 31 +- src/shared/worktree-card-properties.test.ts | 14 +- src/shared/worktree-card-properties.ts | 4 +- 62 files changed, 2363 insertions(+), 666 deletions(-) create mode 100644 src/main/automations/dispatch-tokens.ts create mode 100644 src/main/automations/headless-workspace-create.test.ts create mode 100644 src/main/automations/headless-workspace-create.ts create mode 100644 src/main/automations/workspace-provenance.ts create mode 100644 src/main/ipc/worktree-metadata-merge.ts create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-display-property-options.ts create mode 100644 src/renderer/src/lib/worktree-activation-automation-filter.test.ts create mode 100644 src/shared/automation-workspace-provenance.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 82f1f4ab5fc..f3f66700bc1 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -9,6 +9,7 @@ "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", + "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/src/main/automations/dispatch-tokens.ts b/src/main/automations/dispatch-tokens.ts new file mode 100644 index 00000000000..6eb0ba34ac8 --- /dev/null +++ b/src/main/automations/dispatch-tokens.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'crypto' + +const DISPATCH_TOKEN_TTL_MS = 30 * 60_000 + +type DispatchTokenRecord = { + automationId: string + runId: string + expiresAt: number + reservedBy?: string + inFlight: boolean +} + +const dispatchTokens = new Map() + +function pruneExpiredDispatchTokens(now = Date.now()): void { + for (const [token, record] of dispatchTokens) { + if (record.expiresAt <= now) { + dispatchTokens.delete(token) + } + } +} + +export function createAutomationDispatchToken(automationId: string, runId: string): string { + pruneExpiredDispatchTokens() + const token = randomUUID() + dispatchTokens.set(token, { + automationId, + runId, + expiresAt: Date.now() + DISPATCH_TOKEN_TTL_MS, + inFlight: false + }) + return token +} + +export function beginAutomationDispatchTokenUse(args: { + automationId: string + runId: string + token: string + reservationId: string +}): boolean { + pruneExpiredDispatchTokens() + const record = dispatchTokens.get(args.token) + const valid = + record?.automationId === args.automationId && + record.runId === args.runId && + record.expiresAt > Date.now() + if (!valid) { + return false + } + if (record.reservedBy !== undefined && record.reservedBy !== args.reservationId) { + return false + } + if (record.inFlight) { + return false + } + record.reservedBy = args.reservationId + record.inFlight = true + return true +} + +export function releaseAutomationDispatchTokenUse(args: { + token: string + reservationId: string +}): void { + const record = dispatchTokens.get(args.token) + if (record?.reservedBy === args.reservationId) { + record.inFlight = false + } +} + +export function finishAutomationDispatchTokenUse(args: { + token: string + reservationId: string +}): void { + const record = dispatchTokens.get(args.token) + if (record?.reservedBy === args.reservationId) { + dispatchTokens.delete(args.token) + } +} + +export function clearAutomationDispatchTokens(automationId: string, runId: string): void { + for (const [token, record] of dispatchTokens) { + if (record.automationId === automationId && record.runId === runId) { + dispatchTokens.delete(token) + } + } +} diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts new file mode 100644 index 00000000000..3e247af5046 --- /dev/null +++ b/src/main/automations/headless-workspace-create.test.ts @@ -0,0 +1,91 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/types' +import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create' + +const repoPath = path.join('tmp', 'orca') + +const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 1, + kind: 'git', + executionHostId: 'ssh:ssh-target-1' +} + +const automation: Automation = { + id: 'automation-1', + name: 'Nightly review', + prompt: 'Review changes', + precheck: null, + agentId: 'codex', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: repoPath + }, + sourceContext: null, + projectId: 'legacy-repo-1', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: 'origin/main', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 +} + +describe('headless automation workspace create args', () => { + it('stamps automation provenance for serve-mode new-per-run workspaces', () => { + const args = buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run: { + id: 'run-1', + title: 'Nightly review run', + scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + repo, + createdAt: 123 + }) + + expect(args).toMatchObject({ + repoSelector: 'repo-1', + name: 'auto-nightly-review-run-20260102T0304', + baseBranch: 'origin/main', + setupDecision: 'inherit', + activate: false, + createdWithAgent: 'codex', + startupAgent: 'codex', + startupPrompt: 'Review changes', + telemetrySource: 'unknown', + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + } + }) + }) +}) diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts new file mode 100644 index 00000000000..497ad85573b --- /dev/null +++ b/src/main/automations/headless-workspace-create.ts @@ -0,0 +1,47 @@ +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import type { Repo } from '../../shared/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type HeadlessAutomationRunForWorkspace = Pick +type RuntimeCreateManagedWorktreeArgs = Parameters[0] + +export function buildHeadlessAutomationWorkspaceName( + runTitle: string, + scheduledFor: number +): string { + // Why: generated workspace names must stay deterministic and short enough for + // cross-provider branch/path displays while still carrying the run timestamp. + 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}` +} + +export function buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run, + repo, + createdAt = Date.now() +}: { + automation: Automation + run: HeadlessAutomationRunForWorkspace + repo: Repo + createdAt?: number +}): RuntimeCreateManagedWorktreeArgs { + return { + repoSelector: 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', + automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt) + } +} diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index bd6e7fd534e..53bb902de13 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -14,6 +14,7 @@ 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 { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' import { didAutomationPrecheckPass, formatAutomationPrecheckFailure @@ -131,6 +132,7 @@ export class AutomationService { async markDispatchResult(result: AutomationDispatchResult): Promise { const run = this.store.updateAutomationRun(result) + clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalRunStatus(run.status)) { return run } @@ -231,7 +233,11 @@ export class AutomationService { workspaceId: automation.workspaceId, error: null }) - const payload: AutomationDispatchRequest = { automation, run: updated } + const payload: AutomationDispatchRequest = { + automation, + run: updated, + dispatchToken: createAutomationDispatchToken(automation.id, updated.id) + } webContents.send('automations:dispatchRequested', payload) return updated } diff --git a/src/main/automations/workspace-provenance.ts b/src/main/automations/workspace-provenance.ts new file mode 100644 index 00000000000..44936edc888 --- /dev/null +++ b/src/main/automations/workspace-provenance.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { getAutomationRunRepoId } from '../../shared/automation-run-identity' +import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import type { + AutomationWorkspaceProvenance, + AutomationWorkspaceProvenanceRequest, + Repo +} from '../../shared/types' +import { + beginAutomationDispatchTokenUse, + finishAutomationDispatchTokenUse, + releaseAutomationDispatchTokenUse +} from './dispatch-tokens' + +export type AutomationWorkspaceProvenanceAuthority = { + showAutomation: (id: string) => Automation + listAutomationRuns: (automationId?: string) => AutomationRun[] +} + +export function invalidAutomationProvenanceRequest(): never { + throw new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path: ['automationProvenanceRequest'], + message: 'Invalid automation provenance request' + } + ]) +} + +function repoSelectorMatchesAutomation(selector: string, repoId: string): boolean { + return selector === repoId || selector === `id:${repoId}` +} + +export function resolveAutomationWorkspaceProvenance(args: { + authority: AutomationWorkspaceProvenanceAuthority + repoSelector: string + repo: Repo + request: AutomationWorkspaceProvenanceRequest | undefined +}): AutomationWorkspaceProvenance | undefined { + const { authority, repoSelector, repo, request } = args + if (!request) { + return undefined + } + + let automation: Automation + try { + automation = authority.showAutomation(request.automationId) + } catch { + invalidAutomationProvenanceRequest() + } + const run = authority + .listAutomationRuns(request.automationId) + .find((entry) => entry.id === request.automationRunId) + const expectedRepoId = run?.runContext?.repoId ?? getAutomationRunRepoId(automation) + + if ( + !run || + run.automationId !== automation.id || + run.status !== 'dispatching' || + run.workspaceId !== null || + automation.workspaceMode !== 'new_per_run' || + !repoSelectorMatchesAutomation(repoSelector, expectedRepoId) + ) { + invalidAutomationProvenanceRequest() + } + if ( + !beginAutomationDispatchTokenUse({ + automationId: request.automationId, + runId: request.automationRunId, + token: request.dispatchToken, + reservationId: request.createRequestId + }) + ) { + invalidAutomationProvenanceRequest() + } + + return buildAutomationWorkspaceProvenance(automation, run, repo) +} + +export function releaseAutomationWorkspaceProvenanceRequest( + request: AutomationWorkspaceProvenanceRequest | undefined +): void { + if (!request) { + return + } + releaseAutomationDispatchTokenUse({ + token: request.dispatchToken, + reservationId: request.createRequestId + }) +} + +export function finishAutomationWorkspaceProvenanceRequest( + request: AutomationWorkspaceProvenanceRequest | undefined +): void { + if (!request) { + return + } + finishAutomationDispatchTokenUse({ + token: request.dispatchToken, + reservationId: request.createRequestId + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index a96483bc819..54fccd05dfb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -112,6 +112,7 @@ import { initializeBrowserSessionsForApp } from './browser/browser-session-start import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' import { getCrashBreadcrumbSnapshot, @@ -163,15 +164,6 @@ 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 @@ -1360,15 +1352,11 @@ app.whenReady().then(async () => { 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' + ...buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run, + repo: target.repo + }) }) terminalHandle = created.startupTerminal?.handle ?? '' terminalSessionId = created.startupTerminal?.tabId ?? null diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index cdf28a239dd..0f3e6c6af13 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -354,7 +354,20 @@ describe('mergeWorktree', () => { sortOrder: 5, lastActivityAt: 1000, workspaceStatus: 'in-review', - diffComments: [] + diffComments: [], + automationProvenance: { + kind: 'created-by-automation' as const, + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh' as const, + executionTargetId: 'openclaw-2', + projectId: 'github:stablyai/orca', + repoId: 'repo1', + hostId: 'ssh:openclaw-2' as const + } } const result = mergeWorktree('repo1', baseGit, meta) expect(result).toEqual({ @@ -387,7 +400,20 @@ describe('mergeWorktree', () => { sortOrder: 5, lastActivityAt: 1000, workspaceStatus: 'in-review', - diffComments: [] + diffComments: [], + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh', + executionTargetId: 'openclaw-2', + projectId: 'github:stablyai/orca', + repoId: 'repo1', + hostId: 'ssh:openclaw-2' + } }) }) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index cf0d380ea2e..eb1f64d1a22 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,23 +1,15 @@ -import { basename, resolve, relative, isAbsolute, posix, sep, win32 } from 'path' -import type { - GitWorktreeInfo, - GlobalSettings, - OrcaWorkspaceLayout, - Repo, - Worktree, - WorktreeMeta -} from '../../shared/types' +import { resolve, relative, isAbsolute, posix, sep, win32 } from 'path' +import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' import { resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getWslHome, parseWslPath } from '../wsl' -import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' type WorktreePathSettings = Pick type WorktreeBasePathRepo = Pick export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name' +export { mergeWorktree } from './worktree-metadata-merge' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -265,71 +257,6 @@ export function shouldSetDisplayName( return !(branchName === requestedName && sanitizedName === requestedName) } -/** - * Merge raw git worktree info with persisted user metadata into a full Worktree. - */ -export function mergeWorktree( - repoId: string, - git: GitWorktreeInfo, - meta: WorktreeMeta | undefined, - defaultDisplayName?: string -): Worktree { - const branchShort = git.branch.replace(/^refs\/heads\//, '') - return { - 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, - isBare: git.isBare, - ...(git.isSparse === true ? { isSparse: true } : {}), - isMainWorktree: git.isMainWorktree, - displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), - comment: meta?.comment || '', - linkedIssue: meta?.linkedIssue ?? null, - linkedPR: meta?.linkedPR ?? null, - linkedLinearIssue: meta?.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null, - ...getLinkedWorkItemMetadata(meta), - isArchived: meta?.isArchived ?? false, - isUnread: meta?.isUnread ?? false, - isPinned: meta?.isPinned ?? false, - sortOrder: meta?.sortOrder ?? 0, - ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta?.lastActivityAt ?? 0, - ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta?.pendingFirstAgentMessageRename !== undefined - ? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename } - : {}), - ...(meta?.firstAgentMessageRenameError !== undefined - ? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError } - : {}), - ...(git.isSparse === true - ? { - sparseDirectories: meta?.sparseDirectories, - sparseBaseRef: meta?.sparseBaseRef, - sparsePresetId: meta?.sparsePresetId - } - : {}), - ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), - ...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}), - workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in - // shared/types) and forwarded verbatim so the renderer store mirrors - // on-disk state. `undefined` here means the worktree has no comments yet. - diffComments: meta?.diffComments, - mobileDiffReview: meta?.mobileDiffReview - } -} - /** * Parse a composite worktreeId ("repoId::worktreePath") into its parts. */ diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts new file mode 100644 index 00000000000..a64775db48a --- /dev/null +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -0,0 +1,71 @@ +import { basename } from 'path' +import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' +import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' + +/** + * Merge raw git worktree info with persisted user metadata into a full Worktree. + */ +export function mergeWorktree( + repoId: string, + git: GitWorktreeInfo, + meta: WorktreeMeta | undefined, + defaultDisplayName?: string +): Worktree { + const branchShort = git.branch.replace(/^refs\/heads\//, '') + return { + 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, + isBare: git.isBare, + ...(git.isSparse === true ? { isSparse: true } : {}), + isMainWorktree: git.isMainWorktree, + displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), + comment: meta?.comment || '', + linkedIssue: meta?.linkedIssue ?? null, + linkedPR: meta?.linkedPR ?? null, + linkedLinearIssue: meta?.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null, + ...getLinkedWorkItemMetadata(meta), + isArchived: meta?.isArchived ?? false, + isUnread: meta?.isUnread ?? false, + isPinned: meta?.isPinned ?? false, + sortOrder: meta?.sortOrder ?? 0, + ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta?.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta?.pendingFirstAgentMessageRename !== undefined + ? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename } + : {}), + ...(meta?.firstAgentMessageRenameError !== undefined + ? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError } + : {}), + ...(git.isSparse === true + ? { + sparseDirectories: meta?.sparseDirectories, + sparseBaseRef: meta?.sparseBaseRef, + sparsePresetId: meta?.sparsePresetId + } + : {}), + ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), + ...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}), + workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + // Why: diff comments are persisted on WorktreeMeta and forwarded verbatim + // so the renderer store mirrors on-disk state. + diffComments: meta?.diffComments, + mobileDiffReview: meta?.mobileDiffReview + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 9a6f39dbe54..216465caff5 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -13,6 +13,7 @@ import { existsSync } from 'fs' import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { + AutomationWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, GitPushTarget, @@ -56,6 +57,10 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' + +type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance +} import { sanitizeWorktreeName, sanitizeWorktreeDisplayName, @@ -1341,7 +1346,7 @@ export function emitCreateWorktreeProgress( } export async function createRemoteWorktree( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, mainWindow: BrowserWindow @@ -1642,6 +1647,7 @@ export async function createRemoteWorktree( orcaCreatedAt: now, orcaCreationSource: 'ssh', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), @@ -1749,7 +1755,7 @@ export async function createRemoteWorktree( } export async function createLocalWorktree( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, mainWindow: BrowserWindow, @@ -2238,6 +2244,7 @@ export async function createLocalWorktree( orcaCreatedAt: now, orcaCreationSource: 'desktop', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8e177b23e0c..3f342ef26ba 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -669,6 +669,41 @@ describe('registerWorktreeHandlers', () => { expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('does not trust renderer-authored automation provenance during local create', async () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Forged', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Forged run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1' + } + }) + + const persistedMeta = store.setWorktreeMeta.mock.calls.find( + ([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard' + )?.[1] + expect(persistedMeta).toBeDefined() + expect(persistedMeta).not.toHaveProperty('automationProvenance') + }) + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index cf99b23e1a4..bceaa86f444 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -14,6 +14,7 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { + AutomationWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, DetectedWorktree, @@ -89,6 +90,15 @@ import { removeWorktreeLinkedPaths } from './worktree-symlinks' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' +import { + finishAutomationWorkspaceProvenanceRequest, + releaseAutomationWorkspaceProvenanceRequest, + resolveAutomationWorkspaceProvenance +} from '../automations/workspace-provenance' + +type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance +} import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { @@ -627,6 +637,9 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta lastActivityAt: meta.lastActivityAt ?? 0, ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, mobileDiffReview: meta.mobileDiffReview @@ -698,7 +711,7 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { } function createFolderWorkspace( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store ): CreateWorktreeResult { @@ -715,6 +728,7 @@ function createFolderWorkspace( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), @@ -1022,6 +1036,17 @@ export function registerWorktreeHandlers( const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } + let result: CreateWorktreeResult try { // Why: only wrap the helpers themselves. The pre-validation throws @@ -1030,11 +1055,12 @@ export function registerWorktreeHandlers( // git/filesystem failures the funnel cares about — bucketing them // into `unknown` would pollute the failure taxonomy. result = isFolderRepo(repo) - ? createFolderWorkspace(args, repo, store) + ? createFolderWorkspace(createArgs, repo, store) : repo.connectionId - ? await createRemoteWorktree(args, repo, store, mainWindow) - : await createLocalWorktree(args, repo, store, mainWindow, runtime) + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) track('workspace_create_failed', { source, error_class: classifyWorkspaceCreateError(error), @@ -1042,6 +1068,7 @@ export function registerWorktreeHandlers( }) throw error } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) // Why: emit `workspace_created` only after the underlying create has // resolved (the helpers throw on failure, so reaching this line means diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index e800bd373ff..7decc4fa328 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5084,6 +5084,7 @@ describe('Store', () => { 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents' @@ -5228,6 +5229,7 @@ describe('Store', () => { 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 512d642f310..57a5928e702 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -42,6 +42,7 @@ import type { AutomationWorkspaceMode } from '../../shared/automations-types' import type { + AutomationWorkspaceProvenance, BaseRefSearchResult, CreateWorktreeResult, DetectedWorktree, @@ -1183,6 +1184,9 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt lastActivityAt: meta.lastActivityAt ?? 0, ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, mobileDiffReview: meta.mobileDiffReview @@ -10310,6 +10314,7 @@ export class OrcaRuntimeService { startupAgent?: TuiAgent startupPrompt?: string pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -10369,6 +10374,7 @@ export class OrcaRuntimeService { path: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -10888,6 +10894,7 @@ export class OrcaRuntimeService { ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent ? { pendingFirstAgentMessageRename: true } : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) @@ -11167,6 +11174,7 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup startupDraftPaste?: WorktreeStartupDraftPaste @@ -11217,7 +11225,8 @@ export class OrcaRuntimeService { ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true ? { pendingFirstAgentMessageRename: true } - : {}) + : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}) }, repo, this.store as unknown as Store, diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 1d7d144df3f..fa4a022d00a 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -133,6 +133,7 @@ describe('remote runtime request connection integration', () => { clientEventListeners.add(listener) return () => clientEventListeners.delete(listener) }, + showRepo: () => repo, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, @@ -347,6 +348,7 @@ describe('remote runtime request connection integration', () => { }, watchFileExplorer: async () => () => {}, listRepos: () => [repo], + showRepo: () => repo, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 6805908dd9a..bdb35cd2cc7 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -134,6 +134,7 @@ describe('client UI RPC methods', () => { rightSidebarTab: 'checks', rightSidebarExplorerView: 'search', showActiveOnly: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] } const runtime = { @@ -149,6 +150,7 @@ describe('client UI RPC methods', () => { rightSidebarExplorerView: 'search', showActiveOnly: true, hideSleepingWorkspaces: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] }) ) @@ -159,6 +161,7 @@ describe('client UI RPC methods', () => { rightSidebarExplorerView: 'search', showActiveOnly: true, hideSleepingWorkspaces: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] }) expect(response).toMatchObject({ ok: true, result: { ui: updated } }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 04318f51c39..d7018abb040 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -169,6 +169,7 @@ const UiUpdate = z visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), workspaceHostOrder: z.array(z.string()).optional(), hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index f7c8d91785a..c0e5e4fc83d 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -20,6 +20,13 @@ const OptionalTuiAgent = z .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) .optional() +const AutomationWorkspaceProvenanceRequest = z.object({ + automationId: z.string(), + automationRunId: z.string(), + dispatchToken: z.string(), + createRequestId: z.string() +}) + export const WorktreeListParams = z.object({ repo: OptionalString, limit: OptionalFiniteNumber @@ -129,7 +136,8 @@ export const WorktreeCreate = z createdWithAgent: z .unknown() .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional() + .optional(), + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional() }) .superRefine((params, ctx) => { if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 4b04f89535a..6defbe29579 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -3,6 +3,17 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { WORKTREE_METHODS } from './worktree' +import { createAutomationDispatchToken } from '../../../automations/dispatch-tokens' + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const, + executionHostId: 'ssh:ssh-target-1' as const +} function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -12,6 +23,7 @@ describe('worktree RPC methods', () => { it('routes create options to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -22,6 +34,7 @@ describe('worktree RPC methods', () => { name: 'feature', branchNameOverride: 'feature/something', baseBranch: 'origin/main', + compareBaseRef: undefined, setupDecision: 'skip', displayName: 'Feature title', telemetrySource: 'sidebar', @@ -49,6 +62,9 @@ describe('worktree RPC methods', () => { linkedLinearIssueOrganizationUrlKey: undefined, linkedGitLabIssue: 789, linkedGitLabMR: 321, + linkedBitbucketPR: undefined, + linkedAzureDevOpsPR: undefined, + linkedGiteaPR: undefined, comment: undefined, displayName: 'Feature title', telemetrySource: 'sidebar', @@ -60,6 +76,7 @@ describe('worktree RPC methods', () => { activate: false, setupDecision: 'skip', createdWithAgent: undefined, + automationProvenance: undefined, startup: undefined, startupDraft: undefined, lineage: { @@ -71,9 +88,324 @@ describe('worktree RPC methods', () => { }) }) + it('mints automation provenance from a valid dispatch request on worktree creation', async () => { + const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-1', + name: 'Nightly review', + projectId: 'legacy-repo-1', + runContext: { + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-1', + automationId: 'automation-1', + title: 'Nightly review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken, + createRequestId: 'create-request-1' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + const replay = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace-replay', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken, + createRequestId: 'create-request-1' + } + }) + ) + + expect(replay).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo-1', + name: 'automation-workspace', + automationProvenance: expect.objectContaining({ + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + }) + }) + ) + }) + + it('stamps automation provenance with the persisted runtime host from run context', async () => { + const dispatchToken = createAutomationDispatchToken('automation-runtime', 'run-runtime') + const runtimeLocalRepo = { + id: 'repo-runtime', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const + } + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(runtimeLocalRepo), + showAutomation: vi.fn(() => ({ + id: 'automation-runtime', + name: 'Runtime review', + projectId: 'legacy-repo-runtime', + runContext: { + projectId: 'project-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:owner-runtime' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'runtime', + executionTargetId: 'owner-runtime' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-runtime', + automationId: 'automation-runtime', + title: 'Runtime review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-runtime' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-runtime', + name: 'runtime-automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-runtime', + automationRunId: 'run-runtime', + dispatchToken, + createRequestId: 'create-request-runtime' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + automationProvenance: expect.objectContaining({ + automationId: 'automation-runtime', + automationRunId: 'run-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:owner-runtime' + }) + }) + ) + }) + + it('validates and stamps automation provenance from the dispatching run snapshot', async () => { + const dispatchToken = createAutomationDispatchToken('automation-edited', 'run-edited') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-edited', + name: 'Edited review', + projectId: 'legacy-repo-edited', + runContext: { + projectId: 'project-after-edit', + repoId: 'repo-after-edit', + hostId: 'runtime:after-edit' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'runtime', + executionTargetId: 'after-edit' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-edited', + automationId: 'automation-edited', + title: 'Pre-edit run', + runContext: { + projectId: 'project-before-edit', + repoId: 'repo-1', + hostId: 'runtime:before-edit' + }, + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-edited' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'edited-automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-edited', + automationRunId: 'run-edited', + dispatchToken, + createRequestId: 'create-request-edited' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + automationProvenance: expect.objectContaining({ + automationId: 'automation-edited', + automationRunId: 'run-edited', + projectId: 'project-before-edit', + repoId: 'repo-1', + hostId: 'runtime:before-edit' + }) + }) + ) + }) + + it('allows the same automation provenance request to retry after a failed create attempt', async () => { + const dispatchToken = createAutomationDispatchToken('automation-retry', 'run-retry') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-retry', + name: 'Nightly retry', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-retry', + automationId: 'automation-retry', + title: 'Nightly retry run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi + .fn() + .mockRejectedValueOnce(new Error('Branch "automation-workspace" already exists.')) + .mockResolvedValueOnce({ worktree: { id: 'wt-retry' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const automationProvenanceRequest = { + automationId: 'automation-retry', + automationRunId: 'run-retry', + dispatchToken, + createRequestId: 'create-request-retry' + } + + const firstResponse = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace', + automationProvenanceRequest + }) + ) + const retryResponse = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace-2', + automationProvenanceRequest + }) + ) + + expect(firstResponse).toMatchObject({ ok: false }) + expect(retryResponse).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(2) + expect(runtime.createManagedWorktree).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'automation-workspace-2', + automationProvenance: expect.objectContaining({ + automationId: 'automation-retry', + automationRunId: 'run-retry' + }) + }) + ) + }) + + it('rejects forged automation provenance requests on worktree creation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-1', + name: 'Nightly review', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + executionTargetType: 'local', + executionTargetId: 'local' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-1', + automationId: 'automation-1', + title: 'Nightly review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'manual-workspace', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken: 'forged-token', + createRequestId: 'create-request-forged' + } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) + it('forwards startup command and env to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -106,6 +438,7 @@ describe('worktree RPC methods', () => { it('forwards task startup drafts to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -156,6 +489,7 @@ describe('worktree RPC methods', () => { it('maps unknown telemetry sources to the runtime default instead of rejecting create', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index a707b539e7e..9475886f34b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -1,3 +1,8 @@ +import { + finishAutomationWorkspaceProvenanceRequest, + releaseAutomationWorkspaceProvenanceRequest, + resolveAutomationWorkspaceProvenance +} from '../../../automations/workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { WorktreeCreate, @@ -58,56 +63,74 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => - runtime.createManagedWorktree({ + handler: async (params, { runtime }) => { + const repo = await runtime.showRepo(params.repo) + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, repoSelector: params.repo, - name: params.name ?? '', - baseBranch: params.baseBranch, - compareBaseRef: params.compareBaseRef, - branchNameOverride: params.branchNameOverride, - linkedIssue: params.linkedIssue, - linkedPR: params.linkedPR, - linkedLinearIssue: params.linkedLinearIssue, - linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, - linkedGitLabMR: params.linkedGitLabMR, - linkedGitLabIssue: params.linkedGitLabIssue, - linkedBitbucketPR: params.linkedBitbucketPR, - linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, - linkedGiteaPR: params.linkedGiteaPR, - comment: params.comment, - displayName: params.displayName, - telemetrySource: params.telemetrySource, - workspaceStatus: params.workspaceStatus, - manualOrder: params.manualOrder, - sparseCheckout: params.sparseCheckout, - pushTarget: params.pushTarget, - runHooks: params.runHooks === true, - activate: params.activate === true, - setupDecision: params.setupDecision, - createdWithAgent: params.createdWithAgent ?? params.startupAgent, - startup: params.startupCommand - ? { - command: params.startupCommand, - ...(params.startupEnv ? { env: params.startupEnv } : {}), - ...(params.startupCommandDelivery - ? { startupCommandDelivery: params.startupCommandDelivery } - : {}) - } - : undefined, - ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), - ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), - startupDraft: params.startupDraft, - lineage: { - parentWorkspace: params.parentWorkspace, - envParentWorkspace: params.envParentWorkspace, - parentWorktree: params.parentWorktree, - ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), - noParent: params.noParent === true, - callerTerminalHandle: params.callerTerminalHandle, - orchestrationContext: params.orchestrationContext - } + repo, + request: params.automationProvenanceRequest }) + // Why: provenance tokens are reserved before creation so retries can recover, + // but failed create attempts must release the reservation for a safe retry. + try { + const result = await runtime.createManagedWorktree({ + repoSelector: params.repo, + name: params.name ?? '', + baseBranch: params.baseBranch, + compareBaseRef: params.compareBaseRef, + branchNameOverride: params.branchNameOverride, + linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, + linkedLinearIssue: params.linkedLinearIssue, + linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, + linkedGitLabMR: params.linkedGitLabMR, + linkedGitLabIssue: params.linkedGitLabIssue, + linkedBitbucketPR: params.linkedBitbucketPR, + linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, + linkedGiteaPR: params.linkedGiteaPR, + comment: params.comment, + displayName: params.displayName, + telemetrySource: params.telemetrySource, + workspaceStatus: params.workspaceStatus, + manualOrder: params.manualOrder, + sparseCheckout: params.sparseCheckout, + pushTarget: params.pushTarget, + runHooks: params.runHooks === true, + activate: params.activate === true, + setupDecision: params.setupDecision, + createdWithAgent: params.createdWithAgent ?? params.startupAgent, + automationProvenance, + startup: params.startupCommand + ? { + command: params.startupCommand, + ...(params.startupEnv ? { env: params.startupEnv } : {}), + ...(params.startupCommandDelivery + ? { startupCommandDelivery: params.startupCommandDelivery } + : {}) + } + : undefined, + ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), + ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + startupDraft: params.startupDraft, + lineage: { + parentWorkspace: params.parentWorkspace, + envParentWorkspace: params.envParentWorkspace, + parentWorktree: params.parentWorktree, + ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } + }) + finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + return result + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + throw error + } + } }), defineMethod({ name: 'worktree.prefetchCreateBase', diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index 551edec5376..acb8d2f3f3c 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -93,7 +93,18 @@ describe('stripOrcaProvenanceMetaUpdates', () => { comment: 'keep me', orcaCreatedAt: 123, orcaCreationSource: 'desktop', - orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false } + orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false }, + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1' + } }) ).toEqual({ comment: 'keep me' }) }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a798b219e46..33fbe86fe05 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -22,7 +22,8 @@ const ORCA_CREATION_SOURCES = new Set s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) @@ -1247,6 +1248,7 @@ function App(): React.JSX.Element { hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, // Why: rides the same debounced save so dashboard auto-acks (which fire @@ -1272,6 +1274,7 @@ function App(): React.JSX.Element { projectOrderBy, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index f4c2f3dbc8b..6dde3b4379f 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -17,7 +17,10 @@ import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/gith import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' -import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' +import { + isAutomationGeneratedWorkspace, + isDefaultBranchWorkspace +} from '@/components/sidebar/visible-worktrees' import { isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import StatusIndicator from '@/components/sidebar/StatusIndicator' @@ -321,6 +324,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) @@ -393,6 +397,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) { return false } + if (hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(worktree)) { + return false + } if ( !showSleepingWorkspaces && isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree) @@ -404,6 +411,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [ allWorktrees, browserTabsByWorktree, + hideAutomationGeneratedWorkspaces, hideDefaultBranchWorkspace, ptyIdsByTabId, showSleepingWorkspaces, diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 720f8e2133e..a3a4ced4cbf 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -116,7 +116,10 @@ import { import { createAutomationForTarget, deleteAutomationForTarget, + type AutomationHostTarget, getAutomationListTarget, + getAutomationOwnerTarget, + getAutomationTargetFromHostId, listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, @@ -153,6 +156,10 @@ type SelectedExternalRunPage = { run: ExternalAutomationRun } +function getAutomationHostTargetKey(target: AutomationHostTarget): string { + return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' +} + function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } @@ -353,6 +360,8 @@ export default function AutomationsPage(): React.JSX.Element { ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) + const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation) + const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) @@ -365,6 +374,7 @@ export default function AutomationsPage(): React.JSX.Element { const [automations, setAutomations] = useState([]) const [runs, setRuns] = useState([]) + const [automationHostTargetKey, setAutomationHostTargetKey] = useState(null) const [selectedAutomationRuns, setSelectedAutomationRuns] = useState<{ automationId: string | null runs: AutomationRun[] @@ -478,7 +488,9 @@ export default function AutomationsPage(): React.JSX.Element { (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) const selected = selectedExternal === null - ? (automations.find((automation) => automation.id === selectedId) ?? automations[0] ?? null) + ? selectedId + ? (automations.find((automation) => automation.id === selectedId) ?? null) + : (automations[0] ?? null) : null const runsWithWorkspaceNames = useMemo( () => @@ -515,9 +527,10 @@ export default function AutomationsPage(): React.JSX.Element { selected && selectedAutomationRuns.automationId === selected.id ? selectedAutomationRunsWithWorkspaceNames : runsWithWorkspaceNames - const selectedRuns = selected - ? selectedRunsSource.filter((run) => run.automationId === selected.id) - : [] + const selectedRuns = useMemo( + () => (selected ? selectedRunsSource.filter((run) => run.automationId === selected.id) : []), + [selected, selectedRunsSource] + ) const selectedAutomationRunPage = selectedAutomationRunPageId ? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null) : null @@ -534,6 +547,72 @@ export default function AutomationsPage(): React.JSX.Element { } } }, [worktreeMap]) + useEffect(() => { + if (!pendingAutomationRunNavigation || isLoading) { + return + } + const pending = pendingAutomationRunNavigation + const pendingTargetKey = getAutomationHostTargetKey( + getAutomationTargetFromHostId(pending.hostId) + ) + if (automationHostTargetKey !== pendingTargetKey) { + return + } + const pendingAutomation = automations.find( + (automation) => automation.id === pending.automationId + ) + if (!pendingAutomation) { + // Why: stale provenance should not silently select the first automation. + setSelectedId(pending.automationId) + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + toast.message( + translate( + 'auto.components.automations.AutomationsPage.pendingAutomationMissing', + 'Automation no longer available.' + ) + ) + return + } + if (selectedId !== pending.automationId) { + setSelectedId(pending.automationId) + return + } + if (!pending.runId) { + setActivePaneTab('overview') + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + return + } + if (selectedAutomationRuns.automationId !== pending.automationId) { + return + } + setActivePaneTab('runs') + const pendingRun = selectedRuns.find((run) => run.id === pending.runId) + if (pendingRun) { + setSelectedAutomationRunPageId(pending.runId) + setPendingAutomationRunNavigation(null) + return + } + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + toast.message( + translate( + 'auto.components.automations.AutomationsPage.pendingAutomationRunMissing', + 'Run history no longer available.' + ) + ) + }, [ + automations, + automationHostTargetKey, + isLoading, + pendingAutomationRunNavigation, + selectedAutomationRuns.automationId, + selectedId, + selectedRuns, + setPendingAutomationRunNavigation, + setSelectedId + ]) const activeTerminalTabIds = useMemo(() => { const ids = new Set() for (const tabs of Object.values(unifiedTabsByWorktree)) { @@ -790,7 +869,10 @@ export default function AutomationsPage(): React.JSX.Element { const refresh = useCallback(async () => { setIsLoading(true) - const automationHostTarget = getAutomationListTarget(settings) + const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation + const automationHostTarget = pendingNavigation + ? getAutomationTargetFromHostId(pendingNavigation.hostId) + : getAutomationListTarget(settings) try { const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ listAutomationsForTarget(automationHostTarget), @@ -801,20 +883,26 @@ export default function AutomationsPage(): React.JSX.Element { const hasCurrentSelection = nextAutomations.some( (automation) => automation.id === currentSelectedId ) - const nextSelectedId = hasCurrentSelection - ? currentSelectedId - : (nextAutomations[0]?.id ?? null) + let nextSelectedId: string | null + if (hasCurrentSelection) { + nextSelectedId = currentSelectedId + } else if (pendingNavigation) { + nextSelectedId = pendingNavigation.automationId + } else { + nextSelectedId = nextAutomations[0]?.id ?? null + } const nextSelectedRuns = nextSelectedId ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) : [] setAutomations(nextAutomations) setRuns(nextRuns) + setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) setSelectedAutomationRuns({ automationId: nextSelectedId, runs: nextSelectedRuns }) setExternalManagers(nextExternalManagers) - if (!hasCurrentSelection) { + if (!hasCurrentSelection && !pendingNavigation) { selectAutomationId(nextAutomations[0]?.id ?? null) } } finally { @@ -822,6 +910,18 @@ export default function AutomationsPage(): React.JSX.Element { } }, [selectAutomationId, settings]) + useEffect(() => { + if (!pendingAutomationRunNavigation || isLoading) { + return + } + const pendingTargetKey = getAutomationHostTargetKey( + getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + ) + if (automationHostTargetKey !== pendingTargetKey) { + void refresh() + } + }, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh]) + const hydratePersistedUIState = useCallback(async (): Promise => { useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) }, []) @@ -843,17 +943,22 @@ export default function AutomationsPage(): React.JSX.Element { return } let cancelled = false - void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then( - (nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) - } + const target = + pendingAutomationRunNavigation?.automationId === automationId && + pendingAutomationRunNavigation.hostId + ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + : selected + ? getAutomationOwnerTarget(selected) + : getAutomationListTarget(settings) + void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { + if (!cancelled) { + setSelectedAutomationRuns({ automationId, runs: nextRuns }) } - ) + }) return () => { cancelled = true } - }, [selected?.id, runs, settings]) + }, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index cee3bf6f613..a657c88ad8f 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -22,9 +22,13 @@ type RuntimeAutomationUpdateInput = Omit ): AutomationHostTarget { - return getRuntimeTargetFromHostId(automation.runContext?.hostId) + return getAutomationTargetFromHostId(automation.runContext?.hostId) } export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { - return getRuntimeTargetFromHostId(input.runContext?.hostId) + return getAutomationTargetFromHostId(input.runContext?.hostId) } function toRuntimeAutomationCreateInput( diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index da811a736a7..7d21c357afd 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server } from 'lucide-react' +import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { @@ -39,6 +39,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -85,9 +89,16 @@ const SidebarFilter = React.memo(function SidebarFilter({ const selectedCount = selectedRepoIdSet.size const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES - const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter + const hasAnyFilter = + hasSleepingFilter || + hideDefaultBranchWorkspace || + hideAutomationGeneratedWorkspaces || + hasRepoFilter const activeFilterCount = - (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + + (hideDefaultBranchWorkspace ? 1 : 0) + + (hideAutomationGeneratedWorkspaces ? 1 : 0) + + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) const commandValue = @@ -99,8 +110,14 @@ const SidebarFilter = React.memo(function SidebarFilter({ const clearAll = useCallback(() => { setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) setHideDefaultBranchWorkspace(false) + setHideAutomationGeneratedWorkspaces(false) setFilterRepoIds([]) - }, [setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setFilterRepoIds]) + }, [ + setShowSleepingWorkspaces, + setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, + setFilterRepoIds + ]) // Why: derive ids from the live repos list at click time so a repo added // while the popover is open is included immediately. @@ -176,6 +193,15 @@ const SidebarFilter = React.memo(function SidebarFilter({ checked={hideDefaultBranchWorkspace} onChange={setHideDefaultBranchWorkspace} /> + } + label={translate( + 'auto.components.sidebar.SidebarFilter.automationCreated', + 'Hide automation-created' + )} + checked={hideAutomationGeneratedWorkspaces} + onChange={setHideAutomationGeneratedWorkspaces} + /> {canFilterRepos && ( <> diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 8a2a492cddc..cd0b5d8434f 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { GitBranch, Moon } from 'lucide-react' +import { GitBranch, Moon, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -9,6 +9,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) return ( <> @@ -35,6 +39,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={hideDefaultBranchWorkspace} onChange={setHideDefaultBranchWorkspace} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated', + 'Hide automation-created' + )} + checked={hideAutomationGeneratedWorkspaces} + onChange={setHideAutomationGeneratedWorkspaces} + /> ) }) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 111c8f0778e..fcfbfb9ed95 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -37,6 +37,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM }: SidebarWorkspaceOptionsMenuProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -76,10 +77,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null const hasAnyFilter = - hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter + hasSleepingFilter || + hideDefaultBranchWorkspace || + hideAutomationGeneratedWorkspaces || + hasRepoFilter || + hasHostVisibilityFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + + (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` 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 4741c5193ff..f3866e2577e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -335,6 +335,98 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('Reviewer handoff note') }) + it('shows automation-created workspaces as a metadata icon property', async () => { + worktreeCardProperties = ['status', 'automation'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('Created by automation') + expect(markup).not.toContain('>Automation') + }) + + it('shows the automation metadata icon in compact card mode', async () => { + settings = { compactWorktreeCards: true } + worktreeCardProperties = ['status', 'automation'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('Created by automation') + expect(markup).not.toContain('>Automation') + }) + + it('hides automation-created card surfaces when the Automation property is disabled', async () => { + worktreeCardProperties = ['status'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).not.toContain('Created by automation') + expect(markup).not.toContain('>Automation') + expect(markup).not.toContain('Nightly triage automation') + }) + it('hides live port metadata when the Ports card property is disabled', async () => { const worktree = makeWorktree() workspacePortScan = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 28578ae3400..454367f3b1c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -205,6 +205,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) + const openAutomationsPage = useAppStore((s) => s.openAutomationsPage) + const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) @@ -248,6 +250,53 @@ const WorktreeCard = React.memo(function WorktreeCard({ [worktree, openModal] ) + const handleOpenAutomation = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const automationId = worktree.automationProvenance?.automationId + if (!automationId) { + return + } + const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId, + runId: null, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance?.automationId, + worktree.automationProvenance?.hostId, + worktree.hostId + ] + ) + + const handleOpenAutomationRun = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const provenance = worktree.automationProvenance + if (!provenance) { + return + } + const hostId = provenance.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId: provenance.automationId, + runId: provenance.automationRunId, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance, + worktree.hostId + ] + ) + const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id]) const remoteBranchConflict = useAppStore((s) => s.remoteBranchConflictByWorktreeId[worktree.id]) @@ -442,6 +491,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showIssue = cardProps.includes('issue') const showLinearIssue = cardProps.includes('linear-issue') const showPR = cardProps.includes('pr') + const showAutomation = cardProps.includes('automation') const showComment = cardProps.includes('comment') const showPorts = cardProps.includes('ports') const shouldRefreshHostedReview = newCardStyle ? showStatus : showPR @@ -869,6 +919,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const metaIssue = showIssue ? hoverIssue : null const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null const metaReview = showPR ? hoverReview : null + const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null const metaComment = showComment ? hoverComment : null const handleOpenGitHubIssueInOrca = useCallback( (e: React.MouseEvent) => { @@ -958,7 +1009,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issue: metaIssue, linearIssue: metaLinearIssue, review: newCardStyle ? null : metaReview, - comment: metaComment + comment: metaComment, + automationProvenance: metaAutomationProvenance }) const hasPorts = showPorts && workspacePorts.length > 0 const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id) @@ -986,7 +1038,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showCombinedStatusSlot = showStatus const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) - const showTitleRowIndicators = newCardStyle && (hasDetails || hasPorts) + const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) // Why: detailed cards need a stable metadata lane only when it has content. // Grouped project views can hide the repo badge; don't reserve a blank // metadata lane unless branch or detached-head identity has content. @@ -1014,7 +1066,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issue: hoverIssue, linearIssue: hoverLinearIssue, review: hoverReview, - comment: hoverComment + comment: hoverComment, + automationProvenance: metaAutomationProvenance }) || workspacePorts.length > 0 || showBranchIdentityHover) @@ -1031,6 +1084,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={worktree.displayName} identityOrder="branch-first" @@ -1050,6 +1105,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: compact mode hides the metadata badge row, so title hover // carries the same explicit-link affordance without adding chrome. onUnlinkReview={ @@ -1078,6 +1135,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={newCardStyle ? null : metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} className="ml-0 pr-0" /> )} @@ -1090,6 +1148,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} detailsAfter={hasPorts ? : null} hoverControl={detailsHoverControl} onEditIssue={affiliateListMode ? undefined : handleEditIssue} @@ -1101,6 +1161,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ onOpenReviewInOrca={ metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: branch lookup can show a review without persisted metadata. Only // expose unlink when this workspace has an explicit linked PR/MR. onUnlinkReview={ @@ -1542,6 +1604,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={hoverLinearIssue} review={hoverReview} comment={hoverComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={showBranchIdentityHover ? visibleCardTitle : undefined} detailsAfter={ @@ -1560,6 +1624,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ onOpenReviewInOrca={ hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: branch lookup can show a review without persisted metadata. Only // expose unlink when this workspace has an explicit linked PR/MR. onUnlinkReview={ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx new file mode 100644 index 00000000000..7d8ebd2de99 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import { CalendarClock, PlayCircle } from 'lucide-react' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AutomationWorkspaceProvenance } from '../../../../shared/types' +import { + WorktreeCardDetailSection, + WorktreeCardDetailSectionContent +} from './WorktreeCardDetailSection' +import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { translate } from '@/i18n/i18n' +import { + getAutomationTargetFromHostId, + listAutomationRunsForTarget, + listAutomationsForTarget +} from '@/components/automations/automation-host-client' + +type WorktreeCardAutomationDetailSectionProps = { + provenance: AutomationWorkspaceProvenance + worktreeHostId?: ExecutionHostId + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void +} + +type AutomationProvenanceAvailability = + | { status: 'checking' } + | { status: 'available'; runAvailable: boolean } + | { status: 'automation-missing' } + | { status: 'unavailable' } + +export function WorktreeCardAutomationDetailSection({ + provenance, + worktreeHostId, + onOpenAutomation, + onOpenAutomationRun +}: WorktreeCardAutomationDetailSectionProps): React.JSX.Element { + const [availability, setAvailability] = React.useState({ + status: 'checking' + }) + + React.useEffect(() => { + let cancelled = false + async function resolveAvailability(): Promise { + setAvailability({ status: 'checking' }) + try { + const target = getAutomationTargetFromHostId(provenance.hostId ?? worktreeHostId) + const automations = await listAutomationsForTarget(target) + const automation = automations.find((entry) => entry.id === provenance.automationId) + if (!automation) { + if (!cancelled) { + setAvailability({ status: 'automation-missing' }) + } + return + } + const runs = await listAutomationRunsForTarget(target, provenance.automationId) + if (!cancelled) { + setAvailability({ + status: 'available', + runAvailable: runs.some((run) => run.id === provenance.automationRunId) + }) + } + } catch { + if (!cancelled) { + setAvailability({ status: 'unavailable' }) + } + } + } + + void resolveAvailability() + return () => { + cancelled = true + } + }, [provenance.automationId, provenance.automationRunId, provenance.hostId, worktreeHostId]) + + const canOpenAutomation = availability.status === 'available' + const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable + + return ( + + } + label={translate('auto.components.sidebar.WorktreeCardMeta.automationHeader', 'Automation')} + actions={ + <> + {onOpenAutomation && canOpenAutomation && ( + + + + )} + {onOpenAutomationRun && canOpenAutomationRun && ( + + + + )} + + } + /> + +
+ {provenance.automationNameSnapshot} +
+
+ {provenance.automationRunTitleSnapshot} +
+ {availability.status === 'checking' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.checkingAutomationAvailability', + 'Checking automation availability...' + )} +
+ ) : null} + {availability.status === 'automation-missing' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationMissing', + 'Automation no longer available.' + )} +
+ ) : null} + {availability.status === 'available' && !availability.runAvailable ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationRunMissing', + 'Run history no longer available.' + )} +
+ ) : null} + {availability.status === 'unavailable' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationAvailabilityUnavailable', + 'Automation availability could not be checked.' + )} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx index e5e51b84c68..87a265d3159 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx @@ -14,9 +14,9 @@ import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../.. import { AGENT_ACTIVITY_DISPLAY_OPTIONS, CARD_LAYOUT_OPTIONS, - PROPERTY_OPTIONS, getWorktreeCardPropertyOptions } from './sidebar-workspace-option-items' +import { PROPERTY_OPTIONS } from './worktree-card-display-property-options' import { translate } from '@/i18n/i18n' type WorktreeCardDisplayMenuSectionProps = { diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index a63a2619701..1c5fdf9a155 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' -import { CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -24,6 +24,7 @@ import type { } from './worktree-card-meta-types' import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' +import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' export type { WorktreeCardIssueDisplay, @@ -41,19 +42,20 @@ export function hasWorktreeCardDetails({ issue, linearIssue, review, - comment + comment, + automationProvenance }: WorktreeCardMetaBadgesProps): boolean { - return Boolean(issue || linearIssue || review || hasComment(comment)) + return Boolean(issue || linearIssue || review || hasComment(comment) || automationProvenance) } export const WorktreeCardMetaBadges = React.forwardRef< HTMLDivElement, WorktreeCardMetaBadgesRootProps >(function WorktreeCardMetaBadges( - { issue, linearIssue, review, comment, className, ...props }, + { issue, linearIssue, review, comment, automationProvenance, className, ...props }, ref ): React.JSX.Element | null { - if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) { + if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance })) { return null } @@ -79,6 +81,16 @@ export const WorktreeCardMetaBadges = React.forwardRef< )} + {automationProvenance && ( + + + + )} {issue && ( + {automationProvenance && ( + + )} + {hasComment(comment) && ( s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -4726,6 +4727,7 @@ const WorktreeList = React.memo(function WorktreeList({ ptyIdsByTabId, browserTabsByWorktree, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -4748,6 +4750,7 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -5582,13 +5585,23 @@ const WorktreeList = React.memo(function WorktreeList({ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, visibleWorkspaceHostIds }), - [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, visibleWorkspaceHostIds] + [ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + visibleWorkspaceHostIds + ] ) const hasFilters = sidebarHasActiveFilters(filterState) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -5603,6 +5616,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDefaultBranchWorkspace) { setHideDefaultBranchWorkspace(false) } + if (actions.resetHideAutomationGeneratedWorkspaces) { + setHideAutomationGeneratedWorkspaces(false) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -5610,6 +5626,7 @@ const WorktreeList = React.memo(function WorktreeList({ setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts index 058cb64b237..e43f8b9d7ca 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts @@ -11,10 +11,13 @@ describe('worktree card property options', () => { expect(WORKTREE_CARD_PROPERTY_OPTIONS).toEqual(options) expect(options.map((option) => option.id)).toContain('tasks') + expect(options.map((option) => option.id)).toContain('automation') expect(options.find((option) => option.id === 'tasks')?.properties).toEqual( TASK_WORKTREE_CARD_PROPERTIES ) + expect(options.find((option) => option.id === 'automation')?.properties).toEqual(['automation']) expect(options.map((option) => option.label)).toContain('Tasks') + expect(options.map((option) => option.label)).toContain('Automation') expect(options.map((option) => option.label)).not.toContain('GitHub issues') expect(options.map((option) => option.label)).not.toContain('Linear issues') }) @@ -28,7 +31,9 @@ describe('worktree card property options', () => { expect(options.find((option) => option.id === 'linear-issue')?.properties).toEqual([ 'linear-issue' ]) + expect(options.find((option) => option.id === 'automation')?.properties).toEqual(['automation']) expect(options.map((option) => option.label)).toContain('GitHub issues') expect(options.map((option) => option.label)).toContain('Linear issues') + expect(options.map((option) => option.label)).toContain('Automation') }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 3ceeee44481..1772ea4c14d 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -44,57 +44,6 @@ export const CARD_LAYOUT_OPTIONS = [ } ] 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 @@ -137,6 +86,16 @@ const BASE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [ return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.8d62c68b35', 'Notes') } }, + { + id: 'automation', + properties: ['automation'], + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.automation', + 'Automation' + ) + } + }, { id: 'ports', properties: ['ports'], 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 5ce06c51ad2..03c3dac373d 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 @@ -16,6 +16,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const settings = useAppStore((s) => s.settings) @@ -38,6 +39,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ ptyIdsByTabId, browserTabsByWorktree, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -52,6 +54,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, filterRepoIds, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c0a2bb9d924..81a78bec138 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -81,6 +81,7 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions ptyIdsByTabId: {}, browserTabsByWorktree: {}, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, repoMap, workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -96,6 +97,7 @@ function filterState(overrides: Partial = {}): FilterState { showSleepingWorkspaces: true, filterRepoIds: [], hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, ...overrides } } @@ -130,6 +132,36 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([]) }) + it('hides automation-created workspaces when the automation filter is enabled', () => { + const manual = makeWorktree('manual') + const automationCreated = { + ...makeWorktree('automation-created'), + automationProvenance: { + kind: 'created-by-automation' as const, + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local' as const, + executionTargetId: 'local', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local' as const + } + } + + const result = computeVisibleWorktreeIds( + { repo1: [manual, automationCreated] }, + [manual.id, automationCreated.id], + visibleOptions({ + hideAutomationGeneratedWorkspaces: true + }) + ) + + expect(result).toEqual([manual.id]) + }) + it('does not treat slept wake-hint tabs as live surfaces', () => { const wt = makeWorktree('wt-slept') @@ -517,6 +549,12 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true) }) + it('returns true when only automation-created workspaces are hidden', () => { + expect(sidebarHasActiveFilters(filterState({ hideAutomationGeneratedWorkspaces: true }))).toBe( + true + ) + }) + it('returns true when sleeping workspaces are hidden', () => { expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true) }) @@ -536,6 +574,7 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, resetVisibleWorkspaceHostIds: false }) }) @@ -548,6 +587,19 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: true, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: false + }) + }) + + it('flags only hideAutomationGeneratedWorkspaces for reset when it is the sole filter', () => { + expect( + computeClearFilterActions(filterState({ hideAutomationGeneratedWorkspaces: true })) + ).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: true, resetVisibleWorkspaceHostIds: false }) }) @@ -572,6 +624,7 @@ describe('computeClearFilterActions', () => { showSleepingWorkspaces: false, filterRepoIds: ['repo1', 'repo2'], hideDefaultBranchWorkspace: true, + hideAutomationGeneratedWorkspaces: true, visibleWorkspaceHostIds: ['local'] }) ) @@ -579,6 +632,7 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: true, resetFilterRepoIds: true, resetHideDefaultBranchWorkspace: true, + resetHideAutomationGeneratedWorkspaces: true, resetVisibleWorkspaceHostIds: true }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 41cbe9753b5..785fc2a39fa 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -25,11 +25,16 @@ export function isDefaultBranchWorkspace(worktree: Worktree): boolean { return worktree.isMainWorktree && worktree.branch.trim() !== '' } +export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean { + return worktree.automationProvenance?.kind === 'created-by-automation' +} + /** Inputs describing sidebar filter settings that the Clear Filters path owns. */ export type SidebarFilterState = { showSleepingWorkspaces: boolean filterRepoIds: readonly string[] hideDefaultBranchWorkspace: boolean + hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null } @@ -47,6 +52,7 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES || state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || + state.hideAutomationGeneratedWorkspaces || state.visibleWorkspaceHostIds != null ) } @@ -57,6 +63,7 @@ export type ClearFilterActions = { resetShowSleepingWorkspaces: boolean resetFilterRepoIds: boolean resetHideDefaultBranchWorkspace: boolean + resetHideAutomationGeneratedWorkspaces: boolean resetVisibleWorkspaceHostIds: boolean } @@ -75,6 +82,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES, resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null } } @@ -103,6 +111,7 @@ export function computeVisibleWorktreeIds( // required prevents a future caller from silently dropping the filter by // forgetting to pass it. hideDefaultBranchWorkspace: boolean + hideAutomationGeneratedWorkspaces: boolean repoMap: Map workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -123,6 +132,10 @@ export function computeVisibleWorktreeIds( all = all.filter((w) => !isDefaultBranchWorkspace(w)) } + if (opts.hideAutomationGeneratedWorkspaces) { + all = all.filter((w) => !isAutomationGeneratedWorkspace(w)) + } + const visibleHostIds = opts.visibleWorkspaceHostIds ?? (opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope]) @@ -289,6 +302,7 @@ export function getVisibleWorktreeIds(): string[] { ptyIdsByTabId: state.ptyIdsByTabId, browserTabsByWorktree: state.browserTabsByWorktree, hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, diff --git a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts new file mode 100644 index 00000000000..45ed0bab650 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts @@ -0,0 +1,62 @@ +import type { WorktreeCardProperty } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +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: 'automation', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.automation', + 'Automation' + ) + } + }, + { + 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' + ) + } + } +] diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index 12935476617..f0393848db6 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -1,4 +1,5 @@ -import type { IssueInfo } from '../../../../shared/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AutomationWorkspaceProvenance, IssueInfo } from '../../../../shared/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' @@ -25,6 +26,7 @@ export type WorktreeCardMetaBadgesProps = { linearIssue: WorktreeCardLinearIssueDisplay | null review: WorktreeCardPrDisplay | null comment: string | null + automationProvenance?: AutomationWorkspaceProvenance | null } export type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps & @@ -35,6 +37,7 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { branchName?: string workspaceTitle?: string identityOrder?: 'workspace-first' | 'branch-first' + automationHostId?: ExecutionHostId detailsAfter?: React.ReactNode openDelay?: number closeDelay?: number @@ -44,5 +47,7 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void onOpenReviewInOrca?: (event: React.MouseEvent) => void onUnlinkReview?: () => void + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void hoverControl?: WorktreeCardDetailsHoverControl } diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 2fc2eb3e744..b44c9b52581 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -22,6 +22,7 @@ import { selectAutomationRunOutputSnapshot } from '@/components/automations/automation-run-output-snapshot' import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -46,424 +47,453 @@ function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): s export function useAutomationDispatchEvents(): void { useEffect(() => { - const unsubscribe = window.api.automations.onDispatchRequested(async ({ automation, run }) => { - const markDispatchResult = async (result: AutomationDispatchResult): Promise => { - await window.api.automations.markDispatchResult(result) - window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) - } - const state = useAppStore.getState() - const focusBeforeDispatch = { - activeView: state.activeView, - activeWorktreeId: state.activeWorktreeId, - activeTabId: state.activeTabId, - activeTabType: state.activeTabType - } - 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 - let dispatchWorkspaceId = automation.workspaceId - let dispatchWorkspaceDisplayName = - automationWorktree?.displayName ?? run.workspaceDisplayName ?? null - let precheckResult: AutomationPrecheckResult | null = null - - if (!repo) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: run.workspaceId, - workspaceDisplayName: run.workspaceDisplayName ?? null, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.386db94f3e', - 'The target project is no longer available.' - ) - }) - return - } - - if (repo.connectionId) { - const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: repo.connectionId - }) - if (needsPrompt) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_needs_interactive_auth', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.16a21d6413', - 'SSH reconnect requires interactive credentials.' - ) - }) - return + const unsubscribe = window.api.automations.onDispatchRequested( + async ({ automation, run, dispatchToken }) => { + const markDispatchResult = async (result: AutomationDispatchResult): Promise => { + await window.api.automations.markDispatchResult(result) + window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) } - const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) - if (sshState?.status !== 'connected') { - try { - const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) - if (connected?.status !== 'connected') { - throw new Error('SSH target is unavailable.') - } - } catch (error) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: error instanceof Error ? error.message : String(error) - }) - return - } + const state = useAppStore.getState() + const focusBeforeDispatch = { + activeView: state.activeView, + activeWorktreeId: state.activeWorktreeId, + activeTabId: state.activeTabId, + activeTabType: state.activeTabType } - } + 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 + let dispatchWorkspaceId = automation.workspaceId + let dispatchWorkspaceDisplayName = + automationWorktree?.displayName ?? run.workspaceDisplayName ?? null + let precheckResult: AutomationPrecheckResult | null = null - 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, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' - ) - }) - return - } - - if (run.trigger === 'scheduled' && automation.precheck) { - precheckResult = await window.api.automations.runPrecheck({ - automationId: automation.id, - runId: run.id - }) - if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_precheck', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: formatAutomationPrecheckFailure(precheckResult) - }) - return - } - } - - try { - const worktree = - automation.workspaceMode === 'new_per_run' - ? ( - await useAppStore - .getState() - .createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - 'inherit', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - automation.agentId - ) - ).worktree - : automation.workspaceId - ? automationWorktree - : null - - if (!worktree) { + if (!repo) { await markDispatchResult({ runId: run.id, status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, + workspaceId: run.workspaceId, + workspaceDisplayName: run.workspaceDisplayName ?? null, error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' + 'auto.hooks.useAutomationDispatchEvents.386db94f3e', + 'The target project is no longer available.' ) }) return } - dispatchWorkspaceId = worktree.id - dispatchWorkspaceDisplayName = worktree.displayName - const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() - let latestAssistantMessage: string | null = null - const getOutputSnapshot = () => - selectAutomationRunOutputSnapshot(latestAssistantMessage, outputSnapshotBuffer.snapshot()) - let dispatchMarked = false - let pendingExitCode: number | null = null - let pendingDone = false - let completionMarked = false - let unsubscribeAgentStatus = (): void => {} - let unsubscribeSessionObserver = (): void => {} - let releaseReuseDispatchTab = (): void => {} - const cleanupRunObservers = (): void => { - unsubscribeAgentStatus() - unsubscribeSessionObserver() - releaseReuseDispatchTab() - unsubscribeAgentStatus = (): void => {} - unsubscribeSessionObserver = (): void => {} - releaseReuseDispatchTab = (): void => {} - } - const markCompletionResult = async (): Promise => { - if (completionMarked) { - return - } - completionMarked = true - cleanupRunObservers() - await markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: null - }) - } - const markExitResult = (code: number): Promise => { - cleanupRunObservers() - return markDispatchResult({ - runId: run.id, - status: code === 0 ? 'completed' : 'dispatch_failed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: code === 0 ? null : `Automation process exited with code ${code}.` - }) - } - const handleAgentDone = (): void => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingDone = true - return - } - void markCompletionResult() - } - const observeAgentStatus = ( - tabId: string, - startedAfter: number, - options?: { requireWorkingAfterStart?: boolean } - ): void => { - let sawWorkingAfterStart = false - const checkCurrentStatus = (): void => { - const { agentStatusByPaneKey } = useAppStore.getState() - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) - if (parsed?.tabId !== tabId || entry.updatedAt < startedAfter) { - continue - } - if (entry.state === 'working') { - sawWorkingAfterStart = true - } - if ( - entry.state === 'done' && - (!options?.requireWorkingAfterStart || sawWorkingAfterStart) - ) { - latestAssistantMessage = - entry.lastAssistantMessage?.trim() || latestAssistantMessage - handleAgentDone() + try { + if (repo.connectionId) { + const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ + targetId: repo.connectionId + }) + if (needsPrompt) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_needs_interactive_auth', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.16a21d6413', + 'SSH reconnect requires interactive credentials.' + ) + }) + return + } + const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) + if (sshState?.status !== 'connected') { + try { + const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) + if (connected?.status !== 'connected') { + throw new Error('SSH target is unavailable.') + } + } catch (error) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: error instanceof Error ? error.message : String(error) + }) return } } } - // Why: Codex/Claude completion normally arrives through the global - // hook IPC listener, not the hidden PTY OSC fallback. - unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) - checkCurrentStatus() - } - const dispatchStartedAt = Date.now() - if (automation.reuseSession) { - const reusableSession = findReusableAutomationSession({ - automationId: automation.id, - agentId: automation.agentId, - worktreeId: worktree.id, - currentRunId: run.id, - runs: await window.api.automations.listRuns({ automationId: automation.id }), - state: useAppStore.getState() - }) - if (reusableSession) { - const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) - if (releaseTab) { - releaseReuseDispatchTab = releaseTab - try { - const submitted = await submitPromptToAgentTab({ - tabId: reusableSession.tabId, - content: automation.prompt - }) - if (!submitted) { - cleanupRunObservers() - } else { - let reuseSawWorking = false - const handleReusableAgentStatus = (payload: { state: string }): void => { - if (payload.state === 'working') { - reuseSawWorking = true - return - } - if (payload.state === 'done' && reuseSawWorking) { - handleAgentDone() - } - } - const reuseCompletionStartedAt = Date.now() - unsubscribeSessionObserver = await observeExistingAutomationSession({ - ptyId: reusableSession.ptyId, - paneKey: reusableSession.paneKey, - runId: run.id, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = - payload.lastAssistantMessage?.trim() || latestAssistantMessage - handleReusableAgentStatus(payload) - }, - onExit: (code) => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingExitCode = code - return - } - void markExitResult(code) - } - }) - observeAgentStatus(reusableSession.tabId, reuseCompletionStartedAt, { - requireWorkingAfterStart: true - }) - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: reusableSession.tabId, - precheckResult, - error: null - }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) - } - return - } - } catch (error) { - cleanupRunObservers() - throw error - } - } + + 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 } - } - const result = await launchAgentBackgroundSession({ - agent: automation.agentId, - worktreeId: worktree.id, - prompt: automation.prompt, - launchSource: 'unknown', - title: run.title, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = payload.lastAssistantMessage?.trim() || latestAssistantMessage - if (payload.state !== 'done') { + + if (automation.workspaceMode === 'existing' && !automationWorktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return + } + + if (run.trigger === 'scheduled' && automation.precheck) { + precheckResult = await window.api.automations.runPrecheck({ + automationId: automation.id, + runId: run.id + }) + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) return } - handleAgentDone() - }, - onExit: (_ptyId, code) => { + } + + const automationWorkspaceCreateRequestId = createBrowserUuid() + const worktree = + automation.workspaceMode === 'new_per_run' + ? ( + await useAppStore + .getState() + .createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + 'inherit', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + automation.agentId, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId + } + } + ) + ).worktree + : automation.workspaceId + ? automationWorktree + : null + + if (!worktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return + } + dispatchWorkspaceId = worktree.id + dispatchWorkspaceDisplayName = worktree.displayName + + const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() + let latestAssistantMessage: string | null = null + const getOutputSnapshot = () => + selectAutomationRunOutputSnapshot( + latestAssistantMessage, + outputSnapshotBuffer.snapshot() + ) + let dispatchMarked = false + let pendingExitCode: number | null = null + let pendingDone = false + let completionMarked = false + let unsubscribeAgentStatus = (): void => {} + let unsubscribeSessionObserver = (): void => {} + let releaseReuseDispatchTab = (): void => {} + const cleanupRunObservers = (): void => { + unsubscribeAgentStatus() + unsubscribeSessionObserver() + releaseReuseDispatchTab() + unsubscribeAgentStatus = (): void => {} + unsubscribeSessionObserver = (): void => {} + releaseReuseDispatchTab = (): void => {} + } + const markCompletionResult = async (): Promise => { + if (completionMarked) { + return + } + completionMarked = true + cleanupRunObservers() + await markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: null + }) + } + const markExitResult = (code: number): Promise => { + cleanupRunObservers() + return markDispatchResult({ + runId: run.id, + status: code === 0 ? 'completed' : 'dispatch_failed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: code === 0 ? null : `Automation process exited with code ${code}.` + }) + } + const handleAgentDone = (): void => { if (completionMarked) { return } if (!dispatchMarked) { - pendingExitCode = code + pendingDone = true return } - void markExitResult(code) + void markCompletionResult() } - }) - if (!result) { - throw new Error('Unable to build an agent launch plan.') - } - const launchedTabId = result.tabId - // Why: host-backed automation terminals may lack a local tab id; skip - // pane-key status observation while background session output still - // tracks completion. - if (launchedTabId) { - observeAgentStatus(launchedTabId, dispatchStartedAt) - } - try { - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: launchedTabId, - precheckResult, - error: null + const observeAgentStatus = ( + tabId: string, + startedAfter: number, + options?: { requireWorkingAfterStart?: boolean } + ): void => { + let sawWorkingAfterStart = false + const checkCurrentStatus = (): void => { + const { agentStatusByPaneKey } = useAppStore.getState() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + const parsed = parsePaneKey(paneKey) + if (parsed?.tabId !== tabId || entry.updatedAt < startedAfter) { + continue + } + if (entry.state === 'working') { + sawWorkingAfterStart = true + } + if ( + entry.state === 'done' && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + latestAssistantMessage = + entry.lastAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + return + } + } + } + // Why: Codex/Claude completion normally arrives through the global + // hook IPC listener, not the hidden PTY OSC fallback. + unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) + checkCurrentStatus() + } + const dispatchStartedAt = Date.now() + if (automation.reuseSession) { + const reusableSession = findReusableAutomationSession({ + automationId: automation.id, + agentId: automation.agentId, + worktreeId: worktree.id, + currentRunId: run.id, + runs: await window.api.automations.listRuns({ automationId: automation.id }), + state: useAppStore.getState() + }) + if (reusableSession) { + const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) + if (releaseTab) { + releaseReuseDispatchTab = releaseTab + try { + const submitted = await submitPromptToAgentTab({ + tabId: reusableSession.tabId, + content: automation.prompt + }) + if (!submitted) { + cleanupRunObservers() + } else { + let reuseSawWorking = false + const handleReusableAgentStatus = (payload: { state: string }): void => { + if (payload.state === 'working') { + reuseSawWorking = true + return + } + if (payload.state === 'done' && reuseSawWorking) { + handleAgentDone() + } + } + const reuseCompletionStartedAt = Date.now() + unsubscribeSessionObserver = await observeExistingAutomationSession({ + ptyId: reusableSession.ptyId, + paneKey: reusableSession.paneKey, + runId: run.id, + onData: (chunk) => { + outputSnapshotBuffer.append(chunk) + }, + onAgentStatus: (payload) => { + latestAssistantMessage = + payload.lastAssistantMessage?.trim() || latestAssistantMessage + handleReusableAgentStatus(payload) + }, + onExit: (code) => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingExitCode = code + return + } + void markExitResult(code) + } + }) + observeAgentStatus(reusableSession.tabId, reuseCompletionStartedAt, { + requireWorkingAfterStart: true + }) + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: reusableSession.tabId, + precheckResult, + error: null + }) + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + return + } + } catch (error) { + cleanupRunObservers() + throw error + } + } + } + } + const result = await launchAgentBackgroundSession({ + agent: automation.agentId, + worktreeId: worktree.id, + prompt: automation.prompt, + launchSource: 'unknown', + title: run.title, + onData: (chunk) => { + outputSnapshotBuffer.append(chunk) + }, + onAgentStatus: (payload) => { + latestAssistantMessage = + payload.lastAssistantMessage?.trim() || latestAssistantMessage + if (payload.state !== 'done') { + return + } + handleAgentDone() + }, + onExit: (_ptyId, code) => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingExitCode = code + return + } + void markExitResult(code) + } }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) + if (!result) { + throw new Error('Unable to build an agent launch plan.') + } + const launchedTabId = result.tabId + // Why: host-backed automation terminals may lack a local tab id; skip + // pane-key status observation while background session output still + // tracks completion. + if (launchedTabId) { + observeAgentStatus(launchedTabId, dispatchStartedAt) + } + try { + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: launchedTabId, + precheckResult, + error: null + }) + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + } catch (error) { + cleanupRunObservers() + throw error + } + const currentState = useAppStore.getState() + // Why: Run Now and scheduled dispatches should create workspaces/tabs in + // the background; only an explicit row click should navigate there. + if ( + focusBeforeDispatch.activeWorktreeId !== worktree.id && + currentState.activeWorktreeId === worktree.id + ) { + currentState.setActiveView(focusBeforeDispatch.activeView) + currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) + if (focusBeforeDispatch.activeTabId) { + currentState.setActiveTab(focusBeforeDispatch.activeTabId) + } + currentState.setActiveTabType(focusBeforeDispatch.activeTabType) } } catch (error) { - cleanupRunObservers() - throw error + await markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + precheckResult, + error: error instanceof Error ? error.message : String(error) + }) } - const currentState = useAppStore.getState() - // Why: Run Now and scheduled dispatches should create workspaces/tabs in - // the background; only an explicit row click should navigate there. - if ( - focusBeforeDispatch.activeWorktreeId !== worktree.id && - currentState.activeWorktreeId === worktree.id - ) { - currentState.setActiveView(focusBeforeDispatch.activeView) - currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) - if (focusBeforeDispatch.activeTabId) { - currentState.setActiveTab(focusBeforeDispatch.activeTabId) - } - currentState.setActiveTabType(focusBeforeDispatch.activeTabType) - } - } catch (error) { - await markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: error instanceof Error ? error.message : String(error) - }) } - }) + ) void window.api.automations.rendererReady() return unsubscribe }, []) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6f74439a9d0..bb284d0b0a9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3544,7 +3544,8 @@ "f506a1262a": "Filter workspaces", "75405270ed": "Edit filters ({{value0}} active)", "489d1c8c9f": "Search projects...", - "ee240a39eb": "Edit filters" + "ee240a39eb": "Edit filters", + "automationCreated": "Hide automation-created" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -3606,7 +3607,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Hide default branch", "ed1611b65b": "Hide sleeping", - "82594419ba": "Filters" + "82594419ba": "Filters", + "automationCreated": "Hide automation-created" }, "sidebarHostOptions": { "3e102f111c": "All hosts", @@ -3765,7 +3767,15 @@ "3ea2702e62": "Linked {{value0}} #{{value1}}", "b105fd3057": "Linked Linear {{value0}}", "3f2649eeb8": "Linked issue #{{value0}}", - "fe075cb851": "Workspace notes" + "fe075cb851": "Workspace notes", + "automationHeader": "Automation", + "openAutomation": "Open automation", + "openAutomationRun": "Open run", + "automationCreated": "Created by automation", + "checkingAutomationAvailability": "Checking automation availability...", + "automationMissing": "Automation no longer available.", + "automationRunMissing": "Run history no longer available.", + "automationAvailabilityUnavailable": "Automation availability could not be checked." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "State: Open", @@ -11175,7 +11185,9 @@ "d441032f7e": "pause", "5918020edc": "run", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "Automation no longer available.", + "pendingAutomationRunMissing": "Run history no longer available." }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4bdbdb64b38..78ce8ab3e2b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3538,7 +3538,8 @@ "f506a1262a": "Filtrar espacios de trabajo", "75405270ed": "Editar filtros ({{value0}} activo)", "489d1c8c9f": "Buscar proyectos...", - "ee240a39eb": "Editar filtros" + "ee240a39eb": "Editar filtros", + "automationCreated": "Ocultar creados por automatizaciones" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -3600,7 +3601,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Ocultar rama predeterminada", "ed1611b65b": "Ocultar durmiendo", - "82594419ba": "Filtros" + "82594419ba": "Filtros", + "automationCreated": "Ocultar creados por automatizaciones" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -3751,7 +3753,15 @@ "3ea2702e62": "Vinculado {{value0}} #{{value1}}", "b105fd3057": "Linear vinculado {{value0}}", "3f2649eeb8": "Problema vinculado #{{value0}}", - "fe075cb851": "Notas del espacio de trabajo" + "fe075cb851": "Notas del espacio de trabajo", + "automationHeader": "Automatización", + "openAutomation": "Abrir automatización", + "openAutomationRun": "Abrir ejecución", + "automationCreated": "Creado por automatización", + "checkingAutomationAvailability": "Comprobando disponibilidad de la automatización...", + "automationMissing": "La automatización ya no está disponible.", + "automationRunMissing": "El historial de ejecuciones ya no está disponible.", + "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", @@ -11175,7 +11185,9 @@ "d441032f7e": "pausa", "5918020edc": "correr", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "La automatización ya no está disponible.", + "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." }, "CreateFromPicker": { "f061f49e3f": "Buscar sucursales de repo...", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2f119fa47d2..5768c12d240 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3519,7 +3519,8 @@ "f506a1262a": "ワークスペースのフィルタリング", "75405270ed": "フィルターの編集 ({{value0}} がアクティブ)", "489d1c8c9f": "プロジェクトを検索...", - "ee240a39eb": "フィルターの編集" + "ee240a39eb": "フィルターの編集", + "automationCreated": "自動化で作成されたワークスペースを非表示" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "デフォルトのブランチを非表示にする", "ed1611b65b": "スリープ中を非表示", - "82594419ba": "フィルター" + "82594419ba": "フィルター", + "automationCreated": "自動化で作成されたワークスペースを非表示" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -3732,7 +3734,15 @@ "3ea2702e62": "リンク済み {{value0}} #{{value1}}", "b105fd3057": "リンク Linear {{value0}}", "3f2649eeb8": "リンクされた Issue #{{value0}}", - "fe075cb851": "ワークスペースのメモ" + "fe075cb851": "ワークスペースのメモ", + "automationHeader": "自動化", + "openAutomation": "自動化を開く", + "openAutomationRun": "実行を開く", + "automationCreated": "自動化によって作成", + "checkingAutomationAvailability": "自動化の利用可否を確認中...", + "automationMissing": "自動化は利用できなくなりました。", + "automationRunMissing": "実行履歴は利用できなくなりました。", + "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", @@ -11175,7 +11185,9 @@ "d441032f7e": "一時停止", "5918020edc": "走る", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "自動化は利用できなくなりました。", + "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。" }, "CreateFromPicker": { "f061f49e3f": "repo ブランチを検索...", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 17d2f8c837c..457c889ef32 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3519,7 +3519,8 @@ "f506a1262a": "워크스페이스 필터링", "75405270ed": "필터 편집({{value0}} 활성)", "489d1c8c9f": "프로젝트 검색...", - "ee240a39eb": "필터 편집" + "ee240a39eb": "필터 편집", + "automationCreated": "자동화로 생성된 워크스페이스 숨기기" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "기본 브랜치 숨기기", "ed1611b65b": "슬립 중인 항목 숨기기", - "82594419ba": "필터" + "82594419ba": "필터", + "automationCreated": "자동화로 생성된 워크스페이스 숨기기" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -3732,7 +3734,15 @@ "3ea2702e62": "연결됨 {{value0}} #{{value1}}", "b105fd3057": "연결된 Linear {{value0}}", "3f2649eeb8": "연결된 이슈 #{{value0}}", - "fe075cb851": "워크스페이스 메모" + "fe075cb851": "워크스페이스 메모", + "automationHeader": "자동화", + "openAutomation": "자동화 열기", + "openAutomationRun": "실행 열기", + "automationCreated": "자동화가 생성함", + "checkingAutomationAvailability": "자동화 사용 가능 여부 확인 중...", + "automationMissing": "자동화를 더 이상 사용할 수 없습니다.", + "automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", + "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "상태: 열림", @@ -11175,7 +11185,9 @@ "d441032f7e": "정지시키다", "5918020edc": "달리다", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "자동화를 더 이상 사용할 수 없습니다.", + "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다." }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 165c3e01f59..33be7d88406 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3519,7 +3519,8 @@ "f506a1262a": "搜索工作区", "75405270ed": "编辑搜索器({{value0}} 活动)", "489d1c8c9f": "搜索项目...", - "ee240a39eb": "编辑搜索器" + "ee240a39eb": "编辑搜索器", + "automationCreated": "隐藏自动化创建的工作区" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "隐藏默认分支", "ed1611b65b": "隐藏休眠项", - "82594419ba": "搜索器" + "82594419ba": "搜索器", + "automationCreated": "隐藏自动化创建的工作区" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 活动布局", @@ -3732,7 +3734,15 @@ "3ea2702e62": "已链接 {{value0}} #{{value1}}", "b105fd3057": "链接 Linear {{value0}}", "3f2649eeb8": "链接议题#{{value0}}", - "fe075cb851": "工作区笔记" + "fe075cb851": "工作区笔记", + "automationHeader": "自动化", + "openAutomation": "打开自动化", + "openAutomationRun": "打开运行", + "automationCreated": "由自动化创建", + "checkingAutomationAvailability": "正在检查自动化可用性...", + "automationMissing": "自动化已不可用。", + "automationRunMissing": "运行历史已不可用。", + "automationAvailabilityUnavailable": "无法检查自动化可用性。" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状态:开放", @@ -11175,7 +11185,9 @@ "d441032f7e": "暂停", "5918020edc": "跑步", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "自动化已不可用。", + "pendingAutomationRunMissing": "运行历史已不可用。" }, "CreateFromPicker": { "f061f49e3f": "搜索 repo 分支...", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 54f64270a29..d219a468ce6 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -47,6 +47,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], uiZoomLevel: 0, diff --git a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts new file mode 100644 index 00000000000..a6968e414e2 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../shared/types' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from './worktree-activation' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +function makeAutomationWorktree(): Worktree { + return { + id: 'repo-1::/workspace/automation-run', + repoId: 'repo-1', + path: '/workspace/automation-run', + head: 'abc123', + branch: 'refs/heads/automation-run', + isBare: false, + isMainWorktree: false, + displayName: 'automation-run', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1', + repoId: 'repo-1', + hostId: 'local' + } + } +} + +describe('activateAndRevealWorktree automation filters', () => { + it('clears the automation-generated filter before revealing an automation-created worktree', () => { + const worktree = makeAutomationWorktree() + const revealWorktreeInSidebar = vi.fn() + + useAppStore.setState({ + repos: [ + { + id: worktree.repoId, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + activeWorktreeId: worktree.id, + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabsByWorktree: { [worktree.id]: [] }, + ptyIdsByTabId: {}, + everActivatedWorktreeIds: new Set([worktree.id]), + hideAutomationGeneratedWorkspaces: true, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar + }) + + activateAndRevealWorktree(worktree.id) + + expect(useAppStore.getState().hideAutomationGeneratedWorkspaces).toBe(false) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2957c43d590..fe0dd825558 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -347,6 +347,12 @@ export function activateAndRevealWorktree( if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) { state.setFilterRepoIds([]) } + if ( + state.hideAutomationGeneratedWorkspaces && + wt.automationProvenance?.kind === 'created-by-automation' + ) { + state.setHideAutomationGeneratedWorkspaces(false) + } // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ac8c67d3790..e3954536d53 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -667,6 +667,14 @@ export type UISlice = { closeActivityPage: () => void selectedAutomationId: string | null setSelectedAutomationId: (id: string | null) => void + pendingAutomationRunNavigation: { + automationId: string + runId: string | null + hostId?: ExecutionHostId + } | null + setPendingAutomationRunNavigation: ( + navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null + ) => void openAutomationsPage: () => void closeAutomationsPage: () => void openSpacePage: () => void @@ -786,6 +794,8 @@ export type UISlice = { setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void + hideAutomationGeneratedWorkspaces: boolean + setHideAutomationGeneratedWorkspaces: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -1310,6 +1320,9 @@ export const createUISlice: StateCreator = (set, get) })), selectedAutomationId: null, setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: (navigation) => + set({ pendingAutomationRunNavigation: navigation }), openAutomationsPage: () => { get().recordViewVisit('automations') set((state) => ({ @@ -1887,6 +1900,8 @@ export const createUISlice: StateCreator = (set, get) hideDefaultBranchWorkspace: false, setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), + hideAutomationGeneratedWorkspaces: false, + setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2213,6 +2228,7 @@ export const createUISlice: StateCreator = (set, get) visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, + hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), collapsedGroups: new Set(ui.collapsedGroups ?? []), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index b0f2171b488..b1feaf7e0f4 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -1,5 +1,6 @@ import type { CreateWorktreeResult, + CreateWorktreeArgs, CreateSparseCheckoutRequest, DetectedWorktree, DetectedWorktreeListResult, @@ -147,7 +148,10 @@ export type WorktreeSlice = { linkedBitbucketPR?: number | null, linkedAzureDevOpsPR?: number | null, linkedGiteaPR?: number | null, - compareBaseRef?: string + compareBaseRef?: string, + // Why: reserved for automation-dispatch flows so host-side provenance can + // be minted securely; regular create callers should omit this. + options?: { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] } ) => Promise /** Register an in-flight background creation and make it the active surface. */ beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c4faadc8723..652ce713d0f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1584,8 +1584,10 @@ export const createWorktreeSlice: StateCreator linkedBitbucketPR, linkedAzureDevOpsPR, linkedGiteaPR, - compareBaseRef + compareBaseRef, + options ) => { + const automationProvenanceRequest = options?.automationProvenanceRequest const retryableConflictPatterns = [ /already exists locally/i, /already exists on a remote/i, @@ -1646,7 +1648,8 @@ export const createWorktreeSlice: StateCreator ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(startup ? { startup } : {}), - ...(creationId ? { creationId } : {}) + ...(creationId ? { creationId } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) const result = @@ -1687,6 +1690,7 @@ export const createWorktreeSlice: StateCreator ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { startupCommand: startup.command, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2046a58cfd9..bdf334a9446 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1132,7 +1132,8 @@ function createWorktreesApi(): NonNullable['worktrees']> { pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, parentWorkspace: args.parentWorkspace, workspaceStatus: args.workspaceStatus, - manualOrder: args.manualOrder + manualOrder: args.manualOrder, + automationProvenanceRequest: args.automationProvenanceRequest }) }, // Why: the runtime create path emits no two-phase progress, so the web diff --git a/src/shared/automation-workspace-provenance.ts b/src/shared/automation-workspace-provenance.ts new file mode 100644 index 00000000000..7fd42119e22 --- /dev/null +++ b/src/shared/automation-workspace-provenance.ts @@ -0,0 +1,31 @@ +import type { Automation, AutomationRun } from './automations-types' +import { getRepoExecutionHostId } from './execution-host' +import type { AutomationWorkspaceProvenance, Repo } from './types' + +type AutomationProvenanceRun = Pick + +export function buildAutomationWorkspaceProvenance( + automation: Automation, + run: AutomationProvenanceRun, + repo: Repo, + createdAt = Date.now() +): AutomationWorkspaceProvenance { + return { + kind: 'created-by-automation', + automationId: automation.id, + automationNameSnapshot: automation.name, + automationRunId: run.id, + automationRunTitleSnapshot: run.title, + createdAt, + executionTargetType: automation.executionTargetType, + executionTargetId: automation.executionTargetId, + projectId: + run.runContext?.projectId ?? automation.runContext?.projectId ?? automation.projectId, + ...(run.runContext?.repoId + ? { repoId: run.runContext.repoId } + : automation.runContext?.repoId + ? { repoId: automation.runContext.repoId } + : {}), + hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo) + } +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 776085ab9c2..0eebbccc0d9 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -183,6 +183,7 @@ export type AutomationUpdateInput = Partial< export type AutomationDispatchRequest = { automation: Automation run: AutomationRun + dispatchToken: string } export type AutomationDispatchResult = { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 510652d0bd9..7ffefd7ead5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -444,6 +444,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceHostOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index 81f97763756..82db4123b0b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,7 @@ /* 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 { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' import type { GitHubProjectSettings } from './github-project-types' import type { @@ -496,8 +496,30 @@ export type Worktree = { workspaceStatus?: WorkspaceStatus diffComments?: DiffComment[] mobileDiffReview?: MobileDiffReviewState + automationProvenance?: AutomationWorkspaceProvenance } & GitWorktreeInfo +export type AutomationWorkspaceProvenance = { + kind: 'created-by-automation' + automationId: string + automationNameSnapshot: string + automationRunId: string + automationRunTitleSnapshot: string + createdAt: number + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + projectId: string + repoId?: string + hostId?: ExecutionHostId +} + +export type AutomationWorkspaceProvenanceRequest = { + automationId: string + automationRunId: string + dispatchToken: string + createRequestId: string +} + export type GitPushTarget = { remoteName: string branchName: string @@ -584,6 +606,8 @@ export type WorktreeMeta = { * them. Self-prunes when the worktree is deleted. */ priorWorktreeIds?: string[] mobileDiffReview?: MobileDiffReviewState + /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ + automationProvenance?: AutomationWorkspaceProvenance } export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' @@ -1958,6 +1982,8 @@ export type CreateWorktreeArgs = { * creation in the renderer, so concurrent background creates each drive * their own status surface. Omitted by synchronous callers. */ creationId?: string + /** Authorizes the host to mint system-owned automation provenance. */ + automationProvenanceRequest?: AutomationWorkspaceProvenanceRequest } export type CreateWorktreeResult = { @@ -2912,6 +2938,7 @@ export type WorktreeCardProperty = | 'issue' | 'linear-issue' | 'pr' + | 'automation' | 'comment' | 'ports' // Why: inline list of agent activity rendered directly inside each @@ -3009,6 +3036,8 @@ export type PersistedUIState = { * the predicate in visible-worktrees.ts excludes worktrees with an empty * branch. */ hideDefaultBranchWorkspace: boolean + /** Hide workspaces created by automation new-per-run dispatches. */ + hideAutomationGeneratedWorkspaces?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/src/shared/worktree-card-properties.test.ts b/src/shared/worktree-card-properties.test.ts index b471988f394..20791b47dda 100644 --- a/src/shared/worktree-card-properties.test.ts +++ b/src/shared/worktree-card-properties.test.ts @@ -14,10 +14,11 @@ describe('worktree card properties', () => { expect(props).toContain('inline-agents') expect(props).not.toContain('branch') expect(props).toContain('pr') + expect(props).toContain('automation') expect(props).toEqual(DEFAULT_WORKTREE_CARD_PROPERTIES) }) - it('defines Compact without extra rows or branch metadata', () => { + it('defines Compact with status and automation but without extra rows or branch metadata', () => { const props = getWorktreeCardModeProperties('Compact') expect(props).not.toContain('inline-agents') @@ -27,6 +28,7 @@ describe('worktree card properties', () => { expect(props).not.toContain('ports') expect(props).not.toContain('branch') expect(props).not.toContain('pr') + expect(props).toContain('automation') }) it('keeps status enabled in both presets', () => { @@ -41,13 +43,9 @@ describe('worktree card properties', () => { }) it('normalizes fixed and legacy properties while preserving selected properties', () => { - expect(normalizeWorktreeCardProperties(['ci', 'branch', 'pr', 'unread'])).toEqual([ - 'status', - 'unread', - 'ci', - 'branch', - 'pr' - ]) + expect(normalizeWorktreeCardProperties(['ci', 'branch', 'pr', 'automation', 'unread'])).toEqual( + ['status', 'unread', 'ci', 'branch', 'pr', 'automation'] + ) }) it('returns combined mode update payloads', () => { diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree-card-properties.ts index beee43ed854..cddbfe73bfe 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree-card-properties.ts @@ -13,6 +13,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ ...FIXED_WORKTREE_CARD_PROPERTIES, ...TASK_WORKTREE_CARD_PROPERTIES, 'pr', + 'automation', 'comment', 'ports', // Why: agent activity is the primary reason users opt into the feature, so @@ -21,7 +22,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'inline-agents' ] -export const COMPACT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status'] +export const COMPACT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status', 'automation'] const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'status', @@ -31,6 +32,7 @@ const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents' From 1652f8ede16d3bbb43d592b45a71a80b4688573d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:59:00 -0700 Subject: [PATCH 03/16] Create PR worktrees from the PR head (#5733) --- ...poserState-host-context-boundaries.test.ts | 60 +++- src/renderer/src/hooks/useComposerState.ts | 265 +++++++++++++----- .../src/lib/github-pr-start-point.test.ts | 108 +++++++ src/renderer/src/lib/github-pr-start-point.ts | 43 +++ .../lib/launch-work-item-direct-preflight.ts | 32 +-- .../src/lib/launch-work-item-direct-types.ts | 3 + .../src/lib/launch-work-item-direct.test.ts | 12 +- .../src/lib/launch-work-item-direct.ts | 2 +- .../src/lib/pending-worktree-creation.ts | 5 + src/renderer/src/lib/smart-github-submit.ts | 6 +- .../src/lib/worktree-creation-flow.test.ts | 85 ++++++ .../src/lib/worktree-creation-flow.ts | 13 +- 12 files changed, 536 insertions(+), 98 deletions(-) create mode 100644 src/renderer/src/lib/github-pr-start-point.test.ts create mode 100644 src/renderer/src/lib/github-pr-start-point.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow.test.ts diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 75ecac62f8c..2330ade133e 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -25,8 +25,9 @@ describe('useComposerState host-context boundaries', () => { ) expect(section).toContain('const runRepo = selectedRepo ??') + expect(section).toContain('resolveGitHubPrStartPointForRepo') expect(section).toContain('repoId: runRepo.id') - expect(section).toContain('repo: runRepo.id') + expect(section).toContain('settings: itemRepoSettings') expect(section).not.toContain('repoId: repoForItem.id') expect(section).not.toContain('repo: repoForItem.id') }) @@ -135,12 +136,57 @@ describe('useComposerState host-context boundaries', () => { const submitLookup = sourceBetween( HOOK_SOURCE, 'const resolvePendingSmartGitHubSubmit', - 'const resolution = getSmartGitHubSubmitResolution(item)' + 'const prStartPoint' ) expect(submitLookup).toContain('sourceContext:') expect(submitLookup).toContain('selectedRepoGitHubSourceContext') }) + it('uses submit-time GitHub PR start points for the create payload', () => { + const submitLookup = sourceBetween( + HOOK_SOURCE, + 'const resolvePendingSmartGitHubSubmit', + 'const applyLinkedGitLabWorkItem' + ) + expect(submitLookup).toContain('resolveGitHubPrStartPointForRepo') + expect(submitLookup).toContain("kind: 'pr-start-point'") + expect(submitLookup).toContain("kind: 'metadata-only'") + expect(submitLookup).toContain('baseBranch: prStartPoint.baseBranch') + expect(submitLookup).toContain('branchNameOverride: prStartPoint.branchNameOverride') + + const fullSubmit = sourceBetween( + HOOK_SOURCE, + 'const submit = useCallback', + 'const submitQuick = useCallback' + ) + expect(fullSubmit).toContain("smartGitHubResolution.kind === 'pr-start-point'") + expect(fullSubmit).toContain("smartGitHubResolution.kind === 'metadata-only'") + expect(fullSubmit).toContain('effectiveLinkedPR !== null || linkedGitLabMR !== null') + expect(fullSubmit).toContain('selectedRepoIsGit ? submitBaseBranch : undefined') + expect(fullSubmit).toContain('submitPushTarget') + expect(fullSubmit).toContain('submitCompareBaseRef') + expect(fullSubmit).not.toContain('smartGitHubResolution?.baseBranch ?? baseBranch') + expect(fullSubmit).not.toContain('smartGitHubResolution?.compareBaseRef ?? compareBaseRef') + expect(fullSubmit).not.toContain('smartGitHubResolution?.pushTarget ?? pushTarget') + expect(fullSubmit).not.toContain( + 'smartGitHubResolution?.branchNameOverride ?? branchNameOverride' + ) + + const quickSubmit = sourceBetween(HOOK_SOURCE, 'const submitQuick = useCallback', 'return {') + expect(quickSubmit).toContain("smartGitHubResolution.kind === 'pr-start-point'") + expect(quickSubmit).toContain("smartGitHubResolution.kind === 'metadata-only'") + expect(quickSubmit).toContain('effectiveLinkedPR !== null || linkedGitLabMR !== null') + expect(quickSubmit).toContain('explicitBaseBranch: smartSubmitBaseBranch') + expect(quickSubmit).toContain('pushTarget: submitPushTarget') + expect(quickSubmit).toContain('compareBaseRef: submitCompareBaseRef') + expect(quickSubmit).not.toContain('smartGitHubResolution?.baseBranch ?? baseBranch') + expect(quickSubmit).not.toContain('smartGitHubResolution?.compareBaseRef ?? compareBaseRef') + expect(quickSubmit).not.toContain('smartGitHubResolution?.pushTarget ?? pushTarget') + expect(quickSubmit).not.toContain( + 'smartGitHubResolution?.branchNameOverride ?? branchNameOverride' + ) + }) + it('resolves submit-time GitHub smart input when folder child repos exist', () => { expect( canResolveFolderSmartGitHubSubmit({ @@ -156,7 +202,7 @@ describe('useComposerState host-context boundaries', () => { const lookupSection = sourceBetween( HOOK_SOURCE, 'const resolvePendingSmartGitHubSubmit', - 'const resolution = getSmartGitHubSubmitResolution(item)' + 'const prStartPoint' ) expect(lookupSection).toContain('isProjectGroupTarget') expect(lookupSection).toContain('folderSourceRepos.filter(isGitRepoKind)') @@ -304,10 +350,14 @@ describe('useComposerState host-context boundaries', () => { }) it('resolves quick-create base refs through the worktree-create precedence helper', () => { - const section = sourceBetween(HOOK_SOURCE, 'const submitBaseBranch', 'const createDisplayName') + const section = sourceBetween( + HOOK_SOURCE, + 'const smartSubmitBaseBranch', + 'const createDisplayName' + ) expect(section).toContain('resolveWorktreeCreateBaseBranch') - expect(section).toContain('explicitBaseBranch: baseBranch') + expect(section).toContain('explicitBaseBranch: smartSubmitBaseBranch') expect(section).toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') expect(section).toContain('getRuntimeRepoBaseRefDefault') }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2f403add137..0dc93e8e566 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -34,7 +34,6 @@ import { } from '../../../shared/task-source-context' import type { GitHubWorkItem, - GitHubPrStartPoint, GitPushTarget, GitLabWorkItem, LinearIssue, @@ -91,6 +90,7 @@ import { lookupGitHubWorkItemByOwnerRepoForSource, lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' +import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' import { canUseRepoBackedComposerSources, @@ -164,6 +164,17 @@ export function canResolveFolderSmartGitHubSubmit({ return hasFolderSourceRepos } +type PendingSmartGitHubSubmitResolution = + | { kind: 'none' } + | (SmartGitHubSubmitResolution & { kind: 'metadata-only' }) + | (SmartGitHubSubmitResolution & { + kind: 'pr-start-point' + baseBranch: string + compareBaseRef?: string + pushTarget?: GitPushTarget + branchNameOverride?: string + }) + export type UseComposerStateOptions = { initialRepoId?: string initialProjectGroupId?: string @@ -1636,14 +1647,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) const resolvePendingSmartGitHubSubmit = - useCallback(async (): Promise => { + useCallback(async (): Promise => { if (linkedWorkItem) { - return null + return { kind: 'none' } } const intent = getSmartGitHubSubmitIntent(name) if (!intent) { - return null + return { kind: 'none' } } const item = isProjectGroupTarget @@ -1681,7 +1692,38 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS throw new Error('Could not resolve the GitHub item before creating the workspace.') } - const resolution = getSmartGitHubSubmitResolution(item) + const prStartPoint = + !isProjectGroupTarget && item.type === 'pr' && selectedRepo && selectedRepoIsGit + ? await resolveGitHubPrStartPointForRepo({ + repoId: selectedRepo.id, + prNumber: item.number, + settings: getSettingsForRepoRuntimeOwner( + { repos: [selectedRepo], settings }, + selectedRepo.id + ), + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + : null + const smartGitHubMetadata = getSmartGitHubSubmitResolution(item) + const resolution: Exclude = prStartPoint + ? { + ...smartGitHubMetadata, + kind: 'pr-start-point', + baseBranch: prStartPoint.baseBranch, + ...(prStartPoint.compareBaseRef ? { compareBaseRef: prStartPoint.compareBaseRef } : {}), + ...(prStartPoint.pushTarget ? { pushTarget: prStartPoint.pushTarget } : {}), + ...(prStartPoint.branchNameOverride + ? { branchNameOverride: prStartPoint.branchNameOverride } + : {}) + } + : { + ...smartGitHubMetadata, + kind: 'metadata-only' + } // Why: Create can be clicked before the debounced smart field commits // its selected source. Commit the resolved item here so failures leave // the form showing the title instead of the raw URL. @@ -1694,7 +1736,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedWorkItem(resolution.linkedWorkItem) setName(resolution.workspaceName) lastAutoNameRef.current = resolution.workspaceName - setBranchNameOverride(undefined) + if (prStartPoint) { + setBaseBranch(prStartPoint.baseBranch) + setCompareBaseRef(prStartPoint.compareBaseRef) + setPushTarget(prStartPoint.pushTarget) + if (prStartPoint.branchNameOverride) { + setBranchNameOverride(prStartPoint.branchNameOverride) + setBranchNameOverridePreservesNameEdits(true) + } else { + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + } + setForkPushWarning(getForkPushWarning(prStartPoint)) + } else { + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + } branchAutoNameRef.current = '' setStartFromResetHint(null) return resolution @@ -1705,7 +1762,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS name, selectedRepo, selectedRepoGitHubSourceContext, - selectedRepoIsGit + selectedRepoIsGit, + settings ]) // Why: GitHub/GitLab review routing prefers one provider identity. Clear @@ -2327,41 +2385,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS { repos: [runRepo], settings }, runRepo.id ) - const target = getActiveRuntimeTarget(itemRepoSettings) - const resolvePrBase = - target.kind === 'local' - ? window.api.worktrees.resolvePrBase({ - repoId: runRepo.id, - prNumber: item.number, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } - : {}) - }) - : callRuntimeRpc( - target, - 'worktree.resolvePrBase', - { - repo: runRepo.id, - prNumber: item.number, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } - : {}) - }, - { timeoutMs: 30_000 } - ) + const resolvePrBase = resolveGitHubPrStartPointForRepo({ + repoId: runRepo.id, + prNumber: item.number, + settings: itemRepoSettings, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) void resolvePrBase .then((result) => { - if ('error' in result) { - setBaseBranch(undefined) - setCompareBaseRef(undefined) - setPushTarget(undefined) - toast.error(result.error) - return - } handleBaseBranchPrSelect( result.baseBranch, item, @@ -2658,12 +2693,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) const smartGitHubResolution = shouldResolveSmartGitHubSubmit ? await resolvePendingSmartGitHubSubmit() - : null + : ({ kind: 'none' } as const) + const smartGitHubMetadata = + smartGitHubResolution.kind === 'none' ? null : smartGitHubResolution const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, - name: smartGitHubResolution?.workspaceName ?? name, + name: smartGitHubMetadata?.workspaceName ?? name, lastAutoName: lastAutoNameRef.current, - linkedWorkItem: smartGitHubResolution?.linkedWorkItem ?? linkedWorkItem, + linkedWorkItem: smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem, note, quickAgent: agent, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, @@ -2765,21 +2802,55 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCreating(true) try { const smartGitHubResolution = await resolvePendingSmartGitHubSubmit() - const submitLinkedWorkItem = smartGitHubResolution?.linkedWorkItem ?? linkedWorkItem + const submitLinkedWorkItem = + smartGitHubResolution.kind === 'none' + ? linkedWorkItem + : smartGitHubResolution.linkedWorkItem const submitLinkedIssueNumber = - smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber - const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR + smartGitHubResolution.kind === 'none' + ? parsedLinkedIssueNumber + : smartGitHubResolution.linkedIssueNumber + const submitLinkedPR = + smartGitHubResolution.kind === 'none' ? effectiveLinkedPR : smartGitHubResolution.linkedPR const submitTitleName = submitLinkedWorkItem ? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem) : null const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name) const workspaceName = - smartGitHubResolution?.workspaceName ?? - (nameIsAutoManaged && submitTitleName ? submitTitleName.seedName : workspaceSeedName) + smartGitHubResolution.kind === 'none' + ? nameIsAutoManaged && submitTitleName + ? submitTitleName.seedName + : workspaceSeedName + : smartGitHubResolution.workspaceName if (!workspaceName) { return } + const submitBaseBranch = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.baseBranch + : smartGitHubResolution.kind === 'metadata-only' && + (effectiveLinkedPR !== null || linkedGitLabMR !== null) + ? undefined + : baseBranch + const submitCompareBaseRef = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.compareBaseRef + : smartGitHubResolution.kind === 'none' + ? compareBaseRef + : undefined + const submitPushTarget = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.pushTarget + : smartGitHubResolution.kind === 'none' + ? pushTarget + : undefined + const submitBranchNameOverride = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.branchNameOverride + : smartGitHubResolution.kind === 'none' + ? branchNameOverride + : undefined const submitShouldApplyLinkedOnlyTemplate = enableIssueAutomation && !agentPrompt.trim() && @@ -2853,14 +2924,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? submitLinkedWorkItem.linearOrganizationUrlKey : undefined const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({ - branchNameOverride, + branchNameOverride: submitBranchNameOverride, branchAutoName: branchAutoNameRef.current, workspaceName, - preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits + preserveWorkspaceNameEdits: + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits }) const createDisplayName = - smartGitHubResolution?.displayName ?? - (nameIsAutoManaged ? submitTitleName?.displayName : undefined) + smartGitHubResolution.kind === 'none' + ? nameIsAutoManaged + ? submitTitleName?.displayName + : undefined + : smartGitHubResolution.displayName // Why: the first-work hook only renames blank, auto-generated git workspaces // that actually launch an agent. Persist that known-pending state for the card. const pendingFirstAgentMessageRename = @@ -2901,7 +2976,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const result = await createWorktree( repoId, workspaceName, - selectedRepoIsGit ? baseBranch : undefined, + selectedRepoIsGit ? submitBaseBranch : undefined, effectiveSetupDecision, selectedRepoIsGit && sparseEnabled ? { @@ -2913,13 +2988,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS createDisplayName, submitLinkedIssueNumber ?? undefined, submitLinkedPR ?? undefined, - pushTarget, + submitPushTarget, tuiAgent, linkedLinearIssue, effectiveBranchNameOverride, resolvedInitialWorkspaceStatus, - linkedGitLabMR ?? undefined, - linkedGitLabIssue ?? undefined, + smartGitHubResolution.kind === 'none' ? (linkedGitLabMR ?? undefined) : undefined, + smartGitHubResolution.kind === 'none' ? (linkedGitLabIssue ?? undefined) : undefined, backendStartup, pendingFirstAgentMessageRename, undefined, @@ -2928,7 +3003,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS undefined, undefined, undefined, - compareBaseRef + submitCompareBaseRef ) const worktree = result.worktree @@ -3082,21 +3157,55 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCreating(true) try { const smartGitHubResolution = await resolvePendingSmartGitHubSubmit() - const submitLinkedWorkItem = smartGitHubResolution?.linkedWorkItem ?? linkedWorkItem + const submitLinkedWorkItem = + smartGitHubResolution.kind === 'none' + ? linkedWorkItem + : smartGitHubResolution.linkedWorkItem const submitLinkedIssueNumber = - smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber - const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR + smartGitHubResolution.kind === 'none' + ? parsedLinkedIssueNumber + : smartGitHubResolution.linkedIssueNumber + const submitLinkedPR = + smartGitHubResolution.kind === 'none' ? effectiveLinkedPR : smartGitHubResolution.linkedPR const submitTitleName = submitLinkedWorkItem ? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem) : null const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name) const workspaceName = - smartGitHubResolution?.workspaceName ?? - (nameIsAutoManaged && submitTitleName ? submitTitleName.seedName : workspaceNameSeed) + smartGitHubResolution.kind === 'none' + ? nameIsAutoManaged && submitTitleName + ? submitTitleName.seedName + : workspaceNameSeed + : smartGitHubResolution.workspaceName if (!workspaceName) { return } + const smartSubmitBaseBranch = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.baseBranch + : smartGitHubResolution.kind === 'metadata-only' && + (effectiveLinkedPR !== null || linkedGitLabMR !== null) + ? undefined + : baseBranch + const submitCompareBaseRef = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.compareBaseRef + : smartGitHubResolution.kind === 'none' + ? compareBaseRef + : undefined + const submitPushTarget = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.pushTarget + : smartGitHubResolution.kind === 'none' + ? pushTarget + : undefined + const submitBranchNameOverride = + smartGitHubResolution.kind === 'pr-start-point' + ? smartGitHubResolution.branchNameOverride + : smartGitHubResolution.kind === 'none' + ? branchNameOverride + : undefined let submitSetupConfig = setupConfig let submitResolvedSetupDecision = resolvedSetupDecision @@ -3145,14 +3254,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? submitLinkedWorkItem.linearOrganizationUrlKey : undefined const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({ - branchNameOverride, + branchNameOverride: submitBranchNameOverride, branchAutoName: branchAutoNameRef.current, workspaceName, - preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits + preserveWorkspaceNameEdits: + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits }) const submitBaseBranch = selectedRepoIsGit ? await resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: baseBranch, + explicitBaseBranch: smartSubmitBaseBranch, repoWorktreeBaseRef: selectedRepo.worktreeBaseRef, loadDefaultBaseRef: async () => (await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) @@ -3160,8 +3270,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) : undefined const createDisplayName = - smartGitHubResolution?.displayName ?? - (nameIsAutoManaged ? submitTitleName?.displayName : undefined) + smartGitHubResolution.kind === 'none' + ? nameIsAutoManaged + ? submitTitleName?.displayName + : undefined + : smartGitHubResolution.displayName // Why: quick create uses the same blank-name creature branch flow; the card // needs an explicit marker rather than guessing from the generated title. const pendingFirstAgentMessageRename = @@ -3243,6 +3356,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : undefined const request: WorktreeCreationRequest = { repoId, + worktreeCreateProgressMode: + getActiveRuntimeTarget(selectedRepoSettings).kind === 'local' + ? 'stepped' + : 'indeterminate', ...(taskSourceContext ? { taskSourceContext } : {}), ...(selectedWorkspaceTarget.status === 'ready' ? { @@ -3259,7 +3376,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS name: workspaceName, ...(createDisplayName ? { displayName: createDisplayName } : {}), ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), - ...(selectedRepoIsGit && compareBaseRef ? { compareBaseRef } : {}), + ...(selectedRepoIsGit && submitCompareBaseRef + ? { compareBaseRef: submitCompareBaseRef } + : {}), setupDecision: effectiveSetupDecision, ...(selectedRepoIsGit && sparseEnabled ? { @@ -3272,7 +3391,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ...(telemetrySource ? { telemetrySource } : {}), ...(submitLinkedIssueNumber != null ? { linkedIssue: submitLinkedIssueNumber } : {}), ...(submitLinkedPR != null ? { linkedPR: submitLinkedPR } : {}), - ...(pushTarget ? { pushTarget } : {}), + ...(submitPushTarget ? { pushTarget: submitPushTarget } : {}), agent, ...(linkedLinearIssue ? { linkedLinearIssue } : {}), ...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}), @@ -3285,8 +3404,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ...(resolvedInitialWorkspaceStatus ? { workspaceStatus: resolvedInitialWorkspaceStatus } : {}), - ...(linkedGitLabMR != null ? { linkedGitLabMR } : {}), - ...(linkedGitLabIssue != null ? { linkedGitLabIssue } : {}), + ...(smartGitHubResolution.kind === 'none' && linkedGitLabMR != null + ? { linkedGitLabMR } + : {}), + ...(smartGitHubResolution.kind === 'none' && linkedGitLabIssue != null + ? { linkedGitLabIssue } + : {}), ...(backendStartup ? { startup: backendStartup } : {}), pendingFirstAgentMessageRename, note: trimmedNote, diff --git a/src/renderer/src/lib/github-pr-start-point.test.ts b/src/renderer/src/lib/github-pr-start-point.test.ts new file mode 100644 index 00000000000..23c41f837d3 --- /dev/null +++ b/src/renderer/src/lib/github-pr-start-point.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import { resolveGitHubPrStartPointForRepo } from './github-pr-start-point' + +vi.mock('@/runtime/runtime-rpc-client', async () => { + const actual = await vi.importActual('@/runtime/runtime-rpc-client') + return { + ...actual, + callRuntimeRpc: vi.fn() + } +}) + +describe('resolveGitHubPrStartPointForRepo', () => { + beforeEach(() => { + vi.mocked(callRuntimeRpc).mockReset() + vi.stubGlobal('window', { + api: { + worktrees: { + resolvePrBase: vi.fn() + } + } + }) + }) + + it('resolves local PR heads through worktree IPC with PR branch hints', async () => { + vi.mocked(window.api.worktrees.resolvePrBase).mockResolvedValue({ + baseBranch: 'abc123', + compareBaseRef: 'refs/remotes/origin/main', + branchNameOverride: 'feature/fix', + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + + await expect( + resolveGitHubPrStartPointForRepo({ + repoId: 'repo-1', + prNumber: 42, + settings: { activeRuntimeEnvironmentId: null }, + headRefName: 'feature/fix', + baseRefName: 'main', + isCrossRepository: false + }) + ).resolves.toMatchObject({ + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(window.api.worktrees.resolvePrBase).toHaveBeenCalledWith({ + repoId: 'repo-1', + prNumber: 42, + headRefName: 'feature/fix', + baseRefName: 'main', + isCrossRepository: false + }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('routes runtime-owned PR head resolution through runtime RPC', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValue({ + baseBranch: 'def456', + compareBaseRef: 'refs/remotes/origin/develop', + branchNameOverride: 'feature/runtime', + pushTarget: { remoteName: 'fork', branchName: 'feature/runtime' } + }) + + await expect( + resolveGitHubPrStartPointForRepo({ + repoId: 'repo-runtime', + prNumber: 7, + settings: { activeRuntimeEnvironmentId: 'env-1' }, + headRefName: 'feature/runtime', + baseRefName: 'develop', + isCrossRepository: true + }) + ).resolves.toMatchObject({ + baseBranch: 'def456', + branchNameOverride: 'feature/runtime' + }) + + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'worktree.resolvePrBase', + { + repo: 'repo-runtime', + prNumber: 7, + headRefName: 'feature/runtime', + baseRefName: 'develop', + isCrossRepository: true + }, + { timeoutMs: 30_000 } + ) + expect(window.api.worktrees.resolvePrBase).not.toHaveBeenCalled() + }) + + it('throws resolver errors so submit can abort before creating from the wrong base', async () => { + vi.mocked(window.api.worktrees.resolvePrBase).mockResolvedValue({ + error: 'Could not resolve PR head.' + }) + + await expect( + resolveGitHubPrStartPointForRepo({ + repoId: 'repo-1', + prNumber: 99, + settings: null + }) + ).rejects.toThrow('Could not resolve PR head.') + }) +}) diff --git a/src/renderer/src/lib/github-pr-start-point.ts b/src/renderer/src/lib/github-pr-start-point.ts new file mode 100644 index 00000000000..a0ded7900b8 --- /dev/null +++ b/src/renderer/src/lib/github-pr-start-point.ts @@ -0,0 +1,43 @@ +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubPrStartPoint, GlobalSettings } from '../../../shared/types' + +type PrStartPointSettings = Pick | null | undefined + +export type GitHubPrStartPointInput = { + repoId: string + prNumber: number + settings: PrStartPointSettings + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +} + +export async function resolveGitHubPrStartPointForRepo({ + repoId, + prNumber, + settings, + headRefName, + baseRefName, + isCrossRepository +}: GitHubPrStartPointInput): Promise { + const target = getActiveRuntimeTarget(settings) + const prFields = { + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + } + const result = + target.kind === 'local' + ? await window.api.worktrees.resolvePrBase({ repoId, ...prFields }) + : await callRuntimeRpc( + target, + 'worktree.resolvePrBase', + { repo: repoId, ...prFields }, + { timeoutMs: 30_000 } + ) + if ('error' in result) { + throw new Error(result.error) + } + return result +} 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 e103e83103a..3a09dfc67c1 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -1,6 +1,6 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSetupConfig } from '@/lib/new-workspace' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' import type { GitHubPrStartPoint, GlobalSettings, @@ -16,22 +16,22 @@ type PreflightSettings = Pick | nu export async function resolveDirectPrStartPoint( repoId: string, prNumber: number, - settings: PreflightSettings + settings: PreflightSettings, + hints: { + branchName?: string + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + } = {} ): Promise { - const target = getActiveRuntimeTarget(settings) - const result = - target.kind === 'local' - ? await window.api.worktrees.resolvePrBase({ repoId, prNumber }) - : await callRuntimeRpc( - target, - 'worktree.resolvePrBase', - { repo: repoId, prNumber }, - { timeoutMs: 30_000 } - ) - if ('error' in result) { - throw new Error(result.error) - } - return result + return resolveGitHubPrStartPointForRepo({ + repoId, + prNumber, + settings, + headRefName: hints.headRefName ?? hints.branchName, + baseRefName: hints.baseRefName, + isCrossRepository: hints.isCrossRepository + }) } export async function resolveDirectSetupDecision( diff --git a/src/renderer/src/lib/launch-work-item-direct-types.ts b/src/renderer/src/lib/launch-work-item-direct-types.ts index 47f3e2cbabd..92b93ef5eef 100644 --- a/src/renderer/src/lib/launch-work-item-direct-types.ts +++ b/src/renderer/src/lib/launch-work-item-direct-types.ts @@ -8,6 +8,9 @@ export type LaunchableWorkItem = { type: 'issue' | 'pr' | 'mr' number: number | null repoId?: string + branchName?: string + baseRefName?: string + isCrossRepository?: boolean pasteContent?: string linearIdentifier?: string linearWorkspaceId?: string diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 4d1b4930c24..5740e537399 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -231,10 +231,20 @@ describe('launchWorkItemDirect', () => { type: 'pr', number: 42, title: 'Fix the bug', - url: 'https://github.com/acme/repo/pull/42' + url: 'https://github.com/acme/repo/pull/42', + branchName: 'feature/fix', + baseRefName: 'main', + isCrossRepository: true } }) + expect(mocks.resolvePrBase).toHaveBeenCalledWith({ + repoId: 'repo-1', + prNumber: 42, + headRefName: 'feature/fix', + baseRefName: 'main', + isCrossRepository: true + }) expect(mocks.createWorktree).toHaveBeenCalledWith( 'repo-1', 'review-pr-42', diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 796bf3266c5..d941deb724e 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -142,7 +142,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, repoOwnerSettings) + const result = await resolveDirectPrStartPoint(repoId, item.number, repoOwnerSettings, item) resolvedBaseBranch = result.baseBranch resolvedPushTarget = result.pushTarget resolvedBranchNameOverride = result.branchNameOverride diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 74512c42dba..3c83982b53f 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -17,6 +17,8 @@ import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/tas * phase that never advances past `fetching`. */ export type WorktreeCreationPhase = 'fetching' | 'creating' +export type WorktreeCreationProgressMode = 'stepped' | 'indeterminate' + /** * Everything needed to run a worktree create in the background and reproduce it * verbatim on retry. Captured at the composer's submit cut point — after all @@ -33,6 +35,9 @@ export type WorktreeCreationRequest = { * repoId keeps old create APIs working, while this records the project-first * host intent for retry, diagnostics, and future metadata writes. */ workspaceRunContext?: WorkspaceRunContext | null + /** Captured from the repo/run owner at submit time so Retry keeps the same + * local-vs-runtime progress behavior even if the focused runtime changes. */ + worktreeCreateProgressMode?: WorktreeCreationProgressMode name: string displayName?: string baseBranch?: string diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index dc683db8537..c6ee5abff47 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../shared/types' +import type { GitHubWorkItem, GitPushTarget } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { getTaskSourceCacheScope } from '../../../shared/task-source-context' import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' @@ -24,6 +24,10 @@ export type SmartGitHubSubmitResolution = { linkedWorkItem: LinkedWorkItemSummary linkedIssueNumber: number | null linkedPR: number | null + baseBranch?: string + compareBaseRef?: string + pushTarget?: GitPushTarget + branchNameOverride?: string } export type SmartGitHubSubmitLookup = { diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts new file mode 100644 index 00000000000..447c0052d0c --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' + +const store = { + settings: { activeRuntimeEnvironmentId: null as string | null }, + beginPendingWorktreeCreation: vi.fn(), + setActiveView: vi.fn(), + setSidebarOpen: vi.fn(), + createWorktree: vi.fn(() => new Promise(() => {})) +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'creation-1' +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() +})) + +vi.mock('@/lib/new-workspace-terminal-focus', () => ({ + queueNewWorkspaceTerminalFocus: vi.fn() +})) + +vi.mock('@/lib/new-workspace', () => ({ + ensureAgentStartupInTerminal: vi.fn() +})) + +import { runBackgroundWorktreeCreation } from './worktree-creation-flow' + +function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null, + ...overrides + } +} + +describe('runBackgroundWorktreeCreation', () => { + it('uses the captured repo-owner progress mode instead of focused runtime state', () => { + store.settings.activeRuntimeEnvironmentId = null + store.beginPendingWorktreeCreation.mockClear() + + runBackgroundWorktreeCreation(makeRequest({ worktreeCreateProgressMode: 'indeterminate' })) + + expect(store.beginPendingWorktreeCreation).toHaveBeenCalledWith( + expect.objectContaining({ + creationId: 'creation-1', + indeterminate: true, + request: expect.objectContaining({ + worktreeCreateProgressMode: 'indeterminate' + }) + }) + ) + }) + + it('falls back to focused runtime state for legacy captured requests', () => { + store.settings.activeRuntimeEnvironmentId = 'focused-runtime' + store.beginPendingWorktreeCreation.mockClear() + + runBackgroundWorktreeCreation(makeRequest()) + + expect(store.beginPendingWorktreeCreation).toHaveBeenCalledWith( + expect.objectContaining({ + indeterminate: true, + request: expect.not.objectContaining({ + worktreeCreateProgressMode: expect.any(String) + }) + }) + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 9c3fe6273f4..1eb9f3276ea 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -41,6 +41,13 @@ function buildStartupOpt( } } +function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean { + if (request.worktreeCreateProgressMode) { + return request.worktreeCreateProgressMode === 'indeterminate' + } + return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local' +} + async function preflightAgentTrust(request: WorktreeCreationRequest, path: string): Promise { // Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste // as menu input on first launch. Pre-write the trust artifact before any @@ -198,9 +205,9 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): const creationId = createBrowserUuid() const store = useAppStore.getState() // Why: the remote/runtime create path emits no progress events, so the stepped - // checklist would freeze on step 1. Mark it indeterminate up front so the panel - // shows a single spinner instead of implying phase progress that never arrives. - const indeterminate = getActiveRuntimeTarget(store.settings).kind !== 'local' + // checklist would freeze on step 1. Use the request's captured repo owner so + // Retry does not change shape when focus moves to another runtime. + const indeterminate = getWorktreeCreationIndeterminate(request) store.beginPendingWorktreeCreation({ creationId, phase: 'fetching', From 6d0b5734e7a3d8e54ee922b2d336bfcd5b8dfa50 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Thu, 18 Jun 2026 14:57:10 -0700 Subject: [PATCH 04/16] fix: match slashed branch names in worktree branch search (#5687) Co-authored-by: Cursor Co-authored-by: brennanb2025 --- src/main/git/repo.test.ts | 108 +++++++++++++++++++++++++ src/main/git/repo.ts | 127 ++++++++++++++++++++++++++---- src/main/ipc/repos-remote.test.ts | 19 +++-- src/main/ipc/repos.ts | 61 +++++++++----- src/main/runtime/orca-runtime.ts | 53 +++++++++---- 5 files changed, 310 insertions(+), 58 deletions(-) diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index f89140c53f5..4696e47e2b7 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -64,6 +64,32 @@ describe('buildSearchBaseRefsArgv', () => { expect(argv).toContain('--count=40') expect(argv).toContain('refs/remotes/*upstream*/*main*') expect(argv).toContain('refs/heads/*upstream*/*main*') + expect(argv).toContain('refs/remotes/*/upstream/main*') + expect(argv).toContain('refs/heads/upstream/main*') + }) + + it('anchors local-branch-name searches below configured remotes', () => { + const argv = buildSearchBaseRefsArgv('plan/docs', 10, { remoteNames: ['origin', 'foo/bar'] }) + + expect(argv).toContain('refs/remotes/origin/plan/docs*') + expect(argv).toContain('refs/remotes/foo/bar/plan/docs*') + expect(argv).not.toContain('refs/remotes/**/*plan/docs*') + }) + + it('can build display-format and branch-root patterns separately', () => { + const segmentedArgv = buildSearchBaseRefsArgv('upstream/feat', 10, { + remoteNames: ['origin', 'upstream'], + patternGroup: 'segmented' + }) + const argv = buildSearchBaseRefsArgv('upstream/feat', 10, { + remoteNames: ['origin', 'upstream'], + patternGroup: 'branchRoot' + }) + + expect(segmentedArgv).toContain('refs/remotes/*upstream*/*feat*') + expect(segmentedArgv).not.toContain('refs/remotes/origin/upstream/feat*') + expect(argv).toContain('refs/remotes/origin/upstream/feat*') + expect(argv).not.toContain('refs/remotes/*upstream*/*feat*') }) it('adds fallback headroom when remote HEAD cannot be excluded by git', () => { @@ -351,6 +377,88 @@ describe('searchBaseRefs (widened glob)', () => { expect(await searchBaseRefs(tmpDir, '/upstream')).toContain('upstream/main') expect(await searchBaseRefs(tmpDir, 'upstream//main')).toContain('upstream/main') }) + + it('finds a remote branch when the query is the local branch name with slashes', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + createRemoteRef(tmpDir, 'origin/plan/unified-brainstorm-plan-docs', sha) + + const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs') + + expect(results).toContain('origin/plan/unified-brainstorm-plan-docs') + }) + + it('finds a remote branch by local branch name when the remote name has slashes', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git']) + createRemoteRef(tmpDir, 'foo/bar/plan/unified-brainstorm-plan-docs', sha) + + const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs') + + expect(results).toContain('foo/bar/plan/unified-brainstorm-plan-docs') + }) + + it('does not match slash queries inside unrelated nested branch paths', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git']) + createRemoteRef(tmpDir, 'origin/upstream/feature-x', sha) + createRemoteRef(tmpDir, 'origin/foo/upstream/feature-x', sha) + createRemoteRef(tmpDir, 'upstream/feature-y', sha) + + const results = await searchBaseRefs(tmpDir, 'upstream/feat') + + expect(results).toContain('upstream/feature-y') + expect(results).toContain('origin/upstream/feature-x') + expect(results).not.toContain('origin/foo/upstream/feature-x') + }) + + it('keeps display-format matches when many branch-root matches share the query', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git']) + for (let i = 0; i < 12; i += 1) { + createRemoteRef(tmpDir, `origin/upstream/feature-${i}`, sha) + } + createRemoteRef(tmpDir, 'upstream/feature-target', sha) + + const results = await searchBaseRefs(tmpDir, 'upstream/feature', 2) + + expect(results).toContain('upstream/feature-target') + }) + + it('still finds a local-branch-name match when the first segment is also a remote name', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git']) + createRemoteRef(tmpDir, 'origin/plan/docs', sha) + + const results = await searchBaseRefs(tmpDir, 'plan/docs') + + expect(results).toContain('origin/plan/docs') + }) + + it('keeps branch-root matches when many display-format matches share the query', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git']) + for (let i = 0; i < 12; i += 1) { + createRemoteRef(tmpDir, `plan/docs-${i}`, sha) + } + createRemoteRef(tmpDir, 'origin/plan/docs', sha) + + const results = await searchBaseRefs(tmpDir, 'plan/docs', 2) + + expect(results).toContain('origin/plan/docs') + }) + + it('finds a local slashed branch when the query repeats the full branch name', async () => { + git(tmpDir, ['branch', 'plan/unified-brainstorm-plan-docs']) + + const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs') + + expect(results).toContain('plan/unified-brainstorm-plan-docs') + }) }) describe('getDefaultBaseRef (regression — unchanged behavior)', () => { diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 975caa3f5bd..b2b43e9c04f 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -504,6 +504,12 @@ async function getDefaultBaseRefAsync( const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 const REF_SEARCH_LEGACY_HEADROOM = 100 +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((t) => t.length > 0) +} + function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') @@ -515,7 +521,11 @@ function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): export function buildSearchBaseRefsArgv( normalizedQuery: string, limit: number, - options: { excludeRemoteHead?: boolean } = {} + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} ): string[] { const excludeRemoteHead = options.excludeRemoteHead ?? true const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) @@ -541,7 +551,7 @@ export function buildSearchBaseRefsArgv( // patterns. A single remaining token means the user hasn't committed // to a remote-plus-branch query yet — route through the widened // single-segment globs below instead of pinning to one segment. - const tokens = normalizedQuery.split('/').filter((t) => t.length > 0) + const tokens = getRefSearchTokens(normalizedQuery) if (tokens.length <= 1) { const q = tokens[0] ?? '' // Why `**`, not `*`: git for-each-ref globs are fnmatch-style where a @@ -563,7 +573,82 @@ export function buildSearchBaseRefsArgv( // as `/`, so users naturally retype that format; this // branch is what makes re-typing a visible result actually find it. const segmented = tokens.map((token) => `*${token}*`).join('/') - return [...base, `refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + // Why: branch names often contain slashes (`plan/docs`). Segment-wise + // display-format globs only align with `/`; these root + // patterns also match the local branch name beneath any configured remote. + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + try { + return await gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ) + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + return gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ) + } +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged } export function isForEachRefExcludeUnsupportedError(error: unknown): boolean { @@ -655,23 +740,31 @@ export async function searchBaseRefDetails( try { // Why: argv (including the two-remote-glob rationale) lives in // buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotesPromise = listRemoteNames(path) - let result: { stdout: string } - try { - result = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery, limit), { - cwd: path - }) - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - result = await gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), - { cwd: path } + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + // Why: ambiguous slash queries need both display-format matches + // (`upstream/feat`) and local branch-name matches (`plan/docs`). + // Parse and merge buckets before the final limit so neither side starves. + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit ) } - const remotes = await remotesPromise + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) } catch (err) { // Why: surface the failure for diagnostics; callers treat `[]` as "no diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b1c30a81bd4..8ee9a0ea8c6 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2561,17 +2561,22 @@ describe('repos:searchBaseRefs SSH relay', () => { await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' }) - expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) - const [argv] = mockGitProvider.exec.mock.calls.find( + expect(mockGitProvider.exec).toHaveBeenCalledTimes(3) + const forEachRefCalls = mockGitProvider.exec.mock.calls.filter( (call) => (call[0] as string[])[0] === 'for-each-ref' - )! - expect(argv).toContain('refs/remotes/*upstream*/*main*') - expect(argv).toContain('refs/heads/*upstream*/*main*') + ) + expect(forEachRefCalls).toHaveLength(2) + const segmentedArgv = forEachRefCalls[0][0] as string[] + const branchRootArgv = forEachRefCalls[1][0] as string[] + expect(segmentedArgv).toContain('refs/remotes/*upstream*/*main*') + expect(segmentedArgv).toContain('refs/heads/*upstream*/*main*') + expect(branchRootArgv).toContain('refs/remotes/*/upstream/main*') + expect(branchRootArgv).toContain('refs/heads/upstream/main*') // Regression guard: the literal slash must never appear inside a // single segmented glob (would be `refs/remotes/*upstream/main*`), // which fnmatch cannot match because `*` doesn't cross `/`. - expect(argv).not.toContain('refs/remotes/*upstream/main*/*') - expect(argv).not.toContain('refs/remotes/*/*upstream/main*') + expect(segmentedArgv).not.toContain('refs/remotes/*upstream/main*/*') + expect(segmentedArgv).not.toContain('refs/remotes/*/*upstream/main*') expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') }) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 391774d982d..55827fe1bcd 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -69,6 +69,7 @@ import { resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, + mergeBaseRefSearchResultGroups, searchBaseRefDetails } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' @@ -2423,29 +2424,51 @@ async function searchBaseRefDetailsForRepo( try { // Why: argv (including the two-remote-glob rationale) lives in // buildSearchBaseRefsArgv so the SSH and local paths cannot drift. - const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - let result: { stdout: string } - try { - result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path) - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - result = await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), - repo.path - ) - } - const remotesResult = await remotesPromise - // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline - // to the shared helper so the SSH and local paths cannot diverge. - // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup + - // HEAD-filter rationale. + const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) const remotes = remotesResult.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { + try { + return ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + return ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout + } + } + // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline + // to the shared helper so the SSH and local paths cannot diverge. + // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup + + // HEAD-filter rationale. + const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) + if (searchTokens.length > 1) { + const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) + return mergeBaseRefSearchResultGroups( + results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), + limit + ) + } + return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) } catch (err) { console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', { path: repo.path, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 57a5928e702..3b8c8dca588 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -518,6 +518,7 @@ import { resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, + mergeBaseRefSearchResultGroups, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' @@ -8284,25 +8285,47 @@ export class OrcaRuntimeService { } const normalizedQuery = normalizeRefSearchQuery(query) try { - const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - let result: { stdout: string } - try { - result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path) - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - result = await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), - repo.path - ) - } - const remotesResult = await remotesPromise + const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) const remotes = remotesResult.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { + try { + return ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + return ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout + } + } + const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) + if (searchTokens.length > 1) { + const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) + return mergeBaseRefSearchResultGroups( + results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), + limit + ) + } + return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) } catch (err) { console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { path: repo.path, From a0b19f356fef04bcd27f37154088cceff72c4ccc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:43:40 +0000 Subject: [PATCH 05/16] release: v1.4.80-rc.3 [rc-slot:2026-06-18-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d48f887fa2c..d65b8a8c8bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.80-rc.1", + "version": "1.4.80-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From afde3a374101894efed27dad64c8d69b197bccfe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:52:55 -0700 Subject: [PATCH 06/16] Honor configured SSH for remote-base worktrees (#5739) --- src/main/git/runner-command-exec.test.ts | 184 +++++++++++++++++ src/main/git/runner.ts | 211 ++++++++++++++++++-- src/main/runtime/fetch-remote-cache.test.ts | 16 +- src/main/runtime/orca-runtime.test.ts | 6 +- src/main/runtime/orca-runtime.ts | 9 +- 5 files changed, 404 insertions(+), 22 deletions(-) diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 5c8844a31a2..2d31c255528 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -288,6 +288,190 @@ describe('runner execFile timeout handling', () => { expect(capturedEnv?.GIT_SSH_COMMAND).toContain('BatchMode=yes') }) + it('probes core.sshCommand for opted-in network git calls', async () => { + const child = createMockChildProcess(1234) + const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = [] + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + calls.push({ args, env: opts.env }) + cb(null, args[0] === 'config' ? 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key\n' : '', '') + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(calls[0]?.args).toEqual(['config', '--get', 'core.sshCommand']) + expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0') + expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined() + expect(calls[1]?.args).toEqual(['fetch', 'origin']) + expect(calls[1]?.env.GIT_SSH_COMMAND).toBe( + 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key -o BatchMode=yes' + ) + }) + + it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'ssh -o BatchMode=no -i ~/.ssh/personal\n', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/personal -o BatchMode=yes') + }) + + it('merges quoted ssh.exe command shapes for opted-in network calls', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, '"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\n', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe( + "'C:/Program Files/Git/usr/bin/ssh.exe' -F ~/.ssh/config -o BatchMode=yes" + ) + }) + + it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, `${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\n`, '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe( + String.raw`'C:\Git\usr\bin\ssh.exe' -i 'C:\Users\me\.ssh\work_key' -o BatchMode=yes` + ) + }) + + it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, '/usr/local/bin/work-ssh-wrapper --account work\n', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + expect(capturedEnv?.GIT_ASKPASS).toBe('') + expect(capturedEnv?.SSH_ASKPASS).toBe('') + expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'ssh -i "$HOME/.ssh/work_key"\n', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('falls back to generic batch-mode SSH when opted-in config is unset', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(Object.assign(new Error('missing'), { code: 1 }), '', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') + }) + + it('preserves explicit GIT_SSH_COMMAND and skips the opted-in config probe', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, _args, opts, cb) => { + capturedEnv = opts.env + cb(null, '', '') + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' }, + useConfiguredSshCommandForNetwork: true + }) + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(capturedEnv?.GIT_SSH_COMMAND).toBe('custom-ssh -o IdentityAgent=none') + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + }) + it('routes git through the selected WSL distro login shell when requested', async () => { await withPlatform('win32', async () => { const child = createMockChildProcess(1234) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 576ba99c642..a66bdba08ae 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -253,6 +253,7 @@ type GitExecOptions = { env?: NodeJS.ProcessEnv signal?: AbortSignal wslDistro?: string + useConfiguredSshCommandForNetwork?: boolean } type CommandExecOptions = { @@ -517,6 +518,15 @@ export function gitOptionalLocksDisabledEnv( } } +function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...env, + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: env.GIT_ASKPASS ?? '', + SSH_ASKPASS: env.SSH_ASKPASS ?? '' + } +} + /** * Force git to be non-interactive so it fails fast instead of blocking forever * on a prompt. Without this, a git read-path call (status, worktree list, …) @@ -533,18 +543,175 @@ export function gitOptionalLocksDisabledEnv( * caller hasn't set its own GIT_SSH_COMMAND. */ export function nonInteractiveGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const next: NodeJS.ProcessEnv = { - ...env, - GIT_TERMINAL_PROMPT: '0', - GIT_ASKPASS: env.GIT_ASKPASS ?? '', - SSH_ASKPASS: env.SSH_ASKPASS ?? '' - } + const next = promptGuardGitEnv(env) if (!next.GIT_SSH_COMMAND) { next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' } return next } +type GitSshPolicyMode = + | 'default' + | 'explicit-env' + | 'fallback' + | 'configured-openssh' + | 'configured-wrapper-passthrough' + +const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 + +function commandBasename(command: string): string { + const pieces = command.split(/[\\/]+/) + return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase() +} + +function isMergeableOpenSshCommand(command: string): boolean { + const basename = commandBasename(command) + return basename === 'ssh' || basename === 'ssh.exe' +} + +function shellTokenize(command: string): string[] | null { + const tokens: string[] = [] + let current = '' + let quote: "'" | '"' | null = null + let escaped = false + + for (let i = 0; i < command.length; i++) { + const char = command[i] + if (escaped) { + current += char + escaped = false + continue + } + if (char === '\\') { + const next = command[i + 1] + if (next && /[\s'"\\]/.test(next)) { + escaped = true + } else { + current += char + } + continue + } + if (quote) { + if (char === quote) { + quote = null + } else { + current += char + } + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (/\s/.test(char)) { + if (current) { + tokens.push(current) + current = '' + } + continue + } + if (';&|<>()`'.includes(char)) { + return null + } + current += char + } + + if (escaped || quote) { + return null + } + if (current) { + tokens.push(current) + } + return tokens +} + +function shellQuoteToken(token: string): string { + return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token) +} + +function containsShellExpansionSyntax(command: string): boolean { + return command.includes('$') +} + +function withoutBatchModeOptions(tokens: string[]): string[] { + const next: string[] = [] + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + const lower = token.toLowerCase() + if (lower === '-o') { + const option = tokens[i + 1]?.toLowerCase() + if (option?.startsWith('batchmode')) { + i += 1 + continue + } + } + if (lower.startsWith('-obatchmode')) { + continue + } + next.push(token) + } + return next +} + +function buildOpenSshBatchModeCommand(configuredCommand: string): string | null { + if (containsShellExpansionSyntax(configuredCommand)) { + return null + } + const tokens = shellTokenize(configuredCommand) + if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) { + return null + } + return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ') +} + +async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ + env: NodeJS.ProcessEnv + mode: GitSshPolicyMode +}> { + const promptEnv = promptGuardGitEnv(options.env) + if (promptEnv.GIT_SSH_COMMAND) { + return { env: promptEnv, mode: 'explicit-env' } + } + + const resolved = resolveCommand( + 'git', + ['config', '--get', 'core.sshCommand'], + options.cwd, + options.wslDistro, + { useWslLoginShell: Boolean(options.wslDistro) } + ) + let configuredCommand = '' + try { + const { stdout } = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'utf-8', + maxBuffer: DEFAULT_GIT_MAX_BUFFER, + timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS, + env: promptEnv, + signal: options.signal + }) + configuredCommand = String(stdout).trim() + } catch { + configuredCommand = '' + } + + if (!configuredCommand) { + return { env: { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }, mode: 'fallback' } + } + + const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) + if (!batchModeCommand) { + // Why: custom wrappers are executable user policy; rewriting their argv is + // riskier than relying on prompt guards plus the caller's target timeout. + return { env: promptEnv, mode: 'configured-wrapper-passthrough' } + } + + return { + env: { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }, + mode: 'configured-openssh' + } +} + /** * Async git command execution. Drop-in replacement for * `execFileAsync('git', args, { cwd, encoding, ... })`. @@ -563,16 +730,28 @@ export async function gitExecFileAsync( const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { useWslLoginShell: Boolean(options.wslDistro) }) - const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - // Why: never let a git read-path call block on an interactive prompt - // (issue #5308) — fail fast instead of hanging the runtime. - env: nonInteractiveGitEnv(options.env), - signal: options.signal - }) + const policy = options.useConfiguredSshCommandForNetwork + ? await buildNetworkSshPolicyEnv(options) + : { env: nonInteractiveGitEnv(options.env), mode: 'default' as const } + let result: { stdout: string | Buffer; stderr: string | Buffer } + try { + result = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + // Why: never let a git read-path call block on an interactive prompt + // (issue #5308) — fail fast instead of hanging the runtime. + env: policy.env, + signal: options.signal + }) + } catch (error) { + if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { + Object.assign(error, { gitSshPolicyMode: policy.mode }) + } + throw error + } + const { stdout, stderr } = result return { stdout: stdout as string, stderr: stderr as string } } ) diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 848465d49a5..514594eb64c 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -28,6 +28,14 @@ function fetchCallCount(): number { ).length } +function exactBaseRefreshOptions(cwd: string): { + cwd: string + timeout: number + useConfiguredSshCommandForNetwork: boolean +} { + return { cwd, timeout: 60_000, useConfiguredSshCommandForNetwork: true } +} + function mockFetchResults(results: (Promise | unknown)[]): void { let fetchIndex = 0 gitExecFileAsyncMock.mockImplementation((argv: string[]) => { @@ -175,7 +183,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - { cwd: '/repo/f' } + exactBaseRefreshOptions('/repo/f') ) }) @@ -289,7 +297,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { expect(fetchCalls).toEqual([ [ ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - { cwd: '/repo/h' } + exactBaseRefreshOptions('/repo/h') ], [['fetch', 'origin'], { cwd: '/repo/h' }] ]) @@ -336,7 +344,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { [['fetch', 'origin'], { cwd: '/repo/i' }], [ ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - { cwd: '/repo/i' } + exactBaseRefreshOptions('/repo/i') ] ]) }) @@ -382,7 +390,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { [['fetch', 'origin'], { cwd: '/repo/i-fail' }], [ ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - { cwd: '/repo/i-fail' } + exactBaseRefreshOptions('/repo/i-fail') ] ]) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 20f03052662..756f7244962 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1915,7 +1915,11 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => { expect(gitSpy).toHaveBeenCalledWith( ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - { cwd: TEST_REPO_PATH } + { + cwd: TEST_REPO_PATH, + useConfiguredSshCommandForNetwork: true, + timeout: 60_000 + } ) }) expect(addWorktree).not.toHaveBeenCalled() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3b8c8dca588..d20b86a7c65 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11558,7 +11558,14 @@ export class OrcaRuntimeService { } return gitExecFileAsync( ['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`], - { cwd: repoPath, ...gitOptions } + { + cwd: repoPath, + ...gitOptions, + // Why: exact remote-base refresh is the network gate for worktree + // creation, so honor repo SSH routing and bound custom wrappers. + useConfiguredSshCommandForNetwork: true, + timeout: 60_000 + } ) .then((): RemoteFetchResult => { this.rememberFreshFetchCompletedAt(key) From fe8b60b11c4cbe987fe70b2749742d0468aab878 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:17:28 -0700 Subject: [PATCH 07/16] Show branch cues in experimental worktree cards (#5737) --- .../src/components/sidebar/WorktreeCard.tsx | 1 + .../sidebar/WorktreeCardStatusSlot.test.tsx | 51 +++++++++++++++++-- .../sidebar/WorktreeCardStatusSlot.tsx | 49 ++++++++++++++++-- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 8 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 454367f3b1c..dbb74f9e7b7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1209,6 +1209,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ onToggleUnread={handleToggleUnreadQuick} prDisplay={hoverReview} newCardStyle={newCardStyle} + hasBranchIdentity={!isFolder && branch.length > 0} /> ) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 1bc33db2c46..d916b12e55e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -148,6 +148,46 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) + it('uses a branch icon instead of the quiet active dot when new card style has no review', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Branch') + expect(markup).toContain('lucide-git-branch') + expect(markup).toContain('text-muted-foreground/70') + expect(markup).not.toContain('bg-emerald-500') + }) + + it('keeps the quiet dot when the row has no branch identity', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Active') + expect(markup).toContain('bg-emerald-500') + expect(markup).not.toContain('lucide-git-branch') + }) + it('keeps working activity ahead of PR status in new card style', () => { mocks.status = 'working' const markup = renderToStaticMarkup( @@ -237,23 +277,26 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-emerald-500') }) - it('keeps the new style unread status dot in the stable lane footprint', () => { + it('overlays unread on the no-review branch icon in new card style', () => { const markup = renderToStaticMarkup( ) - expect(markup).toContain('Active · Mark as unread') + expect(markup).toContain('Branch · Mark as read') expect(markup).toContain( 'group/unread relative flex cursor-pointer items-center justify-center rounded transition-all size-5' ) + expect(markup).toContain('lucide-git-branch') + expect(markup).toContain('absolute -right-1 -top-1 size-[13px] text-amber-500') + expect(markup).not.toContain('bg-emerald-500') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 82b6fd2f2e1..e15a41cda95 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Bell } from 'lucide-react' +import { Bell, GitBranch } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' @@ -19,10 +19,15 @@ type WorktreeCardStatusSlotProps = { onPointerDown: React.PointerEventHandler prDisplay?: WorktreeCardPrDisplay | null newCardStyle?: boolean + hasBranchIdentity?: boolean className?: string } const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive']) +// Why: a missing review display can also mean provider state is unavailable, +// so the passive label names the branch cue without claiming no review exists. +const BRANCH_STATUS_LABEL = 'Branch' +const branchStatusIconClassName = 'size-4 text-muted-foreground/70' function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { const label = getReviewLabel(review) @@ -57,6 +62,7 @@ export function WorktreeCardStatusSlot({ onPointerDown, prDisplay = null, newCardStyle = false, + hasBranchIdentity = true, className }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) @@ -66,9 +72,20 @@ export function WorktreeCardStatusSlot({ showStatus && prDisplay !== null && QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) + const canShowBranchStatus = + newCardStyle && + showStatus && + hasBranchIdentity && + prDisplay === null && + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = - canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) : statusLabel + canShowReviewStatus && prDisplay + ? getReviewStatusTooltip(prDisplay) + : canShowBranchStatus + ? BRANCH_STATUS_LABEL + : statusLabel const reviewStatusIconClassName = 'size-4' + const branchStatusIcon =