Show and Filter Automation-Created Workspaces (#5697)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
281edba14a
commit
674639205c
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<string, DispatchTokenRecord>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<AutomationRun, 'id' | 'title' | 'scheduledFor'>
|
||||
type RuntimeCreateManagedWorktreeArgs = Parameters<OrcaRuntimeService['createManagedWorktree']>[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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AutomationRun> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
|
||||
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ const ORCA_CREATION_SOURCES = new Set<NonNullable<WorktreeMeta['orcaCreationSour
|
|||
const ORCA_OWNED_PROVENANCE_META_KEYS = [
|
||||
'orcaCreatedAt',
|
||||
'orcaCreationSource',
|
||||
'orcaCreationWorkspaceLayout'
|
||||
'orcaCreationWorkspaceLayout',
|
||||
'automationProvenance'
|
||||
] as const
|
||||
type UnregisteredOrcaCleanupMeta = Pick<
|
||||
WorktreeMeta,
|
||||
|
|
|
|||
|
|
@ -573,6 +573,7 @@ function App(): React.JSX.Element {
|
|||
const projectOrderBy = useAppStore((s) => 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Automation[]>([])
|
||||
const [runs, setRuns] = useState<AutomationRun[]>([])
|
||||
const [automationHostTargetKey, setAutomationHostTargetKey] = useState<string | null>(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<string>()
|
||||
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<void> => {
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,13 @@ type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'w
|
|||
workspace?: string
|
||||
}
|
||||
|
||||
type AutomationHostTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
|
||||
export type AutomationHostTarget =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'environment'; environmentId: string }
|
||||
|
||||
function getRuntimeTargetFromHostId(hostId: string | null | undefined): AutomationHostTarget {
|
||||
export function getAutomationTargetFromHostId(
|
||||
hostId: string | null | undefined
|
||||
): AutomationHostTarget {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
return parsed?.kind === 'runtime'
|
||||
? { kind: 'environment', environmentId: parsed.environmentId }
|
||||
|
|
@ -41,11 +45,11 @@ export function getAutomationListTarget(
|
|||
export function getAutomationOwnerTarget(
|
||||
automation: Pick<Automation, 'runContext'>
|
||||
): 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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<FilterToggleRow
|
||||
icon={<Workflow className="size-3.5" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarFilter.automationCreated',
|
||||
'Hide automation-created'
|
||||
)}
|
||||
checked={hideAutomationGeneratedWorkspaces}
|
||||
onChange={setHideAutomationGeneratedWorkspaces}
|
||||
/>
|
||||
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<FilterToggleRow
|
||||
icon={<Workflow className="size-3.5" />}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated',
|
||||
'Hide automation-created'
|
||||
)}
|
||||
checked={hideAutomationGeneratedWorkspaces}
|
||||
onChange={setHideAutomationGeneratedWorkspaces}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'}`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'automation-1',
|
||||
automationNameSnapshot: 'Nightly triage automation',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly triage run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'repo-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local'
|
||||
}
|
||||
})}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Created by automation')
|
||||
expect(markup).not.toContain('>Automation</span>')
|
||||
})
|
||||
|
||||
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(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'automation-1',
|
||||
automationNameSnapshot: 'Nightly triage automation',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly triage run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'repo-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local'
|
||||
}
|
||||
})}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Created by automation')
|
||||
expect(markup).not.toContain('>Automation</span>')
|
||||
})
|
||||
|
||||
it('hides automation-created card surfaces when the Automation property is disabled', async () => {
|
||||
worktreeCardProperties = ['status']
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderWorktreeCardMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'automation-1',
|
||||
automationNameSnapshot: 'Nightly triage automation',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly triage run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'repo-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local'
|
||||
}
|
||||
})}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('Created by automation')
|
||||
expect(markup).not.toContain('>Automation</span>')
|
||||
expect(markup).not.toContain('Nightly triage automation')
|
||||
})
|
||||
|
||||
it('hides live port metadata when the Ports card property is disabled', async () => {
|
||||
const worktree = makeWorktree()
|
||||
workspacePortScan = {
|
||||
|
|
|
|||
|
|
@ -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 ? <WorktreeCardPortsDetails ports={workspacePorts} /> : 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={
|
||||
|
|
|
|||
|
|
@ -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<AutomationProvenanceAvailability>({
|
||||
status: 'checking'
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function resolveAvailability(): Promise<void> {
|
||||
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 (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
icon={<CalendarClock className="size-3 text-muted-foreground" />}
|
||||
label={translate('auto.components.sidebar.WorktreeCardMeta.automationHeader', 'Automation')}
|
||||
actions={
|
||||
<>
|
||||
{onOpenAutomation && canOpenAutomation && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.openAutomation',
|
||||
'Open automation'
|
||||
)}
|
||||
onClick={onOpenAutomation}
|
||||
>
|
||||
<CalendarClock className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
{onOpenAutomationRun && canOpenAutomationRun && (
|
||||
<MetadataActionIcon
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.openAutomationRun',
|
||||
'Open run'
|
||||
)}
|
||||
onClick={onOpenAutomationRun}
|
||||
>
|
||||
<PlayCircle className="size-3" />
|
||||
</MetadataActionIcon>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<WorktreeCardDetailSectionContent className="space-y-1.5">
|
||||
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
|
||||
{provenance.automationNameSnapshot}
|
||||
</div>
|
||||
<div className="text-[11.5px] leading-snug text-muted-foreground break-words">
|
||||
{provenance.automationRunTitleSnapshot}
|
||||
</div>
|
||||
{availability.status === 'checking' ? (
|
||||
<div className="text-[11px] leading-snug text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.checkingAutomationAvailability',
|
||||
'Checking automation availability...'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{availability.status === 'automation-missing' ? (
|
||||
<div className="text-[11px] leading-snug text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.automationMissing',
|
||||
'Automation no longer available.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{availability.status === 'available' && !availability.runAvailable ? (
|
||||
<div className="text-[11px] leading-snug text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.automationRunMissing',
|
||||
'Run history no longer available.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{availability.status === 'unavailable' ? (
|
||||
<div className="text-[11px] leading-snug text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.automationAvailabilityUnavailable',
|
||||
'Automation availability could not be checked.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</WorktreeCardDetailSectionContent>
|
||||
</WorktreeCardDetailSection>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<
|
|||
<StickyNote className="text-muted-foreground" />
|
||||
</MetaIconBadge>
|
||||
)}
|
||||
{automationProvenance && (
|
||||
<MetaIconBadge
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardMeta.automationCreated',
|
||||
'Created by automation'
|
||||
)}
|
||||
>
|
||||
<CalendarClock className="text-muted-foreground" />
|
||||
</MetaIconBadge>
|
||||
)}
|
||||
{issue && (
|
||||
<MetaIconBadge
|
||||
label={translate(
|
||||
|
|
@ -121,10 +133,12 @@ export function WorktreeCardDetailsHover({
|
|||
linearIssue,
|
||||
review,
|
||||
comment,
|
||||
automationProvenance,
|
||||
children,
|
||||
branchName,
|
||||
workspaceTitle,
|
||||
identityOrder = 'workspace-first',
|
||||
automationHostId,
|
||||
detailsAfter,
|
||||
openDelay = 250,
|
||||
closeDelay = 120,
|
||||
|
|
@ -134,6 +148,8 @@ export function WorktreeCardDetailsHover({
|
|||
onOpenLinearIssueInOrca,
|
||||
onOpenReviewInOrca,
|
||||
onUnlinkReview,
|
||||
onOpenAutomation,
|
||||
onOpenAutomationRun,
|
||||
hoverControl
|
||||
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
|
||||
const internalHoverControl = useWorktreeCardDetailsHoverControl()
|
||||
|
|
@ -156,7 +172,7 @@ export function WorktreeCardDetailsHover({
|
|||
|
||||
if (
|
||||
!showIdentityHeader &&
|
||||
!hasWorktreeCardDetails({ issue, linearIssue, review, comment }) &&
|
||||
!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance }) &&
|
||||
!detailsAfter
|
||||
) {
|
||||
return children
|
||||
|
|
@ -343,6 +359,17 @@ export function WorktreeCardDetailsHover({
|
|||
closeHover={closeHover}
|
||||
/>
|
||||
|
||||
{automationProvenance && (
|
||||
<WorktreeCardAutomationDetailSection
|
||||
provenance={automationProvenance}
|
||||
worktreeHostId={automationHostId}
|
||||
onOpenAutomation={onOpenAutomation ? dismissAndRun(onOpenAutomation) : undefined}
|
||||
onOpenAutomationRun={
|
||||
onOpenAutomationRun ? dismissAndRun(onOpenAutomationRun) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasComment(comment) && (
|
||||
<WorktreeCardDetailSection>
|
||||
<DetailHeader
|
||||
|
|
|
|||
|
|
@ -4380,6 +4380,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
const projectOrderBy = useAppStore((s) => 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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
|
|||
ptyIdsByTabId: {},
|
||||
browserTabsByWorktree: {},
|
||||
hideDefaultBranchWorkspace: false,
|
||||
hideAutomationGeneratedWorkspaces: false,
|
||||
repoMap,
|
||||
workspaceHostScope: 'all',
|
||||
defaultHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
|
|
@ -96,6 +97,7 @@ function filterState(overrides: Partial<FilterState> = {}): 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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, Repo>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
|
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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 ブランチを検索...",
|
||||
|
|
|
|||
|
|
@ -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 브랜치 검색...",
|
||||
|
|
|
|||
|
|
@ -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 分支...",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>
|
||||
setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void
|
||||
toggleShowDotfilesForWorktree: (worktreeId: string) => void
|
||||
|
|
@ -1310,6 +1320,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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<CreateWorktreeResult>
|
||||
/** Register an in-flight background creation and make it the active surface. */
|
||||
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void
|
||||
|
|
|
|||
|
|
@ -1584,8 +1584,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
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<AppState, [], [], WorktreeSlice>
|
|||
...(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<AppState, [], [], WorktreeSlice>
|
|||
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
|
||||
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
|
||||
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
|
||||
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
|
||||
...(startup
|
||||
? {
|
||||
startupCommand: startup.command,
|
||||
|
|
|
|||
|
|
@ -1132,7 +1132,8 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['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
|
||||
|
|
|
|||
|
|
@ -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<AutomationRun, 'id' | 'title' | 'runContext'>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +183,7 @@ export type AutomationUpdateInput = Partial<
|
|||
export type AutomationDispatchRequest = {
|
||||
automation: Automation
|
||||
run: AutomationRun
|
||||
dispatchToken: string
|
||||
}
|
||||
|
||||
export type AutomationDispatchResult = {
|
||||
|
|
|
|||
|
|
@ -444,6 +444,7 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
workspaceHostOrder: [],
|
||||
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
hideAutomationGeneratedWorkspaces: false,
|
||||
showDotfilesByWorktree: {},
|
||||
filterRepoIds: [],
|
||||
collapsedGroups: [],
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>
|
||||
filterRepoIds: string[]
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue