From b93849a577671857e4a97efc11dc3fe38a50cc29 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:41:30 -0700 Subject: [PATCH 001/177] Add inline diff note edit E2E coverage (#1951) * Add inline diff note edit coverage * test: add diff note edit coverage --- tests/e2e/diff-note-edit.spec.ts | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/e2e/diff-note-edit.spec.ts diff --git a/tests/e2e/diff-note-edit.spec.ts b/tests/e2e/diff-note-edit.spec.ts new file mode 100644 index 00000000000..9d465d6fa76 --- /dev/null +++ b/tests/e2e/diff-note-edit.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +test.describe('Diff note edit', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('editing a saved inline note updates the open diff card', async ({ orcaPage }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + const seededBody = 'edit-me note' + const editedBody = 'edited note from the inline card' + + // Why: create a real modified-file diff so Monaco mounts the saved-note + // view zone on the same local surface that wires updateDiffComment. + const { relativePath } = await orcaPage.evaluate(async (wId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available - is the app in dev mode?') + } + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === wId) + if (!worktree) { + throw new Error('active worktree not found') + } + const separator = worktree.path.includes('\\') ? '\\' : '/' + const rel = `src${separator}index.ts` + const absolutePath = `${worktree.path}${separator}${rel}` + await window.api.fs.writeFile({ + filePath: absolutePath, + content: 'export const hello = "note-edit-test"\n' + }) + return { relativePath: rel } + }, worktreeId) + + const addResult = await orcaPage.evaluate( + async ({ wId, rel, body }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + return store.getState().addDiffComment({ + worktreeId: wId, + filePath: rel, + lineNumber: 1, + body, + side: 'modified' + }) + }, + { wId: worktreeId, rel: relativePath, body: seededBody } + ) + expect(addResult, 'addDiffComment returned null').not.toBeNull() + const commentId = addResult!.id + + await orcaPage.evaluate( + ({ wId, rel }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === wId) + if (!worktree) { + throw new Error('active worktree not found') + } + const separator = worktree.path.includes('\\') ? '\\' : '/' + state.openDiff(wId, `${worktree.path}${separator}${rel}`, rel, 'typescript', false) + }, + { wId: worktreeId, rel: relativePath } + ) + + const card = orcaPage.locator('.orca-diff-comment-card').first() + await expect(card, 'seeded inline note did not render').toBeVisible({ timeout: 15_000 }) + await expect(card.locator('.orca-diff-comment-body')).toHaveText(seededBody) + + await card.locator('.orca-diff-comment-edit').click() + + const textarea = card.locator('.orca-diff-comment-popover-textarea') + await expect(textarea).toBeVisible() + await expect(textarea).toHaveValue(seededBody) + + const saveButton = card + .locator('.orca-diff-comment-popover-footer button') + .filter({ hasText: 'Save' }) + await expect(saveButton, 'Save should be disabled before the body changes').toBeDisabled() + + await textarea.fill(editedBody) + await expect(saveButton, 'Save should be enabled for a non-empty changed body').toBeEnabled() + await saveButton.click() + + await expect(textarea, 'edit controls did not close after saving').toHaveCount(0, { + timeout: 5_000 + }) + + await expect + .poll( + async () => + orcaPage.evaluate((id: string) => { + const store = window.__store + if (!store) { + return null + } + const all = Object.values(store.getState().worktreesByRepo) + .flat() + .flatMap((w) => w.diffComments ?? []) + const comment = all.find((c) => c.id === id) + return comment?.body ?? null + }, commentId), + { + timeout: 5_000, + message: 'updateDiffComment did not persist the edited body in the store' + } + ) + .toBe(editedBody) + + const updatedCard = orcaPage + .locator('.orca-diff-comment-card') + .filter({ has: orcaPage.locator('.orca-diff-comment-body', { hasText: editedBody }) }) + .first() + await expect(updatedCard, 'inline card did not update in the open diff').toBeVisible() + await expect(updatedCard.locator('.orca-diff-comment-body')).toHaveText(editedBody) + await expect(updatedCard.locator('.orca-diff-comment-body')).not.toHaveText(seededBody) + await expect(updatedCard.locator('.orca-diff-comment-edit')).toBeVisible() + }) +}) From 12609a50e33de5e858de78618f2b1a437e7a389b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:42:44 -0700 Subject: [PATCH 002/177] fix: address review findings (#1981) --- src/renderer/src/components/TaskPage.tsx | 12 +-- .../dashboard/DashboardAgentRow.tsx | 102 +++++++----------- .../sidebar/useWorktreeAgentRows.ts | 2 +- .../task-page-linear-team-selection.test.ts | 37 +++++++ .../task-page-linear-team-selection.ts | 19 ++++ src/renderer/src/components/ui/button.tsx | 23 ++-- .../migration-unsupported-agent-entry.test.ts | 33 ++++++ .../lib/migration-unsupported-agent-entry.ts | 46 +++++--- 8 files changed, 174 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/task-page-linear-team-selection.test.ts create mode 100644 src/renderer/src/components/task-page-linear-team-selection.ts create mode 100644 src/renderer/src/lib/migration-unsupported-agent-entry.test.ts diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index a5b260ae00f..37cc7d74a79 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -58,6 +58,7 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill' +import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' import { stripRepoQualifiers } from '../../../shared/task-query' import GitHubItemDialog from '@/components/GitHubItemDialog' import GitLabItemDialog from '@/components/GitLabItemDialog' @@ -1078,15 +1079,10 @@ export default function TaskPage(): React.JSX.Element { return new Set(defaultLinearTeamSelection) }) - // Why: in sticky-all mode, auto-include all teams once the list arrives. - // In explicit-selection mode, the set is already correct from the initializer. + // Why: team IDs belong to one Linear workspace. Switching workspaces while a + // saved subset exists must not leave the task list filtered by stale team IDs. useEffect(() => { - if (availableTeams.length === 0) { - return - } - if (!defaultLinearTeamSelection) { - setLinearTeamSelection(new Set(availableTeams.map((t) => t.id))) - } + setLinearTeamSelection(reconcileLinearTeamSelection(availableTeams, defaultLinearTeamSelection)) }, [availableTeams, defaultLinearTeamSelection]) const displayedLinearIssues = useMemo( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 6b9f841b8d4..e8c6cbcea2f 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback } from 'react' import { X, Wrench, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' -import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' @@ -222,16 +221,12 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ bar + right-side dot combo, which double-encoded state. Size md gives the glyph enough presence for the leading slot without overpowering the prompt text. */} - - - - - - - - {agent.entry.interrupted ? 'Interrupted' : agentStateLabel(asDotState(agent.state))} - - + + + {/* Why: identity (Claude/Codex/Gemini/…) sits inline with the prompt so the reader gets "state → who → what they said" left-to-right on the top row. The sub-rows (tool step, assistant response) are @@ -239,16 +234,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ them — keeping the icon only on the prompt row lets the sub-rows indent under the prompt text cleanly. */} {!hideIdentityIcon && ( - - - - - - - - {formatAgentTypeLabel(agent.agentType)} - - + + + )} {/* Why: animate between a 1-line clipped height and the content's natural height using Chromium's `interpolate-size: allow-keywords` @@ -321,27 +309,21 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ ? formatTimeAgo(startedAt, now) : null} - - - - - - Dismiss - - + )} {/* Why: when there is no timestamp yet (fresh agent, never @@ -350,27 +332,21 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ reachable. Rare path; most rows have a timestamp the moment they start. */} {startedAt === null && doneAt === null && ( - - - - - - Dismiss - - + )} {/* Why: chevron points down when collapsed (content below is available) and rotates 180° to point up when expanded diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 20d13af57db..d0286bbeb20 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -115,7 +115,7 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { } out.push(entry) } - for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) { const entry = migrationUnsupportedToAgentStatusEntry(unsupported) if (!entry) { continue diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts new file mode 100644 index 00000000000..a3d1a207b12 --- /dev/null +++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { LinearTeam } from '../../../shared/types' +import { reconcileLinearTeamSelection } from './task-page-linear-team-selection' + +function team(id: string): LinearTeam { + return { + id, + name: id, + key: id.toUpperCase() + } +} + +describe('reconcileLinearTeamSelection', () => { + it('selects every available team when the saved selection is sticky-all', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], null))).toEqual([ + 'a', + 'b' + ]) + }) + + it('preserves saved teams that still exist', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], ['b']))).toEqual(['b']) + }) + + it('drops stale saved teams after switching workspaces', () => { + expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'd']))).toEqual([ + 'd' + ]) + }) + + it('falls back to all current teams when every saved team is stale', () => { + expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'b']))).toEqual([ + 'c', + 'd' + ]) + }) +}) diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts new file mode 100644 index 00000000000..f6d7e52fa1f --- /dev/null +++ b/src/renderer/src/components/task-page-linear-team-selection.ts @@ -0,0 +1,19 @@ +import type { LinearTeam } from '../../../shared/types' + +export function reconcileLinearTeamSelection( + availableTeams: LinearTeam[], + storedSelection: readonly string[] | null | undefined +): ReadonlySet { + const availableIds = availableTeams.map((team) => team.id) + if (availableIds.length === 0) { + return new Set() + } + + const availableIdSet = new Set(availableIds) + const validStoredSelection = (storedSelection ?? []).filter((id) => availableIdSet.has(id)) + if (validStoredSelection.length > 0) { + return new Set(validStoredSelection) + } + + return new Set(availableIds) +} diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index d87333920c9..d73a70461d4 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -36,20 +36,21 @@ const buttonVariants = cva( } ) -function Button({ - className, - variant = 'default', - size = 'default', - asChild = false, - ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + } +>(function Button( + { className, variant = 'default', size = 'default', asChild = false, ...props }, + ref +) { const Comp = asChild ? Slot.Root : 'button' return ( ) -} +}) export { Button, buttonVariants } diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts new file mode 100644 index 00000000000..cdb59cc02e1 --- /dev/null +++ b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { migrationUnsupportedToAgentStatusEntry } from './migration-unsupported-agent-entry' +import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' + +describe('migrationUnsupportedToAgentStatusEntry', () => { + it('returns a stable synthetic entry for the same migration record', () => { + const unsupported: MigrationUnsupportedPtyEntry = { + ptyId: 'pty-1', + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1234 + } + + const first = migrationUnsupportedToAgentStatusEntry(unsupported) + const second = migrationUnsupportedToAgentStatusEntry(unsupported) + + expect(second).toBe(first) + expect(first?.updatedAt).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('caches null for records that cannot be projected to a pane key', () => { + const unsupported: MigrationUnsupportedPtyEntry = { + ptyId: 'pty-1', + reason: 'legacy-numeric-pane-key', + source: 'ssh', + updatedAt: 1234 + } + + expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull() + expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.ts index 5f3e83496c9..20033a486c5 100644 --- a/src/renderer/src/lib/migration-unsupported-agent-entry.ts +++ b/src/renderer/src/lib/migration-unsupported-agent-entry.ts @@ -3,25 +3,37 @@ import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +const cachedMigrationUnsupportedEntries = new WeakMap< + MigrationUnsupportedPtyEntry, + AgentStatusEntry | null +>() + export function migrationUnsupportedToAgentStatusEntry( entry: MigrationUnsupportedPtyEntry ): AgentStatusEntry | null { - if (!entry.paneKey) { - return null - } - const now = Date.now() - return { - state: 'blocked', - prompt: 'Agent unavailable after pane identity migration', - // Why: this is a persistent migration block, not a hook heartbeat. Keep it - // fresh while present so normal stale-status decay does not hide it. - updatedAt: Math.max(entry.updatedAt, now), - stateStartedAt: entry.updatedAt, - agentType: 'unknown', - paneKey: entry.paneKey, - terminalTitle: 'Migration unsupported', - stateHistory: [], - lastAssistantMessage: - 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.' + const cached = cachedMigrationUnsupportedEntries.get(entry) + if (cached !== undefined) { + return cached } + + const converted: AgentStatusEntry | null = !entry.paneKey + ? null + : { + state: 'blocked', + prompt: 'Agent unavailable after pane identity migration', + // Why: this synthetic row represents a persistent migration block. Keep + // it "fresh" without Date.now() so Zustand selectors can return a stable + // cached object for the same store snapshot. + updatedAt: Number.MAX_SAFE_INTEGER, + stateStartedAt: entry.updatedAt, + agentType: 'unknown', + paneKey: entry.paneKey, + terminalTitle: 'Migration unsupported', + stateHistory: [], + lastAssistantMessage: + 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.' + } + + cachedMigrationUnsupportedEntries.set(entry, converted) + return converted } From 027f561b5bba2d7a9d903a55811b14bdaac818e2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:43:07 -0700 Subject: [PATCH 003/177] fix: speed up quick workspace create (#1985) --- .../components/NewWorkspaceComposerModal.tsx | 4 +- src/renderer/src/hooks/useComposerState.ts | 129 +++++++++++++++--- .../lib/new-workspace-create-gates.test.ts | 61 +++++++++ .../src/lib/new-workspace-create-gates.ts | 36 +++++ 4 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/lib/new-workspace-create-gates.test.ts create mode 100644 src/renderer/src/lib/new-workspace-create-gates.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 9db64d763cd..b7e5958a1ee 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -112,7 +112,9 @@ function QuickTabBody({ ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, onCreated: onClose, - ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}) + ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}), + enableIssueAutomation: false, + createGateMode: 'quick' }) // Why: the composer's built-in `onOpenAgentSettings` handler navigates to // the settings page and closes the modal. For the quick-create flow we want diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 0ab7b97f693..3ff303c7bb5 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -46,13 +46,21 @@ import { renderIssueCommandTemplate, type LinkedWorkItemSummary } from '@/lib/new-workspace' +import { + getFullComposerCreateDisabled, + getQuickComposerCreateDisabled +} from '@/lib/new-workspace-create-gates' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' import { joinPath } from '@/lib/path' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' -import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { + checkRuntimeHooks, + readRuntimeIssueCommand, + type HookCheckResult +} from '@/runtime/runtime-hooks-client' export type UseComposerStateOptions = { initialRepoId?: string @@ -80,6 +88,11 @@ export type UseComposerStateOptions = { * `sidebar`, keyboard shortcut → `shortcut`). Omitted callers default * to `unknown` at the IPC boundary. */ telemetrySource?: WorkspaceCreateTelemetrySource + /** Quick-create launches a blank/draft agent session and does not run + * issueCommand automation, so it can skip the issue-command probe that the + * full composer needs for linked-item prompt previews. */ + enableIssueAutomation?: boolean + createGateMode?: 'full' | 'quick' } export type ComposerCardProps = { @@ -198,7 +211,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS onCreated, repoIdOverride, onRepoIdOverrideChange, - telemetrySource + telemetrySource, + enableIssueAutomation = true, + createGateMode = 'full' } = options // Why: each `useAppStore(s => s.someAction)` registers its own equality @@ -256,6 +271,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId) const repoId = repoIdOverride ?? internalRepoId + const repoIdRef = useRef(repoId) + repoIdRef.current = repoId const setRepoId = useCallback( (value: string) => { if (onRepoIdOverrideChange) { @@ -414,6 +431,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoPathRef.current = selectedRepoPath const settingsRef = useRef(settings) settingsRef.current = settings + const hookCheckRef = useRef<{ + key: string + promise: Promise + } | null>(null) + const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise => { + const key = `${settingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}` + const existing = hookCheckRef.current + if (existing?.key === key) { + return existing.promise + } + const promise = checkRuntimeHooks(settingsRef.current, targetRepoId) + hookCheckRef.current = { key, promise } + return promise + }, []) + const commitHookCheckIfCurrent = useCallback( + (targetRepoId: string, hooks: OrcaHooks | null): boolean => { + if (repoIdRef.current !== targetRepoId) { + return false + } + setYamlHooks(hooks) + setCheckedHooksRepoId(targetRepoId) + return true + }, + [] + ) useEffect(() => { if (!selectedRepo || !selectedRepoPath) { setSelectedRepoSlug(null) @@ -515,14 +557,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [selectedRepo, yamlHooks] ) const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default' - const hasIssueAutomationConfig = issueCommandTemplate.length > 0 + const hasIssueAutomationConfig = enableIssueAutomation && issueCommandTemplate.length > 0 const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig // Why: the "no prompt + linked item" path below rehydrates the issueCommand // template into the main startup prompt. When that happens we suppress the // separate split pane that would otherwise run the same command twice. - const willApplyIssueCommandAsPrompt = !agentPrompt.trim() && Boolean(linkedWorkItem) + const willApplyIssueCommandAsPrompt = + enableIssueAutomation && !agentPrompt.trim() && Boolean(linkedWorkItem) const shouldWaitForIssueAutomationCheck = - (parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) && !hasLoadedIssueCommand + enableIssueAutomation && + (parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) && + !hasLoadedIssueCommand const shouldRunIssueAutomation = canOfferIssueAutomation && !willApplyIssueCommandAsPrompt const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask' const resolvedSetupDecision = @@ -561,7 +606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // the common "paste a link and hit enter" flow produce a useful agent task // instead of a bare URL bullet. const shouldApplyLinkedOnlyTemplate = - !agentPrompt.trim() && Boolean(linkedWorkItem) && hasLoadedIssueCommand + enableIssueAutomation && !agentPrompt.trim() && Boolean(linkedWorkItem) && hasLoadedIssueCommand const linkedOnlyTemplatePrompt = useMemo(() => { if (!shouldApplyLinkedOnlyTemplate || !linkedWorkItem) { return '' @@ -712,20 +757,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setYamlHooks(null) setCheckedHooksRepoId(null) - void checkRuntimeHooks(settings, repoId) + void loadHookCheckForRepo(repoId) .then((result) => { if (!cancelled) { - setYamlHooks(result.hooks) - setCheckedHooksRepoId(repoId) + commitHookCheckIfCurrent(repoId, result.hooks) } }) .catch(() => { if (!cancelled) { - setYamlHooks(null) - setCheckedHooksRepoId(repoId) + commitHookCheckIfCurrent(repoId, null) } }) + if (!enableIssueAutomation) { + setHasLoadedIssueCommand(true) + return () => { + cancelled = true + } + } + void readRuntimeIssueCommand(settings, repoId) .then((result) => { if (!cancelled) { @@ -743,7 +793,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [repoId, settings]) + }, [commitHookCheckIfCurrent, enableIssueAutomation, loadHookCheckForRepo, repoId, settings]) // Why: warm the Start-from picker's PR cache on composer mount and whenever // the selected repo changes so opening the picker paints instantly from @@ -1673,7 +1723,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS !repoId || !workspaceName || !selectedRepo || - shouldWaitForSetupCheck || (requiresExplicitSetupChoice && !setupDecision) || sparseError !== null ) { @@ -1683,11 +1732,37 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCreateError(null) setCreating(true) try { + let submitSetupConfig = setupConfig + let submitResolvedSetupDecision = resolvedSetupDecision + if (checkedHooksRepoId !== repoId) { + let hookCheck: HookCheckResult + try { + hookCheck = await loadHookCheckForRepo(repoId) + } catch { + hookCheck = { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + if (!commitHookCheckIfCurrent(repoId, hookCheck.hooks)) { + return + } + submitSetupConfig = getSetupConfig(selectedRepo, hookCheck.hooks) + submitResolvedSetupDecision = + setupDecision ?? + (!submitSetupConfig || setupPolicy === 'ask' + ? null + : setupPolicy === 'run-by-default' + ? 'run' + : 'skip') + } + if (submitSetupConfig && setupPolicy === 'ask' && !setupDecision) { + setAdvancedOpen(true) + return + } + const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') const effectiveSetupDecision: SetupDecision = trustDecision === 'skip' ? 'skip' - : ((resolvedSetupDecision ?? 'inherit') as SetupDecision) + : ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision) const linkedLinearIssue = linkedWorkItem?.linearIdentifier const result = await createWorktree( @@ -1858,18 +1933,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError, effectivePresetId, telemetrySource, - shouldWaitForSetupCheck + checkedHooksRepoId, + commitHookCheckIfCurrent, + loadHookCheckForRepo, + setupConfig, + setupPolicy ] ) + const createGateInput = { + repoId, + workspaceSeedName, + creating, + shouldWaitForSetupCheck, + shouldWaitForIssueAutomationCheck, + requiresExplicitSetupChoice, + hasSetupDecision: Boolean(setupDecision), + sparseError + } const createDisabled = - !repoId || - !workspaceSeedName || - creating || - shouldWaitForSetupCheck || - shouldWaitForIssueAutomationCheck || - (requiresExplicitSetupChoice && !setupDecision) || - sparseError !== null + createGateMode === 'quick' + ? getQuickComposerCreateDisabled(createGateInput) + : getFullComposerCreateDisabled(createGateInput) const cardProps: ComposerCardProps = { eligibleRepos, diff --git a/src/renderer/src/lib/new-workspace-create-gates.test.ts b/src/renderer/src/lib/new-workspace-create-gates.test.ts new file mode 100644 index 00000000000..d23102ddb3b --- /dev/null +++ b/src/renderer/src/lib/new-workspace-create-gates.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + getFullComposerCreateDisabled, + getQuickComposerCreateDisabled, + type ComposerCreateGateInput +} from './new-workspace-create-gates' + +const readyInput: ComposerCreateGateInput = { + repoId: 'repo-1', + workspaceSeedName: 'feature', + creating: false, + shouldWaitForSetupCheck: false, + shouldWaitForIssueAutomationCheck: false, + requiresExplicitSetupChoice: false, + hasSetupDecision: false, + sparseError: null +} + +describe('new workspace create gates', () => { + it('keeps the full composer disabled while setup and issue automation probes are pending', () => { + expect( + getFullComposerCreateDisabled({ + ...readyInput, + shouldWaitForSetupCheck: true + }) + ).toBe(true) + + expect( + getFullComposerCreateDisabled({ + ...readyInput, + shouldWaitForIssueAutomationCheck: true + }) + ).toBe(true) + }) + + it('lets quick create submit while background setup and issue probes are pending', () => { + expect( + getQuickComposerCreateDisabled({ + ...readyInput, + shouldWaitForSetupCheck: true, + shouldWaitForIssueAutomationCheck: true + }) + ).toBe(false) + }) + + it('still blocks quick create for missing form state and explicit setup choices', () => { + expect(getQuickComposerCreateDisabled({ ...readyInput, repoId: '' })).toBe(true) + expect(getQuickComposerCreateDisabled({ ...readyInput, workspaceSeedName: '' })).toBe(true) + expect(getQuickComposerCreateDisabled({ ...readyInput, creating: true })).toBe(true) + expect( + getQuickComposerCreateDisabled({ + ...readyInput, + requiresExplicitSetupChoice: true, + hasSetupDecision: false + }) + ).toBe(true) + expect(getQuickComposerCreateDisabled({ ...readyInput, sparseError: 'Bad sparse path' })).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/lib/new-workspace-create-gates.ts b/src/renderer/src/lib/new-workspace-create-gates.ts new file mode 100644 index 00000000000..e5ad63484ef --- /dev/null +++ b/src/renderer/src/lib/new-workspace-create-gates.ts @@ -0,0 +1,36 @@ +export type ComposerCreateGateInput = { + repoId: string + workspaceSeedName: string + creating: boolean + shouldWaitForSetupCheck: boolean + shouldWaitForIssueAutomationCheck: boolean + requiresExplicitSetupChoice: boolean + hasSetupDecision: boolean + sparseError: string | null +} + +function hasBlockingCreateState(input: ComposerCreateGateInput): boolean { + return ( + !input.repoId || + !input.workspaceSeedName || + input.creating || + (input.requiresExplicitSetupChoice && !input.hasSetupDecision) || + input.sparseError !== null + ) +} + +export function getFullComposerCreateDisabled(input: ComposerCreateGateInput): boolean { + return ( + hasBlockingCreateState(input) || + input.shouldWaitForSetupCheck || + input.shouldWaitForIssueAutomationCheck + ) +} + +export function getQuickComposerCreateDisabled(input: ComposerCreateGateInput): boolean { + // Why: Cmd/Ctrl+N quick create can resolve setup hooks inside the submit + // handler, and it never runs issue-command automation. Keeping those + // background probes out of the disabled gate makes the primary action usable + // as soon as the form has enough local state to submit. + return hasBlockingCreateState(input) +} From c90b9f72efaf4e365b07e7feb52a3ea0a5e3f2d7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:37:25 -0700 Subject: [PATCH 004/177] Add PR review queue badge foundation - Add provider-neutral review queue types, adapter, and checks-panel badges.\n- Keep the design doc out of the PR diff. --- src/main/github/gh-utils.test.ts | 15 +++ src/main/github/gh-utils.ts | 21 ++- .../components/right-sidebar/ChecksPanel.tsx | 80 ++++++++++++ src/shared/hosted-review-github.test.ts | 98 ++++++++++++++ src/shared/hosted-review-github.ts | 88 +++++++++++++ src/shared/hosted-review-queue.test.ts | 103 +++++++++++++++ src/shared/hosted-review-queue.ts | 122 ++++++++++++++++++ src/shared/hosted-review.ts | 53 ++++++++ 8 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 src/shared/hosted-review-github.test.ts create mode 100644 src/shared/hosted-review-github.ts create mode 100644 src/shared/hosted-review-queue.test.ts create mode 100644 src/shared/hosted-review-queue.ts diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 91cc8588b89..41ce4f465c3 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -20,6 +20,7 @@ import { classifyListIssuesError, getIssueOwnerRepo, getOwnerRepo, + parseGitHubRemoteIdentity, parseGitHubOwnerRepo, resolveIssueSource } from './gh-utils' @@ -47,6 +48,20 @@ describe('github owner/repo resolution', () => { expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull() }) + it('parses GitHub Enterprise host identity', () => { + expect(parseGitHubRemoteIdentity('https://ghe.acme.internal/acme/orca.git')).toEqual({ + host: 'ghe.acme.internal', + owner: 'acme', + repo: 'orca' + }) + expect(parseGitHubRemoteIdentity('git@ghe.acme.internal:acme/orca.git')).toEqual({ + host: 'ghe.acme.internal', + owner: 'acme', + repo: 'orca' + }) + expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull() + }) + it('keeps getOwnerRepo origin-based', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 3d8b3478850..bcff859ad58 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -113,6 +113,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { // short local name `OwnerRepo`. export type OwnerRepo = GitHubOwnerRepo +export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } + export type GitHubRepoContext = { repoPath: string connectionId?: string | null @@ -143,11 +145,24 @@ export function _resetOwnerRepoCache(): void { } export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { - const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) { + const identity = parseGitHubRemoteIdentity(remoteUrl) + if (!identity || identity.host.toLowerCase() !== 'github.com') { return null } - return { owner: match[1], repo: match[2] } + return { owner: identity.owner, repo: identity.repo } +} + +export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { + const trimmed = remoteUrl.trim() + const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i) + if (httpsMatch) { + return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] } + } + const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) + if (sshMatch) { + return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] } + } + return null } export async function getRemoteUrlForRepo( diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 7099e13c492..582a552a77c 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -22,6 +22,11 @@ import { getConnectionId } from '@/lib/connection-context' import { CreatePullRequestDialog } from './CreatePullRequestDialog' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { toast } from 'sonner' +import { + classifyHostedReview, + type HostedReviewClassificationOptions +} from '../../../../shared/hosted-review-queue' +import { hostedReviewSummaryFromGitHubPRInfo } from '../../../../shared/hosted-review-github' export default function ChecksPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() @@ -579,6 +584,68 @@ export default function ChecksPanel(): React.JSX.Element { ] ) + const activeReviewClassification = React.useMemo(() => { + if (!pr || !repo) { + return null + } + let host = 'github.com' + let owner = 'unknown' + let repoName = 'unknown' + try { + const parsed = new URL(pr.url) + host = parsed.host || host + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length >= 2) { + owner = segments[0] + repoName = segments[1] + } + } catch { + // Why: malformed URLs should not block queue-state classification. + } + + // Why: unresolved thread data is paginated and fetched separately. Until + // comments have loaded for this PR, do not let queue badges imply a clean review. + const commentsForClassification = + commentsFetchedAt !== undefined && !commentsLoading ? comments : undefined + const summary = hostedReviewSummaryFromGitHubPRInfo({ + pr, + owner, + repo: repoName, + host, + comments: commentsForClassification, + checks + }) + const options: HostedReviewClassificationOptions = { + agentAuthorLogins: [], + viewer: null + } + return classifyHostedReview(summary, options) + }, [pr, repo, comments, commentsFetchedAt, commentsLoading, checks]) + + const queueBadges = React.useMemo(() => { + if (!activeReviewClassification) { + return [] as string[] + } + const badges: string[] = [] + if (activeReviewClassification.needsResponse) { + badges.push('Needs response') + } + if (activeReviewClassification.readyToMerge) { + badges.push('Ready to merge') + } + if (activeReviewClassification.requested) { + badges.push('Review requested') + } + if (activeReviewClassification.state === 'mine') { + badges.push('My PR') + } else if (activeReviewClassification.state === 'agent') { + badges.push('AI PR') + } else { + badges.push('Teammate PR') + } + return badges + }, [activeReviewClassification]) + // ── Empty state ── if (!activeWorktree) { return ( @@ -763,6 +830,19 @@ export default function ChecksPanel(): React.JSX.Element { )} + {queueBadges.length > 0 && ( +
+ {queueBadges.map((badge) => ( + + {badge} + + ))} +
+ )} + {/* Merge / Delete Worktree actions */} {activeWorktree && repo && ( diff --git a/src/shared/hosted-review-github.test.ts b/src/shared/hosted-review-github.test.ts new file mode 100644 index 00000000000..6ab4dac0172 --- /dev/null +++ b/src/shared/hosted-review-github.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { hostedReviewSummaryFromGitHubPRInfo } from './hosted-review-github' +import type { PRInfo } from './types' + +const pr: PRInfo = { + number: 12, + title: 'Add queue badges', + state: 'open', + url: 'https://github.com/acme/orca/pull/12', + checksStatus: 'pending', + updatedAt: '2026-05-12T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' +} + +describe('hostedReviewSummaryFromGitHubPRInfo', () => { + it('maps PRInfo into provider-neutral summary with host identity', () => { + const summary = hostedReviewSummaryFromGitHubPRInfo({ + pr, + owner: 'acme', + repo: 'orca', + host: 'github.acme.internal' + }) + + expect(summary.identity).toEqual({ + provider: 'github', + host: 'github.acme.internal', + owner: 'acme', + repo: 'orca', + number: 12 + }) + expect(summary.checksStatus).toBe('pending') + expect(summary.threadSummary).toBeUndefined() + }) + + it('derives unresolved thread count and failing status from enrichers', () => { + const summary = hostedReviewSummaryFromGitHubPRInfo({ + pr: { ...pr, checksStatus: 'success' }, + owner: 'acme', + repo: 'orca', + comments: [ + { + id: 1, + author: 'a', + authorAvatarUrl: '', + body: '', + createdAt: '', + url: '', + threadId: 't1', + isResolved: false + }, + { + id: 2, + author: 'b', + authorAvatarUrl: '', + body: '', + createdAt: '', + url: '', + threadId: 't1', + isResolved: false + }, + { + id: 3, + author: 'c', + authorAvatarUrl: '', + body: '', + createdAt: '', + url: '', + threadId: 't2', + isResolved: true + } + ], + checks: [{ name: 'ci', status: 'completed', conclusion: 'failure', url: null }] + }) + + expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' }) + expect(summary.checksStatus).toBe('failure') + }) + + it('distinguishes loaded empty comments from unknown comments', () => { + expect( + hostedReviewSummaryFromGitHubPRInfo({ + pr, + owner: 'acme', + repo: 'orca' + }).threadSummary + ).toBeUndefined() + + expect( + hostedReviewSummaryFromGitHubPRInfo({ + pr, + owner: 'acme', + repo: 'orca', + comments: [] + }).threadSummary + ).toEqual({ unresolvedCount: 0, dataCompleteness: 'partial' }) + }) +}) diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts new file mode 100644 index 00000000000..ea733a59d1f --- /dev/null +++ b/src/shared/hosted-review-github.ts @@ -0,0 +1,88 @@ +import type { PRCheckDetail, PRComment, PRInfo } from './types' +import type { HostedReviewQueueSummary } from './hosted-review' + +export type HostedReviewFromGitHubPRInfoArgs = { + pr: PRInfo + owner: string + repo: string + host?: string + authorLogin?: string | null + authorIsBot?: boolean + requestedReviewerLogins?: string[] | null + comments?: PRComment[] + checks?: PRCheckDetail[] + lastViewedAt?: number +} + +function unresolvedThreadCount(comments?: PRComment[]): number | null { + if (comments === undefined) { + return null + } + const unresolved = new Set() + for (const comment of comments) { + if (!comment.threadId || comment.isResolved !== false) { + continue + } + unresolved.add(comment.threadId) + } + return unresolved.size +} + +function deriveChecksStatus( + prChecksStatus: PRInfo['checksStatus'], + checks?: PRCheckDetail[] +): PRInfo['checksStatus'] { + if (!checks || checks.length === 0) { + return prChecksStatus + } + const hasFailure = checks.some( + (check) => check.conclusion === 'failure' || check.conclusion === 'timed_out' + ) + if (hasFailure) { + return 'failure' + } + const hasPending = checks.some( + (check) => + check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending' + ) + if (hasPending) { + return 'pending' + } + const hasSuccess = checks.some((check) => check.conclusion === 'success') + if (hasSuccess) { + return 'success' + } + return 'neutral' +} + +export function hostedReviewSummaryFromGitHubPRInfo( + args: HostedReviewFromGitHubPRInfoArgs +): HostedReviewQueueSummary { + const unresolvedCount = unresolvedThreadCount(args.comments) + return { + identity: { + provider: 'github', + host: args.host ?? 'github.com', + owner: args.owner, + repo: args.repo, + number: args.pr.number + }, + title: args.pr.title, + url: args.pr.url, + state: args.pr.state, + author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null, + updatedAt: args.pr.updatedAt, + mergeable: args.pr.mergeable, + checksStatus: deriveChecksStatus(args.pr.checksStatus, args.checks), + threadSummary: + unresolvedCount === null + ? undefined + : { + unresolvedCount, + dataCompleteness: 'partial' + }, + requestedReviewerLogins: args.requestedReviewerLogins, + lastViewedAt: args.lastViewedAt, + draft: args.pr.state === 'draft' + } +} diff --git a/src/shared/hosted-review-queue.test.ts b/src/shared/hosted-review-queue.test.ts new file mode 100644 index 00000000000..4e75f314af5 --- /dev/null +++ b/src/shared/hosted-review-queue.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import type { HostedReviewQueueSummary } from './hosted-review' +import { + classifyHostedReview, + hostedReviewIdentityKey, + reviewNeedsResponse, + reviewReadyToMerge +} from './hosted-review-queue' + +function baseSummary(overrides: Partial = {}): HostedReviewQueueSummary { + return { + identity: { provider: 'github', host: 'github.com', owner: 'acme', repo: 'orca', number: 42 }, + title: 'Improve checks panel', + url: 'https://github.com/acme/orca/pull/42', + state: 'open', + author: { login: 'teammate' }, + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE', + checksStatus: 'success', + threadSummary: { unresolvedCount: 0 }, + ...overrides + } +} + +describe('hostedReviewIdentityKey', () => { + it('includes provider and host for enterprise-safe keys', () => { + const dotcom = hostedReviewIdentityKey({ + provider: 'github', + host: 'github.com', + owner: 'acme', + repo: 'orca', + number: 7 + }) + const ghe = hostedReviewIdentityKey({ + provider: 'github', + host: 'github.acme.internal', + owner: 'acme', + repo: 'orca', + number: 7 + }) + expect(dotcom).not.toBe(ghe) + }) +}) + +describe('classifyHostedReview', () => { + it('classifies mine/requested/agent/teammate', () => { + expect( + classifyHostedReview(baseSummary({ author: { login: 'me' } }), { + viewer: { login: 'me' } + }).state + ).toBe('mine') + + expect( + classifyHostedReview(baseSummary({ requestedReviewerLogins: ['me'] }), { + viewer: { login: 'me' } + }).state + ).toBe('requested') + + expect( + classifyHostedReview(baseSummary({ author: { login: 'orca-ci' } }), { + agentAuthorLogins: ['orca-ci'] + }).state + ).toBe('agent') + + expect(classifyHostedReview(baseSummary()).state).toBe('teammate') + }) +}) + +describe('reviewNeedsResponse', () => { + it('returns true for unresolved threads, failed checks, conflicts, and newer remote updates', () => { + expect(reviewNeedsResponse(baseSummary({ threadSummary: { unresolvedCount: 1 } }))).toBe(true) + expect(reviewNeedsResponse(baseSummary({ checksStatus: 'failure' }))).toBe(true) + expect(reviewNeedsResponse(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(true) + expect( + reviewNeedsResponse( + baseSummary({ + updatedAt: '2026-05-11T00:00:00.000Z', + lastViewedAt: Date.parse('2026-05-10T00:00:00.000Z') + }) + ) + ).toBe(true) + }) + + it('does not mark needs-response from updatedAt alone when lastViewedAt is missing', () => { + expect(reviewNeedsResponse(baseSummary({ updatedAt: '2026-05-11T00:00:00.000Z' }))).toBe(false) + }) +}) + +describe('reviewReadyToMerge', () => { + it('rejects drafts, conflicts, failed/pending checks, unresolved threads, and unknown mergeability', () => { + expect(reviewReadyToMerge(baseSummary({ state: 'draft', draft: true }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ checksStatus: 'failure' }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ checksStatus: 'pending' }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ threadSummary: { unresolvedCount: 2 } }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ threadSummary: undefined }))).toBe(false) + expect(reviewReadyToMerge(baseSummary({ mergeable: 'UNKNOWN' }))).toBe(false) + }) + + it('accepts neutral checks when all other gates pass', () => { + expect(reviewReadyToMerge(baseSummary({ checksStatus: 'neutral' }))).toBe(true) + }) +}) diff --git a/src/shared/hosted-review-queue.ts b/src/shared/hosted-review-queue.ts new file mode 100644 index 00000000000..ddd6a4212d7 --- /dev/null +++ b/src/shared/hosted-review-queue.ts @@ -0,0 +1,122 @@ +import type { + HostedReviewIdentity, + HostedReviewQueueClassification, + HostedReviewQueueState, + HostedReviewQueueSummary, + HostedReviewUser +} from './hosted-review' + +export type HostedReviewClassificationOptions = { + viewer?: HostedReviewUser | null + agentAuthorLogins?: string[] +} + +export function hostedReviewIdentityKey(identity: HostedReviewIdentity): string { + return `${identity.provider}::${identity.host.toLowerCase()}::${identity.owner.toLowerCase()}::${identity.repo.toLowerCase()}::${identity.number}` +} + +function hasRequestedReviewerSignal( + summary: HostedReviewQueueSummary, + viewer?: HostedReviewUser | null +): boolean { + if (!viewer?.login) { + return false + } + const requested = summary.requestedReviewerLogins + if (!requested || requested.length === 0) { + return false + } + const viewerLogin = viewer.login.toLowerCase() + return requested.some((login) => login.toLowerCase() === viewerLogin) +} + +function isAgentAuthored( + summary: HostedReviewQueueSummary, + options?: HostedReviewClassificationOptions +): boolean { + if (summary.author?.isBot) { + return true + } + const author = summary.author?.login?.toLowerCase() + if (!author) { + return false + } + if (options?.agentAuthorLogins?.some((login) => login.toLowerCase() === author)) { + return true + } + return author.endsWith('[bot]') || author.includes('bot') +} + +function getQueueState( + summary: HostedReviewQueueSummary, + options?: HostedReviewClassificationOptions +): HostedReviewQueueState { + const viewerLogin = options?.viewer?.login?.toLowerCase() ?? null + const authorLogin = summary.author?.login?.toLowerCase() ?? null + if (viewerLogin && authorLogin && viewerLogin === authorLogin) { + return 'mine' + } + if (hasRequestedReviewerSignal(summary, options?.viewer)) { + return 'requested' + } + if (isAgentAuthored(summary, options)) { + return 'agent' + } + return 'teammate' +} + +export function reviewNeedsResponse( + summary: HostedReviewQueueSummary, + viewer?: HostedReviewUser | null +): boolean { + void viewer + if (summary.state !== 'open' && summary.state !== 'draft') { + return false + } + if ((summary.threadSummary?.unresolvedCount ?? 0) > 0) { + return true + } + if (summary.checksStatus === 'failure') { + return true + } + if (summary.mergeable === 'CONFLICTING') { + return true + } + if (summary.lastViewedAt === undefined) { + return false + } + const updatedAt = Date.parse(summary.updatedAt) + return Number.isFinite(updatedAt) && updatedAt > summary.lastViewedAt +} + +export function reviewReadyToMerge(summary: HostedReviewQueueSummary): boolean { + if (summary.state !== 'open') { + return false + } + if (summary.draft) { + return false + } + if (summary.mergeable !== 'MERGEABLE') { + return false + } + if (summary.checksStatus !== 'success' && summary.checksStatus !== 'neutral') { + return false + } + if (summary.threadSummary?.unresolvedCount !== 0) { + return false + } + return true +} + +export function classifyHostedReview( + summary: HostedReviewQueueSummary, + options?: HostedReviewClassificationOptions +): HostedReviewQueueClassification { + const state = getQueueState(summary, options) + return { + state, + requested: hasRequestedReviewerSignal(summary, options?.viewer), + needsResponse: reviewNeedsResponse(summary, options?.viewer), + readyToMerge: reviewReadyToMerge(summary) + } +} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index c26d6a0b14e..4ebd122c4b3 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -113,3 +113,56 @@ export type HostedReviewCreationEligibilityArgs = { linkedBitbucketPR?: number | null linkedGiteaPR?: number | null } + +export type HostedReviewIdentity = { + provider: HostedReviewProvider + host: string + owner: string + repo: string + number: number +} + +export type HostedReviewUser = { + login: string | null + isBot?: boolean +} + +export type HostedReviewDecision = 'approved' | 'changes_requested' | 'review_required' | null + +export type HostedReviewThreadSummary = { + unresolvedCount: number | null + dataCompleteness?: 'full' | 'partial' +} + +export type HostedReviewQueueSummary = { + identity: HostedReviewIdentity + title: string + url: string + state: HostedReviewState + author: HostedReviewUser | null + updatedAt: string + lastViewedAt?: number + mergeable: PRMergeableState + checksStatus: CheckStatus + reviewDecision?: HostedReviewDecision + threadSummary?: HostedReviewThreadSummary + requestedReviewerLogins?: string[] | null + draft?: boolean +} + +export type HostedReviewQueueKey = + | 'mine' + | 'requested' + | 'agent' + | 'teammate' + | 'needs-response' + | 'ready-to-merge' + +export type HostedReviewQueueState = 'mine' | 'requested' | 'agent' | 'teammate' + +export type HostedReviewQueueClassification = { + state: HostedReviewQueueState + needsResponse: boolean + readyToMerge: boolean + requested: boolean +} From e7f3bb6034740592fed9ab4967d0fe9b5e4ee45d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 13:52:39 -0700 Subject: [PATCH 005/177] Add Grok agent support --- README.md | 5 +- docs/README.es.md | 3 +- docs/README.ja.md | 1 + docs/README.ko.md | 3 +- docs/README.zh-CN.md | 1 + mobile/src/components/NewWorktreeModal.tsx | 2 + .../remote-hook-service-installers.test.ts | 29 +- src/main/grok/hook-service.test.ts | 95 ++++++ src/main/grok/hook-service.ts | 276 ++++++++++++++++++ src/main/index.ts | 4 +- src/main/ipc/agent-hooks.test.ts | 6 + src/main/ipc/agent-hooks.ts | 15 + src/preload/api-types.ts | 1 + src/preload/index.ts | 4 +- .../src/components/settings/agents-search.ts | 1 + .../src/components/settings/general-search.ts | 3 +- .../sidebar/useWorktreeAgentRows.test.ts | 39 ++- .../sidebar/useWorktreeAgentRows.ts | 136 ++++++--- src/renderer/src/lib/agent-catalog.tsx | 7 + src/renderer/src/lib/agent-status.test.ts | 1 + src/renderer/src/lib/agent-status.ts | 6 +- .../src/lib/tui-agent-startup.test.ts | 16 + src/renderer/src/web/web-preload-api.ts | 5 +- src/shared/agent-detection.ts | 6 +- src/shared/agent-hook-listener.test.ts | 70 +++++ src/shared/agent-hook-listener.ts | 184 +++++++++++- src/shared/agent-hook-relay.ts | 10 +- src/shared/agent-hook-types.ts | 2 +- src/shared/agent-kind.ts | 3 +- src/shared/agent-status-types.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/tui-agent-config.ts | 6 + src/shared/types.ts | 1 + 33 files changed, 876 insertions(+), 67 deletions(-) create mode 100644 src/main/grok/hook-service.test.ts create mode 100644 src/main/grok/hook-service.ts diff --git a/README.md b/README.md index 5dfd755e0c2..ccd76d71fe0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*). ## Features -- **No login required** — Bring your own Claude Code or Codex subscription. +- **No login required** — Bring your own Claude Code, Codex, or Grok subscription. - **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly. - **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance. - **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca. diff --git a/docs/README.es.md b/docs/README.es.md index 1147e3b7bc8..4701a21fbcb 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -14,7 +14,7 @@

El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
+ Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.

@@ -33,6 +33,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   diff --git a/docs/README.ja.md b/docs/README.ja.md index 35f688e949b..bd7dcd3dc30 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -33,6 +33,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   diff --git a/docs/README.ko.md b/docs/README.ko.md index 42fabf357ed..070f1666579 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -14,7 +14,7 @@

100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
macOS, Windows, Linux에서 사용할 수 있습니다.

@@ -33,6 +33,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 855471739c8..aaa651d7917 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -33,6 +33,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index a7e7d2579b3..20f74e7d643 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -36,6 +36,7 @@ type AgentOption = { const AGENT_OPTIONS: AgentOption[] = [ { id: 'claude', label: 'Claude' }, { id: 'codex', label: 'Codex' }, + { id: 'grok', label: 'Grok', faviconDomain: 'x.ai' }, { id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' }, { id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' }, { id: 'pi', label: 'Pi' }, @@ -67,6 +68,7 @@ const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL] const AGENT_COMMANDS: Record = { claude: 'claude', codex: 'codex', + grok: 'grok', copilot: 'copilot', opencode: 'opencode', pi: 'pi', diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 95db6b6ba95..f273a47c815 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -11,6 +11,7 @@ import { CodexHookService } from '../codex/hook-service' import { CursorHookService } from '../cursor/hook-service' import { GeminiHookService } from '../gemini/hook-service' import { ClaudeHookService } from '../claude/hook-service' +import { GrokHookService } from '../grok/hook-service' type FakeFs = { files: Map @@ -124,6 +125,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/cursor-hook.sh', install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/grok-hook.sh', + install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev') } ] @@ -185,12 +190,14 @@ describe('remote hook service installers', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') }) - it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => { + it('installs remote Gemini, Cursor, and Grok configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const cursor = createFakeSftp() + const grok = createFakeSftp() await new GeminiHookService().installRemote(gemini.sftp, '/home/dev') await new CursorHookService().installRemote(cursor.sftp, '/home/dev') + await new GrokHookService().installRemote(grok.sftp, '/home/dev') const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as { hooks: Record @@ -220,5 +227,25 @@ describe('remote hook service installers', () => { expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') expect(definition?.hooks).toBeUndefined() } + + const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as { + hooks: Record + } + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'SessionEnd', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification' + ]) { + const definition = grokConfig.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*') }) }) diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts new file mode 100644 index 00000000000..b64c70d7be1 --- /dev/null +++ b/src/main/grok/hook-service.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { GrokHookService } from './hook-service' + +describe('GrokHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-grok-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs a dedicated global Grok hook config and managed script', () => { + const status = new GrokHookService().install() + + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(homeDir, '.grok', 'hooks', 'orca-status.json')) + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse( + readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ) as { + hooks: Record + } + expect(Object.keys(config.hooks).sort()).toEqual( + [ + 'Notification', + 'PostToolUse', + 'PostToolUseFailure', + 'PreToolUse', + 'SessionEnd', + 'SessionStart', + 'Stop', + 'UserPromptSubmit' + ].sort() + ) + expect(config.hooks.PreToolUse[0].matcher).toBe('*') + expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') + expect(config.hooks.Notification[0].matcher).toBeUndefined() + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook') + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + + const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'grok-hook.sh'), 'utf8') + expect(script).toContain('/hook/grok') + expect(script).toContain('payload=$(cat)') + }) + + it('preserves user-authored hook entries in the Orca Grok config file', () => { + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify( + { + hooks: { + Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] + } + }, + null, + 2 + )}\n` + ) + + new GrokHookService().install() + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks: Record + } + const commands = config.hooks.Notification.flatMap((definition) => + definition.hooks.map((hook) => hook.command) + ) + expect(commands).toContain('/usr/local/bin/user-hook') + expect(commands.some((command) => command.includes('grok-hook.sh'))).toBe(true) + }) +}) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts new file mode 100644 index 00000000000..0ffc12abbec --- /dev/null +++ b/src/main/grok/hook-service.ts @@ -0,0 +1,276 @@ +import { homedir } from 'os' +import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + readHooksJson, + removeManagedCommands, + wrapPosixHookCommand, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' + +const GROK_EVENTS = [ + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } +] as const + +function getConfigPath(): string { + // Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep + // Orca's managed entries in a dedicated file so user-authored hook files stay + // untouched and project-level trust is not required for status reporting. + return join(homedir(), '.grok', 'hooks', 'orca-status.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/grok') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + 'exit /b 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +function buildInstalledConfig( + config: NonNullable>, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(GROK_EVENTS.map((event) => event.eventName)) + + // Why: Orca owns only grok-hook.* entries. Sweep stale managed commands out + // of retired events while preserving any user-authored hooks in this file. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const event of GROK_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} + +export class GrokHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const event of GROK_EVENTS) { + const definitions = Array.isArray(config.hooks?.[event.eventName]) + ? config.hooks![event.eventName]! + : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) + ) + if (hasCommand) { + presentCount += 1 + } else { + missing.push(event.eventName) + } + } + + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'grok', state, configPath, managedHooksPresent, detail } + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.grok/hooks/orca-status.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Grok hook config' + } + } + + buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'grok-hook.sh') + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'grok', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'grok', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const grokHookService = new GrokHookService() diff --git a/src/main/index.ts b/src/main/index.ts index e87525574d9..af7af86db47 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' import { droidHookService } from './droid/hook-service' +import { grokHookService } from './grok/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, @@ -712,7 +713,8 @@ app.whenReady().then(async () => { ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['cursor', () => cursorHookService.install()], - ['droid', () => droidHookService.install()] + ['droid', () => droidHookService.install()], + ['grok', () => grokHookService.install()] ]) registerAppMenu({ diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index f82ade795e1..75e76a74f7c 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -53,6 +53,12 @@ vi.mock('../gemini/hook-service', () => ({ vi.mock('../cursor/hook-service', () => ({ cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) } })) +vi.mock('../droid/hook-service', () => ({ + droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) } +})) +vi.mock('../grok/hook-service', () => ({ + grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) } +})) beforeEach(() => { dropStatusEntry.mockReset() diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 350c1c9200e..2ecb426feab 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -14,6 +14,7 @@ import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' import { cursorHookService } from '../cursor/hook-service' import { droidHookService } from '../droid/hook-service' +import { grokHookService } from '../grok/hook-service' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -31,6 +32,7 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:cursorStatus') ipcMain.removeHandler('agentHooks:droidStatus') + ipcMain.removeHandler('agentHooks:grokStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via @@ -133,4 +135,17 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => { + try { + return grokHookService.getStatus() + } catch (err) { + return { + agent: 'grok', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ab03644b477..828d0be1c44 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1021,6 +1021,7 @@ export type PreloadApi = { geminiStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise + grokStatus: () => Promise } agentTrust: { markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 314d84a134c..a45f433e6ee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1092,7 +1092,9 @@ const api = { ipcRenderer.invoke('agentHooks:geminiStatus'), cursorStatus: (): Promise => ipcRenderer.invoke('agentHooks:cursorStatus'), - droidStatus: (): Promise => ipcRenderer.invoke('agentHooks:droidStatus') + droidStatus: (): Promise => + ipcRenderer.invoke('agentHooks:droidStatus'), + grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus') }, agentTrust: { diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 3d384e529c1..319f780f8a8 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -31,6 +31,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'hermes', 'openclaw', 'copilot', + 'grok', 'github', 'github copilot', 'command', diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 26417f4d74a..254b63336bf 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -93,7 +93,8 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'pi', 'gemini', 'aider', - 'copilot' + 'copilot', + 'grok' ] } ] diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 986e96d1f1d..a9827e3d217 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry + type AgentStatusEntry, + type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { buildWorktreeAgentRows } from './useWorktreeAgentRows' +import { + buildWorktreeAgentRows, + selectMigrationUnsupportedEntriesForWorktree +} from './useWorktreeAgentRows' import { makePaneKey } from '../../../../shared/stable-pane-id' const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111') @@ -108,3 +112,34 @@ describe('buildWorktreeAgentRows', () => { expect(done?.state).toBe('done') }) }) + +describe('selectMigrationUnsupportedEntriesForWorktree', () => { + it('returns raw migration records so shallow selectors can cache snapshots', () => { + const unsupported: MigrationUnsupportedPtyEntry = { + ptyId: 'pty-1', + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: '44444444-4444-4444-8444-444444444444', + paneKey: makePaneKey('tab-1', '44444444-4444-4444-8444-444444444444'), + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1000 + } + const state = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: { 'pty-1': unsupported }, + retainedAgentsByPaneKey: {} + } + + const first = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1') + const second = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1') + + // Why: the Electron black-screen regression came from creating converted + // AgentStatusEntry objects inside the Zustand selector. Returning store + // records preserves element identity for useShallow. + expect(first).toEqual([unsupported]) + expect(second).toEqual([unsupported]) + expect(first[0]).toBe(second[0]) + }) +}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index d0286bbeb20..196e6a116ca 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -4,10 +4,12 @@ import { useAppStore } from '@/store' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import type { AppState } from '@/store/types' import type { TerminalTab } from '../../../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry + type AgentStatusEntry, + type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' @@ -18,8 +20,76 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor // unnecessary re-renders — defeating the purpose of the narrow selector. const EMPTY_TABS: TerminalTab[] = [] const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] +const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] const EMPTY_RETAINED: RetainedAgentEntry[] = [] +type WorktreeAgentRowsState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'migrationUnsupportedByPtyId' + | 'retainedAgentsByPaneKey' + | 'tabsByWorktree' +> + +export function selectLiveAgentStatusEntriesForWorktree( + state: WorktreeAgentRowsState, + worktreeId: string +): AgentStatusEntry[] { + const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS + if (wtTabs.length === 0) { + return EMPTY_LIVE_ENTRIES + } + const tabIds = new Set(wtTabs.map((t) => t.id)) + const out: AgentStatusEntry[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + continue + } + if (!tabIds.has(parsed.tabId)) { + continue + } + out.push(entry) + } + return out.length > 0 ? out : EMPTY_LIVE_ENTRIES +} + +export function selectMigrationUnsupportedEntriesForWorktree( + state: WorktreeAgentRowsState, + worktreeId: string +): MigrationUnsupportedPtyEntry[] { + const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS + if (wtTabs.length === 0) { + return EMPTY_MIGRATION_UNSUPPORTED_ENTRIES + } + const tabIds = new Set(wtTabs.map((t) => t.id)) + const out: MigrationUnsupportedPtyEntry[] = [] + for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) { + if (!unsupported.paneKey) { + continue + } + const parsed = parsePaneKey(unsupported.paneKey) + if (!parsed || !tabIds.has(parsed.tabId)) { + continue + } + out.push(unsupported) + } + return out.length > 0 ? out : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES +} + +export function selectRetainedAgentEntriesForWorktree( + state: WorktreeAgentRowsState, + worktreeId: string +): RetainedAgentEntry[] { + const out: RetainedAgentEntry[] = [] + for (const ra of Object.values(state.retainedAgentsByPaneKey)) { + if (ra.worktreeId === worktreeId) { + out.push(ra) + } + } + return out.length > 0 ? out : EMPTY_RETAINED +} + export function buildWorktreeAgentRows(args: { tabs: TerminalTab[] entries: AgentStatusEntry[] @@ -97,48 +167,17 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { // every on-screen card re-render on any agent-status update anywhere — // O(worktrees²) render amplification. Pre-filtering here means the card // only re-renders when something relevant to THIS worktree changes. - const entries = useAppStore( - useShallow((s) => { - const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS - if (wtTabs.length === 0) { - return EMPTY_LIVE_ENTRIES - } - const tabIds = new Set(wtTabs.map((t) => t.id)) - const out: AgentStatusEntry[] = [] - for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - continue - } - if (!tabIds.has(parsed.tabId)) { - continue - } - out.push(entry) - } - for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) { - const entry = migrationUnsupportedToAgentStatusEntry(unsupported) - if (!entry) { - continue - } - const parsed = parsePaneKey(entry.paneKey) - if (!parsed || !tabIds.has(parsed.tabId)) { - continue - } - out.push(entry) - } - return out.length > 0 ? out : EMPTY_LIVE_ENTRIES - }) + const liveEntries = useAppStore( + useShallow((s) => selectLiveAgentStatusEntriesForWorktree(s, worktreeId)) + ) + // Why: keep the store selector limited to stable raw records. Converting + // migration entries creates fresh objects with Date.now(), which breaks + // useSyncExternalStore's cached-snapshot contract and can blank Electron. + const migrationUnsupported = useAppStore( + useShallow((s) => selectMigrationUnsupportedEntriesForWorktree(s, worktreeId)) ) const retained = useAppStore( - useShallow((s) => { - const out: RetainedAgentEntry[] = [] - for (const ra of Object.values(s.retainedAgentsByPaneKey)) { - if (ra.worktreeId === worktreeId) { - out.push(ra) - } - } - return out.length > 0 ? out : EMPTY_RETAINED - }) + useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId)) ) // Why: agentStatusEpoch is included in the dependency array (but not in the // computation itself) so the memo recomputes when freshness boundaries @@ -150,12 +189,23 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { // Why: Date.now() is read inside the memo (not as a dep) so stale-decay // recalculates whenever agentStatusEpoch ticks — same pattern as // useDashboardData. + const now = Date.now() + const entries = + migrationUnsupported.length > 0 + ? [ + ...liveEntries, + ...migrationUnsupported.flatMap((unsupported) => { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + return entry ? [entry] : [] + }) + ] + : liveEntries return buildWorktreeAgentRows({ tabs: tabs ?? [], entries, retained, - now: Date.now() + now }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabs, entries, retained, agentStatusEpoch]) + }, [tabs, liveEntries, migrationUnsupported, retained, agentStatusEpoch]) } diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 30cac2a6870..1d77e38ae27 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -28,6 +28,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [ cmd: 'codex', homepageUrl: 'https://github.com/openai/codex' }, + { + id: 'grok', + label: 'Grok', + cmd: 'grok', + faviconDomain: 'x.ai', + homepageUrl: 'https://x.ai/cli' + }, { id: 'copilot', label: 'GitHub Copilot', diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 770a6302cc1..5aaff7a9204 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -377,6 +377,7 @@ describe('getAgentLabel', () => { expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI') expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code') expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex') + expect(getAgentLabel('Grok running')).toBe('Grok') expect(getAgentLabel('⠋ Droid')).toBe('Droid') expect(getAgentLabel('Droid ready')).toBe('Droid') }) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 7f38952f24d..f1a3a0de942 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -117,7 +117,8 @@ const WELL_KNOWN_LABELS: Record = { cursor: 'Cursor', aider: 'Aider', pi: 'Pi', - droid: 'Droid' + droid: 'Droid', + grok: 'Grok' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { @@ -166,7 +167,8 @@ const ICONABLE_AGENT_TYPES: Record = { rovo: true, hermes: true, openclaw: true, - copilot: true + copilot: true, + grok: true } export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent | null { diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 1adc0dd4a7b..aa634c5bf90 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -118,6 +118,22 @@ describe('buildAgentStartupPlan', () => { }) }) + it('launches Grok first and injects the prompt after startup', () => { + expect( + buildAgentStartupPlan({ + agent: 'grok', + prompt: 'Trace the failing test', + cmdOverrides: {}, + platform: 'linux' + }) + ).toEqual({ + agent: 'grok', + launchCommand: 'grok', + expectedProcess: 'grok', + followupPrompt: 'Trace the failing test' + }) + }) + it('returns null when there is no prompt to inject', () => { expect( buildAgentStartupPlan({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6d990c00532..172742c0b21 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -791,7 +791,7 @@ function createCliApi(): NonNullable['cli']> { } function createAgentHooksApi(): NonNullable['agentHooks']> { - const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid') => + const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok') => Promise.resolve({ agent, state: 'not_installed', @@ -804,7 +804,8 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { codexStatus: () => status('codex'), geminiStatus: () => status('gemini'), cursorStatus: () => status('cursor'), - droidStatus: () => status('droid') + droidStatus: () => status('droid'), + grokStatus: () => status('grok') } } diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index c2ea5e881aa..fe824797c9a 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -29,7 +29,8 @@ export const AGENT_NAMES = [ 'gemini', 'opencode', 'openclaw', - 'aider' + 'aider', + 'grok' ] // Why: `android` contains `droid`; unlike the legacy agent names above, Droid @@ -354,6 +355,9 @@ export function getAgentLabel(title: string): string | null { if (lower.includes('copilot')) { return 'GitHub Copilot' } + if (lower.includes('grok')) { + return 'Grok' + } if (lower.includes('opencode')) { return 'OpenCode' } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index fa3c0e8acbf..54329755534 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -33,6 +33,7 @@ describe('shared agent-hook-listener', () => { it('routes pathnames to a known source or null', () => { expect(resolveHookSource('/hook/claude')).toBe('claude') expect(resolveHookSource('/hook/cursor')).toBe('cursor') + expect(resolveHookSource('/hook/grok')).toBe('grok') expect(resolveHookSource('/hook/unknown')).toBeNull() expect(resolveHookSource('/')).toBeNull() }) @@ -123,6 +124,75 @@ describe('shared agent-hook-listener', () => { expect(event!.payload.prompt).toBe('') }) + it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => { + const prompt = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + payload: { hookEventName: 'user_prompt_submit', prompt: 'run the check' } + }, + 'production' + ) + expect(prompt).not.toBeNull() + expect(prompt!.payload).toMatchObject({ + state: 'working', + prompt: 'run the check', + agentType: 'grok' + }) + + const tool = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + payload: { + hookEventName: 'pre_tool_use', + toolName: 'run_terminal_cmd', + toolInput: { command: 'pnpm test' } + } + }, + 'production' + ) + expect(tool).not.toBeNull() + expect(tool!.payload).toMatchObject({ + state: 'working', + prompt: 'run the check', + agentType: 'grok', + toolName: 'run_terminal_cmd', + toolInput: 'pnpm test' + }) + }) + + it('maps Grok feedback notifications to waiting without overwriting the prompt', () => { + normalizeHookPayload( + state, + 'grok', + { paneKey: PANE_KEY, payload: { hookEventName: 'UserPromptSubmit', prompt: 'ship it' } }, + 'production' + ) + + const event = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'Notification', message: 'Grok needs your feedback to proceed' } + }, + 'production' + ) + + expect(event).not.toBeNull() + expect(event!.payload).toMatchObject({ + state: 'waiting', + prompt: 'ship it', + agentType: 'grok' + }) + }) + describe('writeEndpointFile', () => { let dir: string beforeEach(() => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3b5e2c36bf6..e3e93abc7b8 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -305,6 +305,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { google_web_search: ['query'], exec_command: ['cmd', 'command'], shell_command: ['cmd', 'command'], + run_terminal_cmd: ['command'], apply_patch: ['path', 'file_path'], view_image: ['path', 'file_path'], bash: ['command'], @@ -741,8 +742,102 @@ function extractDroidToolFields( return {} } +function normalizeHookEventName(value: unknown): string { + if (typeof value !== 'string') { + return '' + } + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase() +} + +function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean { + const normalized = normalizeHookEventName(eventName) + return expected.includes(normalized) +} + +function extractGrokToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { + const toolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.toolInput) ?? + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + const update: ToolSnapshot = { toolName, toolInput } + if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { + const responseText = + extractToolResponseText(hookPayload.toolResponse) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolOutput) ?? + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (isGrokEvent(eventName, 'stop', 'session_end')) { + const direct = + readString(hookPayload, 'lastAssistantMessage') ?? + readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastAssistantFromTranscript( + hookPayload.transcriptPath ?? hookPayload.transcript_path + ) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + } + return {} +} + +function isGrokPermissionNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('permission') || + lower.includes('approval') || + lower.includes('approve') || + lower.includes('allow') || + lower.includes('confirm') || + lower.includes('needs your') || + lower.includes('requires your') || + lower.includes('feedback') || + lower.includes('clarify') || + lower.includes('question') + ) +} + +function isGrokIdleNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('type your message') || + lower.includes('enter send') || + lower.includes('shift-tab normal') || + lower.includes('ask a side question') + ) +} + function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - // Why: exhaustive switch so adding an 8th source to AgentHookSource fails + // Why: exhaustive switch so adding a source to AgentHookSource fails // typecheck here instead of silently falling through to `false`. switch (source) { case 'claude': @@ -759,6 +854,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { return eventName === 'before_agent_start' case 'droid': return eventName === 'UserPromptSubmit' + case 'grok': + return isGrokEvent(eventName, 'user_prompt_submit') default: { const _exhaustive: never = source void _exhaustive @@ -772,7 +869,7 @@ function extractToolFields( eventName: unknown, hookPayload: Record ): ToolSnapshot { - // Why: exhaustive switch so adding an 8th source to AgentHookSource fails + // Why: exhaustive switch so adding a source to AgentHookSource fails // typecheck here instead of silently routing through OpenCode's extractor. switch (source) { case 'claude': @@ -789,6 +886,8 @@ function extractToolFields( return extractPiToolFields(eventName, hookPayload) case 'droid': return extractDroidToolFields(eventName, hookPayload) + case 'grok': + return extractGrokToolFields(eventName, hookPayload) default: { const _exhaustive: never = source void _exhaustive @@ -1141,6 +1240,75 @@ function normalizeDroidEvent( ) } +function normalizeGrokEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (isGrokEvent(eventName, 'session_start')) { + // Why: Grok emits SessionStart when the TUI opens/resumes. It should reset + // stale per-turn details without creating a visible "working" row before a + // user prompt or tool event exists. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const notificationMessage = readString(hookPayload, 'message') + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + isGrokEvent( + eventName, + 'user_prompt_submit', + 'pre_tool_use', + 'post_tool_use', + 'post_tool_use_failure' + ) + ) { + stateName = 'working' + } else if (isGrokEvent(eventName, 'stop', 'session_end')) { + stateName = 'done' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokPermissionNotification(notificationMessage) + ) { + stateName = 'waiting' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokIdleNotification(notificationMessage) + ) { + stateName = 'done' + } + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('grok', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('grok', eventName) } + ) + + // Why: Grok Notification.message is status UI text, not necessarily the + // user's prompt. Preserve the cached UserPromptSubmit prompt for the row. + const effectivePrompt = isGrokEvent(eventName, 'notification') ? '' : promptText + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('grok', eventName) + }), + agentType: 'grok', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + function readStringField(record: Record, key: string): string | undefined { const value = record[key] if (typeof value !== 'string') { @@ -1196,10 +1364,10 @@ export function normalizeHookPayload( } const worktreeId = readStringField(record, 'worktreeId') - const eventName = (hookPayload as Record).hook_event_name - const promptText = extractPromptText(hookPayload as Record) const hookPayloadRecord = hookPayload as Record - // Why: exhaustive switch so adding an 8th source to AgentHookSource fails + const eventName = hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName + const promptText = extractPromptText(hookPayload as Record) + // Why: exhaustive switch so adding a source to AgentHookSource fails // typecheck here instead of silently routing through OpenCode's normalizer. let payload: ParsedAgentStatusPayload | null switch (source) { @@ -1224,6 +1392,9 @@ export function normalizeHookPayload( case 'droid': payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord) break + case 'grok': + payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break default: { const _exhaustive: never = source void _exhaustive @@ -1247,7 +1418,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/opencode': 'opencode', '/hook/cursor': 'cursor', '/hook/pi': 'pi', - '/hook/droid': 'droid' + '/hook/droid': 'droid', + '/hook/grok': 'grok' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index a0d78bdccf8..d855f67d5ac 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -30,7 +30,15 @@ import type { ParsedAgentStatusPayload } from './agent-status-types' // Promoted from `src/main/agent-hooks/server.ts` so the relay can import it // without dragging Electron in (the shared listener module is the only place // that consumes it from the relay side). -export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' | 'droid' +export type AgentHookSource = + | 'claude' + | 'codex' + | 'gemini' + | 'opencode' + | 'cursor' + | 'pi' + | 'droid' + | 'grok' /** Env marker used by the remote relay. It is a transport/location marker, not * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 68cbf758b82..1013dc84106 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -4,7 +4,7 @@ // gemini/cursor/hook-service.ts). Lives in `shared/` to keep a single // source of truth for the version string and status contract. -export const AGENT_HOOK_TARGETS = ['claude', 'codex', 'gemini', 'cursor', 'droid'] as const +export const AGENT_HOOK_TARGETS = ['claude', 'codex', 'gemini', 'cursor', 'droid', 'grok'] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error' diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index ef26793cb0f..33585d5a823 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -38,7 +38,8 @@ const TUI_AGENT_KIND_BY_AGENT = { rovo: 'rovo', hermes: 'hermes', openclaw: 'openclaw', - copilot: 'copilot' + copilot: 'copilot', + grok: 'grok' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 402907d339b..9bf00dc6eb0 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -18,6 +18,7 @@ export type WellKnownAgentType = | 'aider' | 'pi' | 'droid' + | 'grok' | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index d5694770e55..1878c7e0b2c 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -60,6 +60,7 @@ export const AGENT_KIND_VALUES = [ 'hermes', 'openclaw', 'copilot', + 'grok', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ad868812de8..81b7dd699e5 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -232,6 +232,12 @@ export const TUI_AGENT_CONFIG: Record = { // `addTrustedFolder` writes after the user accepts) makes the menu skip // entirely. See agent-trust-presets.ts for the file layout. preflightTrust: 'copilot' + }, + grok: { + detectCmd: 'grok', + launchCmd: 'grok', + expectedProcess: 'grok', + promptInjectionMode: 'stdin-after-start' } } diff --git a/src/shared/types.ts b/src/shared/types.ts index c6364d024b2..5f4fa70fb36 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1145,6 +1145,7 @@ export type TuiAgent = | 'hermes' // Hermes Agent | 'openclaw' // OpenClaw | 'copilot' // GitHub Copilot CLI + | 'grok' // xAI Grok CLI export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' From 8fff0a08e006528e81c6961db4c350362e920dfd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:53:50 -0700 Subject: [PATCH 006/177] Add GitHub issue link from worktree details (#1987) --- .../components/sidebar/WorktreeMetaDialog.tsx | 124 ++++++++++++++++-- 1 file changed, 115 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 5abb326083e..b400ad0109b 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -10,14 +10,27 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { ExternalLink, LoaderCircle } from 'lucide-react' import type { WorktreeMeta } from '../../../../shared/types' +function parseExplicitGitHubIssueUrl(input: string): string | null { + const trimmed = input.trim() + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link || link.type !== 'issue') { + return null + } + + return trimmed +} + const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const activeModal = useAppStore((s) => s.activeModal) const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const fetchIssue = useAppStore((s) => s.fetchIssue) const isEditMeta = activeModal === 'edit-meta' const isOpen = isEditMeta @@ -37,6 +50,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [prInput, setPrInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) + const [openingIssue, setOpeningIssue] = useState(false) const isMac = navigator.userAgent.includes('Mac') const issueInputRef = useRef(null) @@ -49,9 +63,35 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { setIssueInput(currentIssue) setPrInput(currentPR) setCommentInput(currentComment) + setOpeningIssue(false) } prevIsOpenRef.current = isOpen + const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput]) + const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput]) + const issueInputLooksLikeUrl = useMemo( + () => /^https?:\/\//i.test(issueInput.trim()), + [issueInput] + ) + const issueRepo = useAppStore((s) => { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((item) => item.id === worktreeId) + if (!worktree) { + return undefined + } + return s.repos.find((repo) => repo.id === worktree.repoId) + }) + const cachedIssueUrl = useAppStore((s) => { + if (!issueRepo || issueNumber === null) { + return null + } + return s.issueCache[`${issueRepo.id}::${issueNumber}`]?.data?.url ?? null + }) + const canOpenIssue = issueInputLooksLikeUrl + ? Boolean(issueUrlFromInput) + : Boolean(cachedIssueUrl || (issueRepo && issueNumber)) + const autoResize = useCallback(() => { const ta = textareaRef.current if (!ta) { @@ -154,6 +194,48 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { [handleSave] ) + const handleOpenIssue = useCallback(async () => { + if (openingIssue) { + return + } + + if (issueUrlFromInput) { + void window.api.shell.openUrl(issueUrlFromInput) + return + } + + if (issueInputLooksLikeUrl) { + return + } + + if (cachedIssueUrl) { + void window.api.shell.openUrl(cachedIssueUrl) + return + } + + if (!issueRepo || issueNumber === null) { + return + } + + setOpeningIssue(true) + try { + const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id }) + if (issue?.url) { + void window.api.shell.openUrl(issue.url) + } + } finally { + setOpeningIssue(false) + } + }, [ + cachedIssueUrl, + fetchIssue, + issueInputLooksLikeUrl, + issueNumber, + issueRepo, + issueUrlFromInput, + openingIssue + ]) + return (

- setIssueInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder="Issue # or GitHub URL" - className="h-8 text-xs" - /> +
+ setIssueInput(e.target.value)} + onKeyDown={handleIssueKeyDown} + placeholder="Issue # or GitHub URL" + className="h-8 pr-9 text-xs" + /> + + + + + + Open GitHub issue + + +

Paste an issue URL, or enter a number. Leave blank to remove the link.

From f5cc8196f9c5ee2eb07c04cbf1cb6e666eae6b88 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 15 May 2026 14:00:23 -0700 Subject: [PATCH 007/177] Clean up old workspaces (#1873) Co-authored-by: Orca --- src/main/ipc/workspace-cleanup.test.ts | 418 ++++++++ src/main/ipc/workspace-cleanup.ts | 700 +++++++++++++ src/main/runtime/orca-runtime.ts | 17 + .../attach-main-window-services.test.ts | 1 + .../window/attach-main-window-services.ts | 4 +- src/preload/api-types.ts | 15 + src/preload/index.ts | 13 + src/renderer/src/App.tsx | 5 + .../status-bar/ResourceUsageStatusSegment.tsx | 50 +- .../WorkspaceCleanupDialog.tsx | 980 ++++++++++++++++++ src/renderer/src/store/index.ts | 4 +- .../src/store/slices/diffComments.test.ts | 4 +- .../slices/store-session-cascades.test.ts | 4 +- .../src/store/slices/store-test-helpers.ts | 4 +- src/renderer/src/store/slices/tabs.test.ts | 4 +- src/renderer/src/store/slices/ui.ts | 42 + .../store/slices/workspace-cleanup.test.ts | 363 +++++++ .../src/store/slices/workspace-cleanup.ts | 527 ++++++++++ src/renderer/src/store/types.ts | 4 +- src/shared/constants.ts | 3 +- src/shared/types.ts | 2 + src/shared/workspace-cleanup.test.ts | 129 +++ src/shared/workspace-cleanup.ts | 232 +++++ 23 files changed, 3511 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup.test.ts create mode 100644 src/main/ipc/workspace-cleanup.ts create mode 100644 src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx create mode 100644 src/renderer/src/store/slices/workspace-cleanup.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup.ts create mode 100644 src/shared/workspace-cleanup.test.ts create mode 100644 src/shared/workspace-cleanup.ts diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts new file mode 100644 index 00000000000..2a1e5072183 --- /dev/null +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -0,0 +1,418 @@ +/* eslint-disable max-lines -- Why: scan and IPC process-liveness tests share + hoisted Electron/git provider mocks; splitting would duplicate brittle setup. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { DiffComment, GitStatusResult, Repo } from '../../shared/types' + +const { + listRepoWorktreesMock, + getStatusMock, + gitExecFileAsyncMock, + getSshGitProviderMock, + getSshPtyProviderMock, + listRegisteredPtysMock +} = vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + getStatusMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getSshPtyProviderMock: vi.fn(), + listRegisteredPtysMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../repo-worktrees', () => ({ + listRepoWorktrees: listRepoWorktreesMock, + createFolderWorktree: vi.fn() +})) + +vi.mock('../git/status', () => ({ + getStatus: getStatusMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: listRegisteredPtysMock +})) + +vi.mock('./pty', () => ({ + getSshPtyProvider: getSshPtyProviderMock +})) + +import { registerWorkspaceCleanupHandlers, scanWorkspaceCleanup } from './workspace-cleanup' + +const NOW = 1_700_000_000_000 +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: NOW +} + +function makeStore( + options: { + baseRef?: string + diffComments?: DiffComment[] + lastActivityAt?: number + linkedIssue?: number | null + repos?: Repo[] + } = {} +): Store { + const baseRef = Object.hasOwn(options, 'baseRef') ? options.baseRef : 'origin/main' + return { + getRepos: () => options.repos ?? [REPO], + getWorktreeMeta: () => ({ + linkedPR: null, + linkedIssue: options.linkedIssue ?? null, + lastActivityAt: options.lastActivityAt ?? NOW - 40 * 24 * 60 * 60 * 1000, + baseRef, + diffComments: options.diffComments + }), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ + pr: {}, + issue: {} + }) + } as unknown as Store +} + +describe('workspace cleanup scan', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + listRepoWorktreesMock.mockReset() + getStatusMock.mockReset() + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + getSshPtyProviderMock.mockReset() + listRegisteredPtysMock.mockReset() + listRegisteredPtysMock.mockReturnValue([]) + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.removeHandler).mockReset() + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '0\n', stderr: '' }) + getSshGitProviderMock.mockReturnValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('default-selects inactive workspaces when git status is clean', async () => { + const result = await scanWorkspaceCleanup(makeStore()) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + git: { + clean: true, + upstreamAhead: 0 + } + }) + }) + + it('keeps raw scan errors out of renderer-facing results', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.errors).toEqual([ + { + repoId: 'repo-1', + repoName: 'Repo', + message: 'Git could not list worktrees.' + } + ]) + }) + + it('skips disconnected remote workspaces without a scan warning', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(result.errors).toEqual([]) + expect(result.candidates).toEqual([]) + }) + + it('scans connected remote workspaces through the SSH git provider', async () => { + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]), + getStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + } + getSshGitProviderMock.mockReturnValue(provider) + + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature') + expect(result.errors).toEqual([]) + expect(result.candidates[0]).toMatchObject({ + connectionId: 'ssh-1', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'] + }) + }) + + it('skips connected remote workspaces that fail during broad scans', async () => { + const provider = { + listWorktrees: vi.fn().mockRejectedValue(new Error('ssh timeout')), + getStatus: vi.fn() + } + getSshGitProviderMock.mockReturnValue(provider) + + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(result.errors).toEqual([]) + expect(result.candidates).toEqual([]) + }) + + it('filters out recent workspaces before running git status', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + }) + ) + + expect(getStatusMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(result.candidates).toEqual([]) + }) + + it('includes focused remove preflight rows even when they are recent', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + }), + { worktreeId: 'repo-1::/repo-feature' } + ) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(result.candidates[0]).toMatchObject({ + tier: 'review', + selectedByDefault: false, + reasons: [], + git: { + clean: true, + checkedAt: expect.any(Number) + } + }) + }) + + it('honors renderer git deferrals without hiding the workspace', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { + skipGitWorktreeIds: ['repo-1::/repo-feature'] + }) + + expect(getStatusMock).not.toHaveBeenCalled() + expect(result.candidates[0]).toMatchObject({ + tier: 'review', + selectedByDefault: false, + reasons: ['idle-clean'], + git: { + clean: null, + checkedAt: null + } + }) + }) + + it('uses remote commit presence when a clean inactive workspace has no upstream', async () => { + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-list', '--count', 'HEAD', '--not', '--remotes'], + { cwd: '/repo-feature' } + ) + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + git: { + clean: true, + upstreamAhead: null + } + }) + }) + + it('protects clean inactive workspaces with local-only commits', async () => { + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '2\n', stderr: '' }) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.candidates[0]).toMatchObject({ + tier: 'protected', + selectedByDefault: false, + blockers: ['unpushed-commits'], + git: { + clean: true, + upstreamAhead: null + } + }) + }) + + it('keeps diff notes as context instead of blocking inactive cleanup', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + baseRef: undefined, + diffComments: [ + { + id: 'comment-1', + worktreeId: 'repo-1::/repo-feature', + filePath: 'src/file.ts', + lineNumber: 12, + body: 'Follow up before deleting', + createdAt: NOW - 1_000, + side: 'modified' + } + ] + }) + ) + + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + localContext: { + diffCommentCount: 1, + newestDiffCommentAt: NOW - 1_000 + } + }) + }) + + it('does not expose PR cache state in inactivity cleanup results', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [REPO, { ...REPO, id: 'repo-2' }] + }) + ) + + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'] + }) + expect(result.candidates[0]).not.toHaveProperty('linkedPR') + }) + + it('reports local processes that workspace deletion would kill', async () => { + const localProvider = { + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'repo-1::/repo-feature@@session-1', + cwd: '/repo-feature', + title: 'zsh' + } + ]) + } + registerWorkspaceCleanupHandlers(makeStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never, + getLocalPtyProvider: () => localProvider as never + }) + + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] + + await expect(handler?.({} as never, { worktreeId: 'repo-1::/repo-feature' })).resolves.toEqual({ + hasKillableProcesses: true + }) + }) + + it('reports SSH processes inside the remote workspace path', async () => { + getSshPtyProviderMock.mockReturnValue({ + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'remote-session-1', + cwd: '/remote/repo-feature/subdir', + title: 'codex' + } + ]) + }) + registerWorkspaceCleanupHandlers(makeStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never + }) + + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] + + await expect( + handler?.({} as never, { + worktreeId: 'repo-ssh::/remote/repo-feature', + connectionId: 'ssh-1', + worktreePath: '/remote/repo-feature' + }) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts new file mode 100644 index 00000000000..21c7e26819a --- /dev/null +++ b/src/main/ipc/workspace-cleanup.ts @@ -0,0 +1,700 @@ +/* eslint-disable max-lines */ +import { ipcMain } from 'electron' +import { basename } from 'node:path' +import type { Store } from '../persistence' +import { getStatus } from '../git/status' +import { gitExecFileAsync } from '../git/runner' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { IGitProvider, IPtyProvider } from '../providers/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { listRegisteredPtys } from '../memory/pty-registry' +import { getSshPtyProvider } from './pty' +import { isFolderRepo } from '../../shared/repo-kind' +import type { GitStatusResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import { mergeWorktree } from './worktree-logic' +import { splitWorktreeId } from '../../shared/worktree-id' +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + applyWorkspaceCleanupPolicy, + createWorkspaceCleanupFingerprint, + getWorkspaceCleanupInactivityReasons, + isWorkspaceOldForCleanup, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupDismissArgs, + type WorkspaceCleanupLocalProcessArgs, + type WorkspaceCleanupLocalProcessResult, + type WorkspaceCleanupReason, + type WorkspaceCleanupScanError, + type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' + +const GIT_READ_TIMEOUT_MS = 8_000 +const WORKTREE_SCAN_CONCURRENCY = 3 + +type GitEvidence = { + clean: boolean | null + upstreamAhead: number | null + upstreamBehind: number | null + checkedAt: number | null + blockers: WorkspaceCleanupBlocker[] +} + +type WorkspaceCleanupHandlerDeps = { + runtime?: OrcaRuntimeService + getLocalPtyProvider?: () => IPtyProvider +} + +export function registerWorkspaceCleanupHandlers( + store: Store, + deps: WorkspaceCleanupHandlerDeps = {} +): void { + ipcMain.removeHandler('workspaceCleanup:scan') + ipcMain.removeHandler('workspaceCleanup:dismiss') + ipcMain.removeHandler('workspaceCleanup:clearDismissals') + ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses') + + ipcMain.handle( + 'workspaceCleanup:scan', + (_event, args?: WorkspaceCleanupScanArgs): Promise => + scanWorkspaceCleanup(store, args ?? {}) + ) + + ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { + const current = store.getUI().workspaceCleanup?.dismissals ?? {} + const next = { ...current } + for (const dismissal of args.dismissals ?? []) { + if ( + dismissal && + dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION && + typeof dismissal.worktreeId === 'string' && + typeof dismissal.fingerprint === 'string' + ) { + next[dismissal.worktreeId] = dismissal + } + } + store.updateUI({ workspaceCleanup: { dismissals: next } }) + }) + + ipcMain.handle('workspaceCleanup:clearDismissals', () => { + store.updateUI({ workspaceCleanup: { dismissals: {} } }) + }) + + ipcMain.handle( + 'workspaceCleanup:hasKillableLocalProcesses', + async ( + _event, + args: WorkspaceCleanupLocalProcessArgs + ): Promise => ({ + hasKillableProcesses: await hasKillableProcesses(args, deps) + }) + ) +} + +async function hasKillableProcesses( + args: WorkspaceCleanupLocalProcessArgs, + deps: WorkspaceCleanupHandlerDeps +): Promise { + const { worktreeId } = args + if (typeof worktreeId !== 'string' || worktreeId.length === 0) { + return false + } + + let livenessUnknown = false + if (deps.runtime) { + try { + if (await deps.runtime.hasTerminalsForWorktree(worktreeId)) { + return true + } + } catch { + livenessUnknown = true + } + } + + if (args.connectionId) { + return hasKillableSshProcesses(args.connectionId, args.worktreePath ?? '', livenessUnknown) + } + + const registryPtyIds = new Set( + listRegisteredPtys() + .filter((entry) => entry.worktreeId === worktreeId) + .map((entry) => entry.ptyId) + ) + + const provider = deps.getLocalPtyProvider?.() + if (!provider) { + return registryPtyIds.size > 0 ? true : null + } + + try { + const prefix = `${worktreeId}@@` + const sessions = await provider.listProcesses() + if ( + sessions.some((session) => session.id.startsWith(prefix) || registryPtyIds.has(session.id)) + ) { + return true + } + return livenessUnknown ? null : false + } catch { + return registryPtyIds.size > 0 ? true : null + } +} + +async function hasKillableSshProcesses( + connectionId: string, + worktreePath: string, + livenessUnknown: boolean +): Promise { + const provider = getSshPtyProvider(connectionId) + if (!provider) { + return null + } + + try { + const normalizedWorktreePath = normalizeRemotePath(worktreePath) + const sessions = await provider.listProcesses() + if ( + sessions.some((session) => { + if (session.id.startsWith(`${worktreePath}@@`)) { + return true + } + return ( + normalizedWorktreePath.length > 0 && + isPathWithin(normalizeRemotePath(session.cwd), normalizedWorktreePath) + ) + }) + ) { + return true + } + return livenessUnknown ? null : false + } catch { + return null + } +} + +function normalizeRemotePath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function isPathWithin(candidatePath: string, parentPath: string): boolean { + return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`) +} + +export async function scanWorkspaceCleanup( + store: Store, + args: WorkspaceCleanupScanArgs = {} +): Promise { + const scannedAt = Date.now() + const repos = store.getRepos() + const errors: WorkspaceCleanupScanResult['errors'] = [] + const candidates: WorkspaceCleanupCandidate[] = [] + + for (const repo of repos) { + const result = await scanRepoWorkspaces({ + store, + repo, + scannedAt, + targetWorktreeId: args.worktreeId, + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []) + }) + candidates.push(...result.candidates) + errors.push(...result.errors) + } + + return { scannedAt, candidates, errors } +} + +async function scanRepoWorkspaces(args: { + store: Store + repo: Repo + scannedAt: number + targetWorktreeId?: string + skipGitWorktreeIds: Set +}): Promise { + const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args + const errors: WorkspaceCleanupScanResult['errors'] = [] + let provider: IGitProvider | null = null + let gitWorktrees: GitWorktreeInfo[] = [] + const repoIsFolder = isFolderRepo(repo) + + try { + if (repoIsFolder) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + provider = getSshGitProvider(repo.connectionId) ?? null + if (!provider) { + // Why: cleanup should reflect only workspaces Orca can currently + // inspect. Disconnected SSH repos are skipped in broad scans. + return { + scannedAt, + candidates: targetWorktreeId + ? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId) + : [], + errors: [] + } + } + gitWorktrees = await withTimeout( + provider.listWorktrees(repo.path), + GIT_READ_TIMEOUT_MS, + 'Timed out listing SSH worktrees.' + ) + } else { + gitWorktrees = await withTimeout( + listRepoWorktrees(repo), + GIT_READ_TIMEOUT_MS, + 'Timed out listing worktrees.' + ) + } + } catch (error) { + console.error('Workspace cleanup repo scan failed', error) + if (repo.connectionId && !targetWorktreeId) { + // Why: broad cleanup only shows remote workspaces Orca can inspect now. + // A connected SSH repo that fails mid-scan is omitted, not bannered. + return { scannedAt, candidates: [], errors: [] } + } + errors.push(createScanError(repo, toSafeRepoScanError(error))) + return { scannedAt, candidates: [], errors } + } + + const worktrees = gitWorktrees + .map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + .filter((worktree) => { + if (targetWorktreeId) { + return worktree.id === targetWorktreeId + } + return ( + !repoIsFolder && + !worktree.isMainWorktree && + isWorkspaceInactiveForCleanup(worktree, scannedAt) + ) + }) + + const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) => + buildCandidate({ + repo, + worktree, + scannedAt, + provider, + skipGit: skipGitWorktreeIds.has(worktree.id), + forceGitCheck: Boolean(targetWorktreeId) + }).catch((error) => { + console.error('Workspace cleanup candidate scan failed', error) + return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error)) + }) + ) + + return { scannedAt, candidates, errors } +} + +async function buildCandidate(args: { + repo: Repo + worktree: Worktree + scannedAt: number + provider: IGitProvider | null + skipGit: boolean + forceGitCheck: boolean +}): Promise { + const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args + const blockers: WorkspaceCleanupBlocker[] = [] + const reasons = getInactivityReasons(worktree, scannedAt) + const repoIsFolder = isFolderRepo(repo) + + if (worktree.isMainWorktree) { + blockers.push('main-worktree') + } + if (repoIsFolder) { + blockers.push('folder-repo') + } + if (worktree.isPinned) { + blockers.push('pinned') + } + + const localContext = buildLocalContext(worktree) + const shouldReadGit = shouldReadGitEvidence({ + repoIsFolder, + blockers, + worktree, + skipGit, + forceGitCheck + }) + + const gitEvidence = !shouldReadGit + ? createEmptyGitEvidence() + : await readGitEvidence(worktree, repo, provider) + blockers.push(...gitEvidence.blockers) + + const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortBranchName(worktree.branch), + path: worktree.path, + tier: 'review', + selectedByDefault: false, + reasons, + blockers: uniqueBlockers(blockers), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext, + git: { + clean: gitEvidence.clean, + upstreamAhead: gitEvidence.upstreamAhead, + upstreamBehind: gitEvidence.upstreamBehind, + checkedAt: gitEvidence.checkedAt + }, + fingerprint: '' + } + + const fingerprint = createWorkspaceCleanupFingerprint({ + branch: candidateWithoutFingerprint.branch, + head: worktree.head, + gitClean: gitEvidence.clean, + lastActivityAt: worktree.lastActivityAt + }) + + return applyWorkspaceCleanupPolicy({ + ...candidateWithoutFingerprint, + reasons: uniqueReasons(reasons), + blockers: uniqueBlockers(blockers), + fingerprint + }) +} + +function shouldReadGitEvidence(args: { + repoIsFolder: boolean + blockers: WorkspaceCleanupBlocker[] + worktree: Worktree + skipGit: boolean + forceGitCheck: boolean +}): boolean { + const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args + if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { + return false + } + if ( + blockers.includes('pinned') || + blockers.includes('main-worktree') || + blockers.includes('folder-repo') + ) { + return false + } + + // Why: inactivity is the only recommendation signal now. Git is read only + // to keep the destructive path from deleting dirty or local-only branch work. + return true +} + +async function readGitEvidence( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + const blockers: WorkspaceCleanupBlocker[] = [] + let status: GitStatusResult + const checkedAt = Date.now() + + try { + status = await withTimeout( + repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path), + GIT_READ_TIMEOUT_MS, + 'Timed out reading git status.' + ) + } catch { + return { + ...createEmptyGitEvidence(), + blockers: ['git-status-error'] + } + } + + if (status.upstreamStatus === undefined) { + return { + ...createEmptyGitEvidence(), + blockers: ['git-status-error'] + } + } + + const clean = status.entries.length === 0 + if (!clean) { + blockers.push('dirty-files') + } + + const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null + const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null + if (upstreamAhead !== null && upstreamAhead > 0) { + blockers.push('unpushed-commits') + } + if (clean && upstreamAhead === null) { + const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) + if (unpushedCommitCount === null) { + blockers.push('unknown-base') + } else if (unpushedCommitCount > 0) { + blockers.push('unpushed-commits') + } + } + + const evidence: GitEvidence = { + clean, + upstreamAhead, + upstreamBehind, + checkedAt, + blockers + } + + return { ...evidence, blockers: uniqueBlockers(blockers) } +} + +async function readUnpushedCommitCount( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + try { + const result = await withTimeout( + repo.connectionId + ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path) + : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path + }), + GIT_READ_TIMEOUT_MS, + 'Timed out checking unpushed commits.' + ) + const count = Number.parseInt(result.stdout.trim(), 10) + return Number.isFinite(count) ? count : null + } catch { + return null + } +} + +function buildCandidateFromError( + repo: Repo, + worktree: Worktree, + scannedAt: number, + _message: string +): WorkspaceCleanupCandidate { + return applyWorkspaceCleanupPolicy({ + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortBranchName(worktree.branch), + path: worktree.path, + tier: 'protected', + selectedByDefault: false, + reasons: getInactivityReasons(worktree, scannedAt), + blockers: ['git-status-error'], + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext: buildLocalContext(worktree), + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: scannedAt + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: shortBranchName(worktree.branch), + head: worktree.head, + gitClean: null, + lastActivityAt: worktree.lastActivityAt + }) + }) +} + +function synthesizeDisconnectedSshCandidates( + store: Store, + repo: Repo, + scannedAt: number, + targetWorktreeId?: string +): WorkspaceCleanupCandidate[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + if (!worktreeId.startsWith(`${repo.id}::`)) { + return false + } + if (targetWorktreeId) { + return targetWorktreeId === worktreeId + } + return isWorkspaceInactiveForCleanup(meta, scannedAt) + }) + .map(([worktreeId, meta]) => { + const parsed = splitWorktreeId(worktreeId) + const path = parsed?.worktreePath ?? worktreeId + const reasons = getInactivityReasons(meta, scannedAt) + return applyWorkspaceCleanupPolicy({ + worktreeId, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: meta.displayName || basename(path), + branch: basename(path), + path, + tier: 'protected', + selectedByDefault: false, + reasons, + blockers: ['ssh-disconnected'], + lastActivityAt: meta.lastActivityAt, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: meta.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments), + retainedDoneAgentCount: 0 + }, + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: basename(path), + head: '', + gitClean: null, + lastActivityAt: meta.lastActivityAt + }) + }) + }) +} + +function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] { + return { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: worktree.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestDiffCommentAt(worktree.diffComments), + retainedDoneAgentCount: 0 + } +} + +function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefined): number | null { + if (!diffComments || diffComments.length === 0) { + return null + } + return Math.max(...diffComments.map((comment) => comment.createdAt)) +} + +function createEmptyGitEvidence(): GitEvidence { + return { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null, + blockers: [] + } +} + +function shortBranchName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} + +function uniqueBlockers(blockers: WorkspaceCleanupBlocker[]): WorkspaceCleanupBlocker[] { + return [...new Set(blockers)] +} + +function uniqueReasons(reasons: WorkspaceCleanupReason[]): WorkspaceCleanupReason[] { + return [...new Set(reasons)] +} + +function isWorkspaceInactiveForCleanup( + workspace: Pick, + scannedAt: number +): boolean { + return isWorkspaceOldForCleanup(workspace, scannedAt) +} + +function getInactivityReasons( + workspace: Pick, + scannedAt: number +): WorkspaceCleanupReason[] { + return getWorkspaceCleanupInactivityReasons(workspace, scannedAt) +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function createScanError(repo: Repo, message: string): WorkspaceCleanupScanError { + return { + repoId: repo.id, + repoName: repo.displayName || basename(repo.path), + message + } +} + +// Why: git errors often include absolute paths or command output. Keep the +// cause useful without leaking raw local/remote filesystem details to the UI. +function toSafeRepoScanError(error: unknown): string { + const message = toErrorMessage(error) + if (message === 'Timed out listing SSH worktrees.') { + return 'Timed out listing remote worktrees.' + } + if (message === 'Timed out listing worktrees.') { + return 'Timed out listing worktrees.' + } + if (message.startsWith('Timed out ')) { + return message.replace(/\.$/, '') + } + + const lower = message.toLowerCase() + if (lower.includes('not a git repository') || lower.includes('not a git worktree')) { + return 'Repository is not a git checkout.' + } + if ( + lower.includes('enoent') || + lower.includes('no such file') || + lower.includes('cannot find') || + lower.includes('does not exist') + ) { + return 'Repository folder was not found.' + } + if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) { + return 'Repository folder is not accessible.' + } + return 'Git could not list worktrees.' +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d589fbeab65..f52bc0ebcfa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6466,6 +6466,23 @@ export class OrcaRuntimeService { return { stopped } } + async hasTerminalsForWorktree(worktreeSelector: string): Promise { + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === worktree.id && leaf.ptyId) { + return true + } + } + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktree.id && pty.connected) { + return true + } + } + return false + } + markRendererReloading(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1a11ddd2f69..24c9afc1839 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -61,6 +61,7 @@ vi.mock('../ipc/worktrees', () => ({ })) vi.mock('../ipc/pty', () => ({ + getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock })) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 6bc943dec84..32389c5c704 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -10,7 +10,8 @@ import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/t import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' -import { registerPtyHandlers } from '../ipc/pty' +import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -44,6 +45,7 @@ export function attachMainWindowServices( ): void { registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime) + registerWorkspaceCleanupHandlers(store, { runtime, getLocalPtyProvider }) registerPtyHandlers( mainWindow, runtime, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 828d0be1c44..df7aaa2b59c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -229,6 +229,13 @@ import type { AutomationRun, AutomationUpdateInput } from '../shared/automations-types' +import type { + WorkspaceCleanupDismissArgs, + WorkspaceCleanupLocalProcessArgs, + WorkspaceCleanupLocalProcessResult, + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' export type BrowserApi = { registerGuest: (args: { @@ -552,6 +559,14 @@ export type PreloadApi = { callback: (data: WorktreeRemoteBranchConflictEvent) => void ) => () => void } + workspaceCleanup: { + scan: (args?: WorkspaceCleanupScanArgs) => Promise + dismiss: (args: WorkspaceCleanupDismissArgs) => Promise + clearDismissals: () => Promise + hasKillableLocalProcesses: ( + args: WorkspaceCleanupLocalProcessArgs + ) => Promise + } workspaceSpace: { analyze: () => Promise cancel: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index a45f433e6ee..e7330fd1b9c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -494,6 +494,19 @@ const api = { } }, + workspaceCleanup: { + scan: (args?: { worktreeId?: string; skipGitWorktreeIds?: string[] }): Promise => + ipcRenderer.invoke('workspaceCleanup:scan', args), + dismiss: (args: { dismissals: unknown[] }): Promise => + ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: (): Promise => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args: { + worktreeId: string + connectionId?: string | null + worktreePath?: string + }): Promise => ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + }, + workspaceSpace: { analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 83fa72b75bd..6a7a0e96053 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -165,6 +165,9 @@ const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/Works const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) +const WorkspaceCleanupDialog = lazy( + () => import('./components/workspace-cleanup/WorkspaceCleanupDialog') +) const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal')) // Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless // the user opts into the experimental flag. @@ -1045,6 +1048,7 @@ function App(): React.JSX.Element { activeModal !== 'quick-open' && activeModal !== 'worktree-palette' && activeModal !== 'new-workspace-composer' && + activeModal !== 'workspace-cleanup' && activeModal !== 'feature-wall' ) { return @@ -1408,6 +1412,7 @@ function App(): React.JSX.Element { whether triggered from Cmd+J or any future entry point. */} {mountedLazyModalIds.has('new-workspace-composer') ? : null} + {mountedLazyModalIds.has('workspace-cleanup') ? : null} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 213a0de60d9..a904e9796db 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -7,6 +7,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, + ArchiveX, ChevronDown, ChevronRight, LoaderCircle, @@ -32,12 +33,14 @@ import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { useAppStore } from '../../store' -import { useWorktreeMap } from '../../store/selectors' +import { useAllWorktrees, useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { mergeSnapshotAndSessions, @@ -656,8 +659,10 @@ export function ResourceUsageStatusSegment({ const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) const setActiveView = useAppStore((s) => s.setActiveView) + const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) const repos = useAppStore((s) => s.repos) + const allWorktrees = useAllWorktrees() const activeRuntimeEnvironmentId = useAppStore( (s) => s.settings?.activeRuntimeEnvironmentId ?? null ) @@ -778,6 +783,23 @@ export function ResourceUsageStatusSegment({ return map }, [repos]) + const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + + const oldWorkspaceCount = useMemo(() => { + const now = Date.now() + let count = 0 + for (const worktree of allWorktrees) { + const repo = repoById.get(worktree.repoId) + if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { + continue + } + if (isWorkspaceOldForCleanup(worktree, now)) { + count += 1 + } + } + return count + }, [allWorktrees, repoById]) + // Why: skip the merge entirely when the popover is closed. The merged // tree is only ever displayed inside ; computing it on // every store mutation (e.g. runtimePaneTitlesByTabId, which changes on @@ -926,6 +948,11 @@ export function ResourceUsageStatusSegment({ void runSleepWorktree(id) }, []) + const handleOpenWorkspaceCleanup = useCallback((): void => { + setOpen(false) + queueMicrotask(() => openModal('workspace-cleanup')) + }, [openModal]) + const handleKillSession = useCallback( (session: UnifiedSessionRow): void => { // Why: orphan sessions have no tab in this Orca instance, so there's @@ -1294,17 +1321,28 @@ export function ResourceUsageStatusSegment({ - {orphanCount > 0 && ( -
+
+ + {orphanCount > 0 ? ( -
- )} + ) : null} +
diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx new file mode 100644 index 00000000000..212da4f0370 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -0,0 +1,980 @@ +/* eslint-disable max-lines */ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertTriangle, + Check, + EyeOff, + Loader2, + Minus, + RefreshCcw, + Search, + Trash2, + X +} from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { + canQueueWorkspaceCleanupCandidate, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupScanError, + type WorkspaceCleanupTier +} from '../../../../shared/workspace-cleanup' + +const TIER_LABELS: Record = { + ready: 'Suggested cleanup', + review: 'Needs a closer look', + protected: 'Not suggested for cleanup' +} + +type CleanupView = WorkspaceCleanupTier | 'hidden' + +const BLOCKER_LABELS: Record = { + 'main-worktree': 'Main workspace', + 'folder-repo': 'Folder project', + pinned: 'Pinned', + 'active-workspace': 'Active workspace', + 'running-terminal': 'Running terminal process', + 'terminal-liveness-unknown': 'Terminal liveness unknown', + 'dirty-editor-buffer': 'Unsaved editor buffer', + 'volatile-local-context': 'Volatile local context', + 'recent-visible-context': 'Recently visited tabs', + 'live-agent': 'Active agent', + 'ssh-disconnected': 'Remote unavailable', + 'git-status-error': 'Git status unavailable', + 'dirty-files': 'Changed files', + 'unpushed-commits': 'Unpushed commits', + 'unknown-base': 'Could not verify unpushed commits', + dismissed: 'Ignored' +} + +function formatRelativeTime(timestamp: number): string { + if (!timestamp) { + return 'Never' + } + const deltaMs = Date.now() - timestamp + if (deltaMs < 60_000) { + return 'Just now' + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return `${minutes}m ago` + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return `${hours}h ago` + } + return `${Math.floor(hours / 24)}d ago` +} + +function isDisconnectedRemoteScanError(message: string): boolean { + return ( + message === 'SSH provider is unavailable.' || + message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' + ) +} + +function formatScanNoticeMessage( + errors: WorkspaceCleanupScanError[], + repoNameById: Map +): string | null { + const visibleErrors = errors.filter( + (error) => !isDisconnectedRemoteScanError(error.message ?? '') + ) + if (visibleErrors.length === 0) { + return null + } + if (visibleErrors.length === 1) { + const error = visibleErrors[0] + const repoName = formatScanErrorRepoName(error, repoNameById) + return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some old workspaces may be missing. Refresh to try again.` + } + const repoNames = visibleErrors + .slice(0, 3) + .map((error) => formatScanErrorRepoName(error, repoNameById)) + .join(', ') + const moreCount = visibleErrors.length - 3 + const suffix = moreCount > 0 ? `, +${moreCount} more` : '' + return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some old workspaces may be missing. Refresh to try again.` +} + +function formatScanErrorRepoName( + error: Partial, + repoNameById: Map +): string { + const repoName = error.repoName?.trim() + if (repoName) { + return repoName + } + const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' + return fallback || 'a repository' +} + +function formatScanErrorReason(message: string | undefined): string { + if (!message) { + return 'Git could not list worktrees' + } + if (message === 'Could not scan workspace cleanup for this repository.') { + return 'Git could not list worktrees' + } + return message.replace(/\.$/, '') +} + +function isOldWorkspaceCandidate(candidate: WorkspaceCleanupCandidate): boolean { + if (candidate.blockers.includes('main-worktree') || candidate.blockers.includes('folder-repo')) { + return false + } + return candidate.reasons.includes('archived') || candidate.reasons.includes('idle-clean') +} + +function compareCleanupCandidates( + a: WorkspaceCleanupCandidate, + b: WorkspaceCleanupCandidate +): number { + const priorityA = getCleanupCandidatePriority(a) + const priorityB = getCleanupCandidatePriority(b) + if (priorityA !== priorityB) { + return priorityA - priorityB + } + return a.lastActivityAt - b.lastActivityAt +} + +function getCleanupCandidatePriority(candidate: WorkspaceCleanupCandidate): number { + if (candidate.tier === 'ready') { + return 0 + } + if (candidate.reasons.length > 0) { + return 1 + } + if (isOldWorkspaceCandidate(candidate)) { + return 2 + } + return 3 +} + +export default function WorkspaceCleanupDialog(): React.JSX.Element { + const activeModal = useAppStore((s) => s.activeModal) + const closeModal = useAppStore((s) => s.closeModal) + const scan = useAppStore((s) => s.workspaceCleanupScan) + const loading = useAppStore((s) => s.workspaceCleanupLoading) + const error = useAppStore((s) => s.workspaceCleanupError) + const repos = useAppStore((s) => s.repos) + const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) + const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) + const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) + const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) + const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + + const open = activeModal === 'workspace-cleanup' + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [activeView, setActiveView] = useState('ready') + const [confirming, setConfirming] = useState(false) + const [removing, setRemoving] = useState(false) + const [rowFailures, setRowFailures] = useState>({}) + + useEffect(() => { + if (open) { + setRowFailures({}) + setActiveView('ready') + void scanWorkspaceCleanup().catch((err: unknown) => { + toast.error('Workspace cleanup scan failed', { + description: err instanceof Error ? err.message : String(err) + }) + }) + } + }, [open, scanWorkspaceCleanup]) + + const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) + + useEffect(() => { + if (!open || !scan) { + return + } + setSelectedIds( + new Set( + candidates + .filter((candidate) => candidate.selectedByDefault) + .map((candidate) => candidate.worktreeId) + ) + ) + setConfirming(false) + }, [open, scan, scan?.scannedAt, candidates]) + + const visibleCandidates = useMemo(() => { + const rows = candidates.filter((candidate) => !candidate.blockers.includes('dismissed')) + return [...rows].sort(compareCleanupCandidates) + }, [candidates]) + const hiddenCandidates = useMemo( + () => + candidates + .filter((candidate) => candidate.blockers.includes('dismissed')) + .sort(compareCleanupCandidates), + [candidates] + ) + const groups = useMemo( + () => ({ + ready: visibleCandidates.filter((candidate) => candidate.tier === 'ready'), + review: visibleCandidates.filter((candidate) => candidate.tier === 'review'), + protected: visibleCandidates.filter((candidate) => candidate.tier === 'protected') + }), + [visibleCandidates] + ) + const selectedCandidates = useMemo(() => { + const byId = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) + return [...selectedIds] + .map((id) => byId.get(id)) + .filter( + (candidate): candidate is WorkspaceCleanupCandidate => + candidate != null && canQueueWorkspaceCleanupCandidate(candidate) + ) + }, [candidates, selectedIds]) + + const hiddenByKeepCount = candidates.filter((candidate) => + candidate.blockers.includes('dismissed') + ).length + const repoNameById = useMemo( + () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), + [repos] + ) + const scanNoticeMessage = useMemo( + () => formatScanNoticeMessage(scan?.errors ?? [], repoNameById), + [repoNameById, scan?.errors] + ) + const readyCount = groups.ready.length + const oldCandidateCount = useMemo( + () => candidates.filter(isOldWorkspaceCandidate).length, + [candidates] + ) + const initialLoading = loading && !scan + const activeRows = activeView === 'hidden' ? hiddenCandidates : groups[activeView] + const activeQueueableRows = useMemo( + () => activeRows.filter(canQueueWorkspaceCleanupCandidate), + [activeRows] + ) + const activeQueueableSelected = useMemo( + () => activeQueueableRows.filter((candidate) => selectedIds.has(candidate.worktreeId)).length, + [activeQueueableRows, selectedIds] + ) + const allActiveQueueableSelected = + activeQueueableRows.length > 0 && activeQueueableSelected === activeQueueableRows.length + const someActiveQueueableSelected = activeQueueableSelected > 0 + const activeSelectionState = allActiveQueueableSelected + ? 'checked' + : someActiveQueueableSelected + ? 'mixed' + : 'unchecked' + + useEffect(() => { + if (!open || loading || !scan) { + return + } + if (activeRows.length > 0) { + return + } + if (readyCount > 0) { + setActiveView('ready') + } else if (groups.review.length > 0) { + setActiveView('review') + } else if (groups.protected.length > 0) { + setActiveView('protected') + } else if (hiddenCandidates.length > 0) { + setActiveView('hidden') + } + }, [ + activeRows.length, + groups.protected.length, + groups.review.length, + hiddenCandidates.length, + loading, + open, + readyCount, + scan + ]) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && !removing) { + closeModal() + } + }, + [closeModal, removing] + ) + + const refresh = useCallback(() => { + setRowFailures({}) + void scanWorkspaceCleanup().catch((err: unknown) => { + toast.error('Workspace cleanup scan failed', { + description: err instanceof Error ? err.message : String(err) + }) + }) + }, [scanWorkspaceCleanup]) + + const toggleActiveSelection = useCallback(() => { + if (activeQueueableRows.length === 0) { + return + } + setSelectedIds((current) => { + const next = new Set(current) + if (allActiveQueueableSelected) { + for (const candidate of activeQueueableRows) { + next.delete(candidate.worktreeId) + } + } else { + for (const candidate of activeQueueableRows) { + next.add(candidate.worktreeId) + } + } + return next + }) + }, [activeQueueableRows, allActiveQueueableSelected]) + + const ignoreCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + void dismissCandidates([candidate]) + .then(() => { + setSelectedIds((current) => { + const next = new Set(current) + next.delete(candidate.worktreeId) + return next + }) + }) + .catch((err: unknown) => { + toast.error('Could not ignore cleanup suggestion', { + description: err instanceof Error ? err.message : String(err) + }) + }) + }, + [dismissCandidates] + ) + + const confirmRemove = useCallback(async () => { + if (selectedCandidates.length === 0) { + return + } + setRemoving(true) + setRowFailures({}) + try { + const result = await removeCandidates( + selectedCandidates.map((candidate) => candidate.worktreeId) + ) + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + } + setRowFailures(nextFailures) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of result.removedIds) { + next.delete(id) + } + return next + }) + if (result.removedIds.length > 0) { + toast.success( + `Removed ${result.removedIds.length} workspace${result.removedIds.length === 1 ? '' : 's'}` + ) + } + if (result.failures.length > 0) { + toast.error( + `${result.failures.length} workspace${result.failures.length === 1 ? '' : 's'} could not be removed` + ) + } else { + setConfirming(false) + } + } finally { + setRemoving(false) + } + }, [removeCandidates, selectedCandidates]) + + const selectedCount = selectedCandidates.length + + return ( + + + {!confirming ? ( + <> + +
+
+ Clean Up Old Workspaces + + Review old workspaces before deleting their local files and Orca state. + +
+
+ + + + + + Refresh + + + +
+
+
+ + {initialLoading ? ( +
+ + Checking old workspaces +
+ ) : oldCandidateCount > 0 || hiddenByKeepCount > 0 ? ( +
+
+
+ {selectedCount} selected +
+ {readyCount > 0 ? ( + {readyCount} safe to remove + ) : null} + {groups.review.length > 0 ? ( + {groups.review.length} need review + ) : null} +
+
+ +
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : scanNoticeMessage ? ( +
+ + {scanNoticeMessage} +
+ ) : null} + +
+ +
+
+
+ {activeView !== 'hidden' && activeQueueableRows.length > 0 ? ( + + ) : null} +
+ {activeView === 'hidden' + ? 'Ignored cleanup suggestions' + : TIER_LABELS[activeView]} +
+
+ {activeView === 'hidden' && hiddenByKeepCount > 0 ? ( + + ) : ( +
+ Sorted by oldest activity +
+ )} +
+ +
+ {initialLoading ? : null} + {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( + + ) : null} + {!loading && scan && candidates.length === 0 && scanNoticeMessage ? ( + + ) : null} + {!loading && scan && candidates.length > 0 && visibleCandidates.length === 0 ? ( + setActiveView('hidden')} + /> + ) : null} + {!loading && scan && activeRows.length === 0 && visibleCandidates.length > 0 ? ( + + ) : null} + {activeRows.map((candidate, index) => ( + + setSelectedIds((current) => toggleSetMember(current, id)) + } + onView={closeAndView} + onIgnore={ignoreCandidate} + onRemove={(candidate) => { + setSelectedIds(new Set([candidate.worktreeId])) + setConfirming(true) + }} + /> + ))} +
+
+
+
+ + ) : ( + setConfirming(false)} + onConfirm={() => void confirmRemove()} + /> + )} +
+
+ ) + + function closeAndView(candidate: WorkspaceCleanupCandidate): void { + markCandidateViewed(candidate) + closeModal() + activateAndRevealWorktree(candidate.worktreeId) + } +} + +function StatusPill({ + children, + tone = 'neutral' +}: { + children: React.ReactNode + tone?: 'neutral' | 'ready' | 'review' | 'destructive' +}): React.JSX.Element { + return ( + + {children} + + ) +} + +function CleanupViewNav({ + activeView, + counts, + onViewChange +}: { + activeView: CleanupView + counts: Record + onViewChange: (view: CleanupView) => void +}): React.JSX.Element { + const items: { view: CleanupView; label: string }[] = [ + { view: 'ready', label: 'Suggested' }, + { view: 'review', label: 'Needs review' }, + { view: 'protected', label: 'Not suggested' }, + { view: 'hidden', label: 'Ignored' } + ] + + return ( + + ) +} + +function CandidateRow({ + candidate, + last, + selected, + failure, + onToggleSelected, + onView, + onIgnore, + onRemove +}: { + candidate: WorkspaceCleanupCandidate + last: boolean + selected: boolean + failure?: string + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void +}): React.JSX.Element { + const selectable = canQueueWorkspaceCleanupCandidate(candidate) + const ignored = candidate.blockers.includes('dismissed') + const blockers = candidate.blockers.map((blocker) => BLOCKER_LABELS[blocker]) + const contextDetails = formatContextDetails(candidate) + const branchSafetyDetails = formatBranchSafetyDetails(candidate) + const status = getCandidateStatus(candidate) + + return ( +
+
+ {selectable ? ( + + ) : ( + +
+ ) +} + +function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { + label: string + tone: 'neutral' | 'ready' | 'review' | 'destructive' +} { + if (candidate.blockers.includes('dismissed')) { + return { label: 'Ignored', tone: 'neutral' } + } + if (candidate.tier === 'ready') { + return { label: candidate.reasons.includes('archived') ? 'Archived' : 'Clean', tone: 'ready' } + } + if (candidate.blockers.length > 0) { + return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' } + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return { label: 'Unpushed commits', tone: 'review' } + } + if (candidate.git.clean === false) { + return { label: 'Dirty', tone: 'review' } + } + if (candidate.tier === 'review') { + return { label: 'Review', tone: 'review' } + } + return { label: 'Not suggested', tone: 'neutral' } +} + +function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { + if (candidate.git.clean === true) { + return 'Clean git' + } + if (candidate.git.clean === false) { + return 'Dirty git' + } + return 'Git unknown' +} + +function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { + const details: string[] = [] + if (candidate.git.upstreamAhead !== null) { + details.push( + candidate.git.upstreamAhead === 0 + ? 'No unpushed commits' + : `${candidate.git.upstreamAhead} unpushed commit${ + candidate.git.upstreamAhead === 1 ? '' : 's' + }` + ) + } + return details +} + +function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { + const parts: string[] = [] + if (candidate.localContext.terminalTabCount > 0) { + parts.push( + `${candidate.localContext.terminalTabCount} terminal tab${ + candidate.localContext.terminalTabCount === 1 ? '' : 's' + }` + ) + } + if (candidate.localContext.cleanEditorTabCount > 0) { + parts.push( + `${candidate.localContext.cleanEditorTabCount} editor tab${ + candidate.localContext.cleanEditorTabCount === 1 ? '' : 's' + }` + ) + } + if (candidate.localContext.browserTabCount > 0) { + parts.push( + `${candidate.localContext.browserTabCount} browser tab${ + candidate.localContext.browserTabCount === 1 ? '' : 's' + }` + ) + } + if (candidate.localContext.diffCommentCount > 0) { + parts.push( + `${candidate.localContext.diffCommentCount} diff note${ + candidate.localContext.diffCommentCount === 1 ? '' : 's' + }` + ) + } + if (candidate.localContext.retainedDoneAgentCount > 0) { + parts.push( + `${candidate.localContext.retainedDoneAgentCount} completed agent${ + candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's' + }` + ) + } + return parts.length > 0 ? parts.join(', ') : null +} + +function ConfirmRemove({ + count, + removing, + onCancel, + onConfirm +}: { + count: number + removing: boolean + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + <> + + + Remove {count} workspace{count === 1 ? '' : 's'}? + + + Removing a workspace deletes its working tree folder, local Orca metadata, terminal + history, browser workspace state, and the local branch when the existing git deletion path + decides that branch is no longer used. + + +
+ Cleanup rechecks each selected workspace before deletion. Suggested rows use the normal + git-safe removal path; not suggested rows may need forced worktree removal and are reported + if they cannot be removed. +
+ + + + + + ) +} + +function SkeletonRows(): React.JSX.Element { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) +} + +function EmptyState({ + title, + actionLabel, + onAction +}: { + title: string + actionLabel?: string + onAction?: () => void +}): React.JSX.Element { + return ( +
+ {title} + {actionLabel && onAction ? ( + + ) : null} +
+ ) +} + +function toggleSetMember(current: Set, value: string): Set { + const next = new Set(current) + if (next.has(value)) { + next.delete(value) + } else { + next.add(value) + } + return next +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 76a8c91ed87..3337e94403a 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -24,6 +24,7 @@ import { createDiffCommentsSlice } from './slices/diffComments' import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' import { createDictationSlice } from './slices/dictation' +import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup' import { e2eConfig } from '@/lib/e2e-config' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' @@ -51,7 +52,8 @@ export const useAppStore = create()((...a) => ({ ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a) + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a) })) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index f41a4750357..7f7018d7231 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -111,6 +111,7 @@ import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' function createTestStore() { return create()((...a) => ({ @@ -137,7 +138,8 @@ function createTestStore() { ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a) + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a) })) } diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 990a4f3fc01..bff2a4ff2ae 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -114,6 +114,7 @@ import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' function createTestStore() { return create()((...a) => ({ @@ -140,7 +141,8 @@ function createTestStore() { ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a) + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a) })) } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index e4c43e4dfb8..ab8b589ce24 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -32,6 +32,7 @@ import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' export const TEST_REPO = { id: 'repo1', @@ -66,7 +67,8 @@ export function createTestStore() { ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a) + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a) })) } diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 32e245f1d2e..a68f34b135f 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -108,6 +108,7 @@ import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' const WT = 'repo1::/tmp/feature' @@ -136,7 +137,8 @@ function createTestStore() { ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a) + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a) })) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e807ec3a67a..a6688a47dc5 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -16,6 +16,10 @@ import type { WorktreeCardProperty } from '../../../../shared/types' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + type WorkspaceCleanupDismissal +} from '../../../../shared/workspace-cleanup' import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { DEFAULT_STATUS_BAR_ITEMS, @@ -152,6 +156,40 @@ function sanitizeAcknowledgedAgentsByPaneKey(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + continue + } + const input = raw as Record + if ( + typeof input.worktreeId !== 'string' || + typeof input.dismissedAt !== 'number' || + !Number.isFinite(input.dismissedAt) || + typeof input.fingerprint !== 'string' || + input.classifierVersion !== WORKSPACE_CLEANUP_CLASSIFIER_VERSION + ) { + continue + } + out[key] = { + worktreeId: input.worktreeId, + dismissedAt: input.dismissedAt, + fingerprint: input.fingerprint, + classifierVersion: input.classifierVersion + } + } + return out +} + function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined { if (!value || typeof value !== 'object') { return undefined @@ -284,6 +322,7 @@ export type UISlice = { | 'add-repo' | 'quick-open' | 'worktree-palette' + | 'workspace-cleanup' | 'feature-wall' | 'new-workspace-composer' | 'confirm-orca-yaml-hooks' @@ -896,6 +935,9 @@ export const createUISlice: StateCreator = (set, get) acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( ui.acknowledgedAgentsByPaneKey ), + workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( + ui.workspaceCleanup?.dismissals + ), persistedUIReady: true } }), diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts new file mode 100644 index 00000000000..70dc8e2afed --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup.test.ts @@ -0,0 +1,363 @@ +/* eslint-disable max-lines -- Why: cleanup store tests share a Zustand store + harness and mocked window API; splitting would obscure the state transitions. */ +import { create } from 'zustand' +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup' + +const WORKTREE_ID = 'repo1::/tmp/old-workspace' +const NOW = 1_700_000_000_000 + +function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: WORKTREE_ID, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId: null, + displayName: 'old-workspace', + branch: 'old-workspace', + path: '/tmp/old-workspace', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + }, + fingerprint: 'fingerprint-1', + ...overrides + } +} + +function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + workspaceCleanupDismissals: {}, + workspaceCleanupViewedCandidates: {}, + ...overrides + } as AppState +} + +function createCleanupTestStore(removeWorktree = vi.fn()) { + return create()( + (...a) => + ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + removeWorktree, + ...createWorkspaceCleanupSlice(...a) + }) as unknown as AppState + ) +} + +describe('workspace cleanup viewed rows', () => { + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ activeWorktreeId: WORKTREE_ID }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('keeps a viewed active workspace visible but not removable', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('active-workspace') + }) + + it('does not preserve the cleanup view exception after the row changes', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate({ fingerprint: 'fingerprint-2' })], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('protects recently visible old workspaces with open context', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + openFiles: [ + { + id: 'file-1', + worktreeId: WORKTREE_ID, + filePath: '/tmp/old-workspace/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + isDirty: false + } + ] as AppState['openFiles'], + lastVisitedAtByWorktreeId: { + [WORKTREE_ID]: Date.now() + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('recent-visible-context') + }) + + it('uses current renderer state after async delete preflight scan resolves', async () => { + let resolveScan: (value: WorkspaceCleanupScanResult) => void + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan: vi.fn( + (): Promise => + new Promise((resolve) => { + resolveScan = resolve + }) + ), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + store.setState({ activeWorktreeId: WORKTREE_ID }) + resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] }) + + await expect(removal).resolves.toEqual({ + removedIds: [WORKTREE_ID], + failures: [] + }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) + }) + + it('defers git checks for locally active workspaces on initial scans', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const store = createCleanupTestStore() + store.setState({ + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + 'repo1::/tmp/terminal-workspace': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledWith({ + skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) + }) + }) + + it('does not defer git checks for focused remove preflights', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) + + await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + + expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID }) + }) + + it('lets explicitly selected not-suggested workspaces reach the removal path', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: true + }) + } + } + } + + const store = createCleanupTestStore(removeWorktree) + + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( + { + removedIds: [WORKTREE_ID], + failures: [] + } + ) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) + }) + + it('protects old workspaces when an agent process is still foregrounded', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn().mockResolvedValue(true), + getForegroundProcess: vi.fn().mockResolvedValue('codex') + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) + + it('does not let an idle title in another tab mask a running agent process', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'), + getForegroundProcess: vi.fn(async (ptyId: string) => + ptyId === 'pty-running' ? 'codex' : 'zsh' + ) + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + { id: 'tab-running', title: 'zsh' }, + { id: 'tab-idle', title: 'Codex done' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { + 'tab-running': ['pty-running'], + 'tab-idle': ['pty-idle'] + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts new file mode 100644 index 00000000000..e9f112e7bff --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -0,0 +1,527 @@ +/* eslint-disable max-lines */ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + applyWorkspaceCleanupPolicy, + canQueueWorkspaceCleanupCandidate, + canSelectWorkspaceCleanupCandidate, + shouldForceWorkspaceCleanupRemoval, + shouldHideWorkspaceCleanupCandidate, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupDismissal, + type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' + +export type WorkspaceCleanupFailure = { + worktreeId: string + displayName: string + message: string +} + +export type WorkspaceCleanupRemoveResult = { + removedIds: string[] + failures: WorkspaceCleanupFailure[] +} + +type WorkspaceCleanupViewedCandidate = { + viewedAt: number + fingerprint: string + wasSuggested: boolean +} + +export type WorkspaceCleanupSlice = { + workspaceCleanupScan: WorkspaceCleanupScanResult | null + workspaceCleanupLoading: boolean + workspaceCleanupError: string | null + workspaceCleanupDismissals: Record + workspaceCleanupViewedCandidates: Record + scanWorkspaceCleanup: (args?: WorkspaceCleanupScanArgs) => Promise + markWorkspaceCleanupCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void + dismissWorkspaceCleanupCandidates: ( + candidates: readonly WorkspaceCleanupCandidate[] + ) => Promise + resetWorkspaceCleanupDismissals: () => Promise + removeWorkspaceCleanupCandidates: ( + worktreeIds: readonly string[] + ) => Promise +} + +type EnrichOptions = { + applyDismissals?: boolean +} + +const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 +const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 + +const SHELL_PROCESS_NAMES = new Set([ + 'bash', + 'cmd', + 'cmd.exe', + 'fish', + 'nu', + 'powershell', + 'powershell.exe', + 'pwsh', + 'pwsh.exe', + 'sh', + 'zsh' +]) + +const AGENT_PROCESS_NAMES = new Set([ + 'aider', + 'amp', + 'claude', + 'claude-code', + 'codex', + 'crush', + 'droid', + 'gemini', + 'gemini-cli', + 'goose', + 'opencode' +]) + +export const createWorkspaceCleanupSlice: StateCreator = ( + set, + get +) => ({ + workspaceCleanupScan: null, + workspaceCleanupLoading: false, + workspaceCleanupError: null, + workspaceCleanupDismissals: {}, + workspaceCleanupViewedCandidates: {}, + + scanWorkspaceCleanup: async (args) => { + set({ workspaceCleanupLoading: true, workspaceCleanupError: null }) + try { + const scanArgs = + args?.worktreeId !== undefined + ? args + : { + ...args, + skipGitWorktreeIds: [ + ...new Set([ + ...(args?.skipGitWorktreeIds ?? []), + ...getInitialWorkspaceCleanupGitDeferrals(get()) + ]) + ] + } + const scan = await window.api.workspaceCleanup.scan(scanArgs) + const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get()) + const result = { ...scan, candidates: enriched } + set({ workspaceCleanupScan: result, workspaceCleanupLoading: false }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + set({ workspaceCleanupError: message, workspaceCleanupLoading: false }) + throw error + } + }, + + markWorkspaceCleanupCandidateViewed: (candidate) => { + set((state) => ({ + workspaceCleanupViewedCandidates: { + ...state.workspaceCleanupViewedCandidates, + [candidate.worktreeId]: { + viewedAt: Date.now(), + fingerprint: candidate.fingerprint, + wasSuggested: candidate.tier === 'ready' && canSelectWorkspaceCleanupCandidate(candidate) + } + } + })) + }, + + dismissWorkspaceCleanupCandidates: async (candidates) => { + const now = Date.now() + const dismissals = candidates.map((candidate) => ({ + worktreeId: candidate.worktreeId, + dismissedAt: now, + fingerprint: candidate.fingerprint, + classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION + })) + + set((state) => { + const nextDismissals = { ...state.workspaceCleanupDismissals } + for (const dismissal of dismissals) { + nextDismissals[dismissal.worktreeId] = dismissal + } + const nextScan = state.workspaceCleanupScan + ? { + ...state.workspaceCleanupScan, + candidates: state.workspaceCleanupScan.candidates.map((candidate) => + applyDismissal(candidate, nextDismissals) + ) + } + : state.workspaceCleanupScan + return { + workspaceCleanupDismissals: nextDismissals, + workspaceCleanupScan: nextScan + } + }) + + await window.api.workspaceCleanup.dismiss({ dismissals }) + }, + + resetWorkspaceCleanupDismissals: async () => { + set((state) => ({ + workspaceCleanupDismissals: {}, + workspaceCleanupScan: state.workspaceCleanupScan + ? { + ...state.workspaceCleanupScan, + candidates: state.workspaceCleanupScan.candidates.map((candidate) => + applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: candidate.blockers.filter((blocker) => blocker !== 'dismissed') + }) + ) + } + : state.workspaceCleanupScan + })) + await window.api.workspaceCleanup.clearDismissals() + }, + + removeWorkspaceCleanupCandidates: async (worktreeIds) => { + const removedIds: string[] = [] + const failures: WorkspaceCleanupFailure[] = [] + + for (const worktreeId of worktreeIds) { + const preflight = await preflightWorkspaceCleanupCandidate(worktreeId, get) + if (!preflight.ok) { + failures.push(preflight.failure) + continue + } + + const result = await get().removeWorktree( + worktreeId, + shouldForceWorkspaceCleanupRemoval(preflight.candidate) + ) + if (result.ok) { + removedIds.push(worktreeId) + } else { + failures.push({ + worktreeId, + displayName: preflight.candidate.displayName, + message: result.error + }) + } + } + + if (removedIds.length > 0) { + set((state) => ({ + workspaceCleanupScan: state.workspaceCleanupScan + ? { + ...state.workspaceCleanupScan, + candidates: state.workspaceCleanupScan.candidates.filter( + (candidate) => !removedIds.includes(candidate.worktreeId) + ) + } + : state.workspaceCleanupScan + })) + } + + return { removedIds, failures } + } +}) + +function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] { + const ids = new Set() + if (state.activeWorktreeId) { + ids.add(state.activeWorktreeId) + } + + for (const file of state.openFiles) { + if (file.isDirty || state.editorDrafts[file.id] !== undefined) { + ids.add(file.worktreeId) + } + } + + const openEditorWorktreeIds = new Set(state.openFiles.map((file) => file.worktreeId)) + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + const tabIds = new Set(tabs.map((tab) => tab.id)) + if (tabs.some((tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0)) { + ids.add(worktreeId) + } + if (hasFreshLiveAgent(state, tabIds) || hasWorkingTitleAgent(state, tabs)) { + ids.add(worktreeId) + } + } + + for (const worktreeId of new Set([ + ...openEditorWorktreeIds, + ...Object.keys(state.browserTabsByWorktree) + ])) { + const hasVisibleContext = + openEditorWorktreeIds.has(worktreeId) || + (state.browserTabsByWorktree[worktreeId]?.length ?? 0) > 0 + const lastVisitedAt = state.lastVisitedAtByWorktreeId[worktreeId] ?? 0 + if ( + hasVisibleContext && + lastVisitedAt > 0 && + Date.now() - lastVisitedAt <= RECENT_VISIBLE_CONTEXT_MS + ) { + ids.add(worktreeId) + } + } + + // Why: these rows must stay visible, but they already need user attention. + // Defer expensive git reads until a focused refresh/remove preflight. + return [...ids] +} + +export async function enrichWorkspaceCleanupCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + state: AppState, + options: EnrichOptions = {} +): Promise { + return Promise.all( + candidates.map((candidate) => enrichWorkspaceCleanupCandidate(candidate, state, options)) + ) +} + +async function enrichWorkspaceCleanupCandidate( + candidate: WorkspaceCleanupCandidate, + state: AppState, + options: EnrichOptions +): Promise { + const tabs = state.tabsByWorktree[candidate.worktreeId] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const openFiles = state.openFiles.filter((file) => file.worktreeId === candidate.worktreeId) + const dirtyEditorBuffers = openFiles.filter( + (file) => file.isDirty || state.editorDrafts[file.id] !== undefined + ) + const cleanEditorTabCount = openFiles.length - dirtyEditorBuffers.length + const browserTabCount = (state.browserTabsByWorktree[candidate.worktreeId] ?? []).length + const retainedDoneAgentCount = Object.values(state.retainedAgentsByPaneKey).filter( + (entry) => entry.worktreeId === candidate.worktreeId && entry.entry.state === 'done' + ).length + const blockers = candidate.blockers.filter((blocker) => blocker !== 'dismissed') + const preserveCleanupInspection = shouldPreserveCleanupInspection(candidate, state) + + if (state.activeWorktreeId === candidate.worktreeId) { + blockers.push('active-workspace') + } + if (dirtyEditorBuffers.length > 0) { + blockers.push('dirty-editor-buffer') + } + if (hasFreshLiveAgent(state, tabIds)) { + blockers.push('live-agent') + } + if (hasWorkingTitleAgent(state, tabs)) { + blockers.push('live-agent') + } + + const terminalProbe = await probeTerminalLiveness(state, tabs) + if (terminalProbe === 'running') { + blockers.push('running-terminal') + } else if (terminalProbe === 'unknown') { + blockers.push('terminal-liveness-unknown') + } + + const lastVisitedAt = state.lastVisitedAtByWorktreeId[candidate.worktreeId] ?? 0 + const hasVisibleContext = cleanEditorTabCount > 0 || browserTabCount > 0 + if ( + hasVisibleContext && + !preserveCleanupInspection && + lastVisitedAt > 0 && + Date.now() - lastVisitedAt <= RECENT_VISIBLE_CONTEXT_MS + ) { + blockers.push('recent-visible-context') + } + + const enriched = applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: [...new Set(blockers)], + localContext: { + ...candidate.localContext, + terminalTabCount: tabs.length, + cleanEditorTabCount, + browserTabCount, + retainedDoneAgentCount + } + }) + + return options.applyDismissals === false + ? enriched + : applyDismissal(enriched, state.workspaceCleanupDismissals) +} + +function shouldPreserveCleanupInspection( + candidate: WorkspaceCleanupCandidate, + state: AppState +): boolean { + const viewed = state.workspaceCleanupViewedCandidates[candidate.worktreeId] + if (!viewed?.wasSuggested || viewed.fingerprint !== candidate.fingerprint) { + return false + } + // Why: View is part of cleanup review. It should not make the same + // suggested row vanish on the next scan, but this exception must expire. + return Date.now() - viewed.viewedAt <= VIEWED_FROM_CLEANUP_MS +} + +function applyDismissal( + candidate: WorkspaceCleanupCandidate, + dismissals: Record +): WorkspaceCleanupCandidate { + if (!shouldHideWorkspaceCleanupCandidate(candidate, dismissals[candidate.worktreeId])) { + return candidate + } + return applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: [...new Set([...candidate.blockers, 'dismissed'])] + }) +} + +async function preflightWorkspaceCleanupCandidate( + worktreeId: string, + getState: () => AppState +): Promise< + | { ok: true; candidate: WorkspaceCleanupCandidate } + | { ok: false; failure: WorkspaceCleanupFailure } +> { + const scan = await window.api.workspaceCleanup.scan({ worktreeId }) + const [candidate] = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), { + applyDismissals: false + }) + if (!candidate) { + return { + ok: false, + failure: { + worktreeId, + displayName: worktreeId, + message: 'Workspace no longer exists.' + } + } + } + if (!canQueueWorkspaceCleanupCandidate(candidate)) { + return { + ok: false, + failure: { + worktreeId, + displayName: candidate.displayName, + message: candidate.blockers.length + ? candidate.blockers.join(', ') + : 'Workspace is no longer safe to remove.' + } + } + } + return { ok: true, candidate } +} + +function hasFreshLiveAgent(state: AppState, tabIds: Set): boolean { + const now = Date.now() + return Object.values(state.agentStatusByPaneKey).some( + (entry) => + tabIds.has(getPaneKeyTabId(entry.paneKey)) && + isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) && + (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') + ) +} + +function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string }[]): boolean { + for (const tab of tabs) { + if ((state.ptyIdsByTabId[tab.id]?.length ?? 0) === 0) { + continue + } + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + const titles = + paneTitles && Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title] + for (const title of titles) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working' || status === 'permission') { + return true + } + } + } + return false +} + +async function probeTerminalLiveness( + state: AppState, + tabs: { id: string; title: string }[] +): Promise<'idle' | 'running' | 'unknown'> { + const ptyChecks = tabs.flatMap((tab) => + (state.ptyIdsByTabId[tab.id] ?? []).map((ptyId) => ({ tab, ptyId })) + ) + if (ptyChecks.length === 0) { + return 'idle' + } + + let unknown = false + for (const { tab, ptyId } of ptyChecks) { + try { + const [hasChildProcesses, foregroundProcess] = await Promise.all([ + window.api.pty.hasChildProcesses(ptyId), + window.api.pty.getForegroundProcess(ptyId) + ]) + const processName = normalizeProcessName(foregroundProcess) + if (!hasChildProcesses && (!processName || SHELL_PROCESS_NAMES.has(processName))) { + continue + } + if ( + processName && + AGENT_PROCESS_NAMES.has(processName) && + hasIdleAgentTitleForPty(state, tab, ptyId) + ) { + continue + } + return 'running' + } catch { + unknown = true + } + } + + return unknown ? 'unknown' : 'idle' +} + +function hasIdleAgentTitleForPty( + state: AppState, + tab: { id: string; title: string }, + ptyId: string +): boolean { + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} + const layoutPtyIds = state.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {} + const matchingTitles = Object.entries(layoutPtyIds) + .filter(([, leafPtyId]) => leafPtyId === ptyId) + .map(([leafId]) => paneTitles[leafId.replace(/^pane:/, '')]) + .filter((title): title is string => typeof title === 'string') + + if (matchingTitles.length > 0) { + return matchingTitles.some(isIdleAgentTitle) + } + + // Why: without a pane->PTY binding, a tab-level idle title is safe evidence + // only when this tab has a single live PTY. Multi-pane tabs stay protected. + const tabPtyIds = state.ptyIdsByTabId[tab.id] ?? [] + if (tabPtyIds.length !== 1) { + return false + } + + const titles = Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title] + return titles.some(isIdleAgentTitle) +} + +function isIdleAgentTitle(title: string): boolean { + return detectAgentStatusFromTitle(title) === 'idle' +} + +function getPaneKeyTabId(paneKey: AgentStatusEntry['paneKey']): string { + const separatorIndex = paneKey.lastIndexOf(':') + return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) +} + +function normalizeProcessName(value: string | null): string | null { + if (!value) { + return null + } + const normalizedPath = value.replace(/\\/g, '/') + const name = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1).toLowerCase() + return name.replace(/\.exe$/i, '.exe') +} diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 4c8f148985f..ec246109a53 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -22,6 +22,7 @@ import type { DiffCommentsSlice } from './slices/diffComments' import type { DetectedAgentsSlice } from './slices/detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' import type { DictationSlice } from './slices/dictation' +import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup' export type AppState = RepoSlice & SparsePresetsSlice & @@ -46,4 +47,5 @@ export type AppState = RepoSlice & DiffCommentsSlice & DetectedAgentsSlice & WorktreeNavHistorySlice & - DictationSlice + DictationSlice & + WorkspaceCleanupSlice diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 42270639004..777b7142f86 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -349,7 +349,8 @@ export function getDefaultUIState(): PersistedUIState { dismissedUpdateVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, - acknowledgedAgentsByPaneKey: {} + acknowledgedAgentsByPaneKey: {}, + workspaceCleanup: { dismissals: {} } } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 5f4fa70fb36..9e8f171acca 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,6 +5,7 @@ import type { WorkspaceSource } from './telemetry-events' import type { GitHubProjectSettings } from './github-project-types' import type { MigrationUnsupportedPtyEntry } from './agent-status-types' import type { VoiceSettings } from './speech-types' +import type { WorkspaceCleanupUIState } from './workspace-cleanup' import type { GitLabProjectSettings } from './gitlab-types' // Re-exported for backward compat with renderer call sites that import @@ -1730,6 +1731,7 @@ export type PersistedUIState = { * using their existing settings paths; this only restores transient tabs * and applied searches. */ taskResumeState?: TaskResumeState + workspaceCleanup?: WorkspaceCleanupUIState } export const PET_SIZE_MIN = 60 diff --git a/src/shared/workspace-cleanup.test.ts b/src/shared/workspace-cleanup.test.ts new file mode 100644 index 00000000000..e1576bf5f86 --- /dev/null +++ b/src/shared/workspace-cleanup.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + applyWorkspaceCleanupPolicy, + canQueueWorkspaceCleanupCandidate, + canSelectWorkspaceCleanupCandidate, + createWorkspaceCleanupFingerprint, + shouldForceWorkspaceCleanupRemoval, + shouldHideWorkspaceCleanupCandidate, + type WorkspaceCleanupCandidate +} from './workspace-cleanup' + +type CandidateOverrides = Partial> & { + git?: Partial + localContext?: Partial +} + +function makeCandidate(overrides: CandidateOverrides = {}): WorkspaceCleanupCandidate { + const { git, localContext, ...candidateOverrides } = overrides + const candidate: WorkspaceCleanupCandidate = { + worktreeId: 'repo-1::/tmp/feature', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + displayName: 'feature', + branch: 'feature', + path: '/tmp/feature', + tier: 'review', + selectedByDefault: false, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: 1_700_000_000_000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1_700_000_000_000 + }, + fingerprint: 'fingerprint', + ...candidateOverrides + } + return { + ...candidate, + git: { ...candidate.git, ...git }, + localContext: { ...candidate.localContext, ...localContext } + } +} + +describe('workspace cleanup policy', () => { + it('marks clean inactive workspaces as ready and selected', () => { + const candidate = applyWorkspaceCleanupPolicy(makeCandidate()) + + expect(candidate.tier).toBe('ready') + expect(candidate.selectedByDefault).toBe(true) + expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(true) + }) + + it('requires an inactivity reason before selecting a workspace', () => { + const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ reasons: [] })) + + expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) + expect(candidate.tier).toBe('review') + expect(candidate.selectedByDefault).toBe(false) + }) + + it('keeps not-suggested candidates queueable when git evidence is clean', () => { + const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['unpushed-commits'] })) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(candidate)).toBe(true) + expect(shouldForceWorkspaceCleanupRemoval(candidate)).toBe(true) + }) + + it('does not queue main worktrees or folder projects for cleanup removal', () => { + const mainWorktree = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['main-worktree'] })) + const folderProject = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['folder-repo'] })) + + expect(canQueueWorkspaceCleanupCandidate(mainWorktree)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(folderProject)).toBe(false) + }) + + it('requires current git status before selecting a workspace', () => { + const candidate = applyWorkspaceCleanupPolicy( + makeCandidate({ + git: { clean: null, checkedAt: null } + }) + ) + + expect(candidate.tier).toBe('review') + expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) + }) + + it('matches dismissals only for the current classifier fingerprint', () => { + const fingerprint = createWorkspaceCleanupFingerprint({ + branch: 'feature', + head: 'abc123', + gitClean: true, + lastActivityAt: 1_700_000_000_000 + }) + const candidate = makeCandidate({ fingerprint }) + + expect( + shouldHideWorkspaceCleanupCandidate(candidate, { + worktreeId: candidate.worktreeId, + dismissedAt: 1_700_000_000_000, + fingerprint, + classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION + }) + ).toBe(true) + expect( + shouldHideWorkspaceCleanupCandidate(candidate, { + worktreeId: candidate.worktreeId, + dismissedAt: 1_700_000_000_000, + fingerprint: `${fingerprint}|changed`, + classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION + }) + ).toBe(false) + }) +}) diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts new file mode 100644 index 00000000000..f751b7738a5 --- /dev/null +++ b/src/shared/workspace-cleanup.ts @@ -0,0 +1,232 @@ +export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2 +export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000 +export const WORKSPACE_CLEANUP_IDLE_MS = 30 * 24 * 60 * 60 * 1000 + +export type WorkspaceCleanupTier = 'ready' | 'review' | 'protected' + +export type WorkspaceCleanupReason = 'archived' | 'idle-clean' + +export type WorkspaceCleanupInactivityInput = { + isArchived: boolean + lastActivityAt: number +} + +export type WorkspaceCleanupBlocker = + | 'main-worktree' + | 'folder-repo' + | 'pinned' + | 'active-workspace' + | 'running-terminal' + | 'terminal-liveness-unknown' + | 'dirty-editor-buffer' + | 'volatile-local-context' + | 'recent-visible-context' + | 'live-agent' + | 'ssh-disconnected' + | 'git-status-error' + | 'dirty-files' + | 'unpushed-commits' + | 'unknown-base' + | 'dismissed' + +export type WorkspaceCleanupDismissal = { + worktreeId: string + dismissedAt: number + fingerprint: string + classifierVersion: number +} + +export type WorkspaceCleanupUIState = { + dismissals: Record +} + +export type WorkspaceCleanupCandidate = { + worktreeId: string + repoId: string + repoName: string + connectionId: string | null + displayName: string + branch: string + path: string + tier: WorkspaceCleanupTier + selectedByDefault: boolean + reasons: WorkspaceCleanupReason[] + blockers: WorkspaceCleanupBlocker[] + lastActivityAt: number + createdAt?: number + localContext: { + terminalTabCount: number + cleanEditorTabCount: number + browserTabCount: number + diffCommentCount: number + newestDiffCommentAt: number | null + retainedDoneAgentCount: number + } + git: { + clean: boolean | null + upstreamAhead: number | null + upstreamBehind: number | null + checkedAt: number | null + } + fingerprint: string +} + +export type WorkspaceCleanupScanArgs = { + worktreeId?: string + skipGitWorktreeIds?: string[] +} + +export type WorkspaceCleanupLocalProcessArgs = { + worktreeId: string + connectionId?: string | null + worktreePath?: string +} + +export type WorkspaceCleanupScanError = { + repoId: string + repoName: string + message: string +} + +export type WorkspaceCleanupScanResult = { + scannedAt: number + candidates: WorkspaceCleanupCandidate[] + errors: WorkspaceCleanupScanError[] +} + +export type WorkspaceCleanupLocalProcessResult = { + hasKillableProcesses: boolean | null +} + +export type WorkspaceCleanupDismissArgs = { + dismissals: WorkspaceCleanupDismissal[] +} + +export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ + 'main-worktree', + 'folder-repo', + 'pinned', + 'active-workspace', + 'running-terminal', + 'terminal-liveness-unknown', + 'dirty-editor-buffer', + 'volatile-local-context', + 'live-agent', + 'recent-visible-context', + 'ssh-disconnected', + 'git-status-error', + 'dirty-files', + 'unpushed-commits', + 'unknown-base', + 'dismissed' +]) + +const WORKSPACE_CLEANUP_QUEUE_BLOCKERS: ReadonlySet = new Set([ + 'main-worktree', + 'folder-repo', + 'dismissed' +]) + +export const WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS: ReadonlySet = + new Set(['dirty-files', 'unpushed-commits', 'unknown-base', 'git-status-error']) + +export function isWorkspaceCleanupHardBlocker(blocker: WorkspaceCleanupBlocker): boolean { + return WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker) +} + +export function canQueueWorkspaceCleanupCandidate( + candidate: Pick +): boolean { + return ( + candidate.reasons.length > 0 && + !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker)) + ) +} + +export function shouldForceWorkspaceCleanupRemoval( + candidate: Pick +): boolean { + return ( + candidate.git.clean !== true || + candidate.git.checkedAt === null || + candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS.has(blocker)) + ) +} + +export function canSelectWorkspaceCleanupCandidate( + candidate: Pick +): boolean { + return ( + candidate.reasons.length > 0 && + candidate.git.clean === true && + candidate.git.checkedAt !== null && + !candidate.blockers.some(isWorkspaceCleanupHardBlocker) + ) +} + +export function applyWorkspaceCleanupPolicy( + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupCandidate { + const canSelect = canSelectWorkspaceCleanupCandidate(candidate) + const hasHardBlocker = candidate.blockers.some(isWorkspaceCleanupHardBlocker) + const tier: WorkspaceCleanupTier = hasHardBlocker ? 'protected' : canSelect ? 'ready' : 'review' + + return { + ...candidate, + tier, + selectedByDefault: tier === 'ready' && canSelect + } +} + +export function createWorkspaceCleanupFingerprint(args: { + branch: string + head: string + gitClean: boolean | null + lastActivityAt: number + classifierVersion?: number +}): string { + const version = args.classifierVersion ?? WORKSPACE_CLEANUP_CLASSIFIER_VERSION + const lastActivityBucket = Math.floor((args.lastActivityAt || 0) / (24 * 60 * 60 * 1000)) + return [ + version, + args.branch, + args.head, + args.gitClean === null ? 'unknown' : args.gitClean ? 'clean' : 'dirty', + lastActivityBucket + ].join('|') +} + +export function getWorkspaceCleanupInactivityReasons( + workspace: WorkspaceCleanupInactivityInput, + scannedAt: number +): WorkspaceCleanupReason[] { + const reasons: WorkspaceCleanupReason[] = [] + if ( + workspace.isArchived && + scannedAt - workspace.lastActivityAt >= WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS + ) { + reasons.push('archived') + } + if (scannedAt - workspace.lastActivityAt >= WORKSPACE_CLEANUP_IDLE_MS) { + reasons.push('idle-clean') + } + return reasons +} + +export function isWorkspaceOldForCleanup( + workspace: WorkspaceCleanupInactivityInput, + scannedAt: number +): boolean { + return getWorkspaceCleanupInactivityReasons(workspace, scannedAt).length > 0 +} + +export function shouldHideWorkspaceCleanupCandidate( + candidate: Pick, + dismissal: WorkspaceCleanupDismissal | undefined +): boolean { + return ( + dismissal?.worktreeId === candidate.worktreeId && + dismissal.fingerprint === candidate.fingerprint && + dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION + ) +} From b10177b8e4b6609c41b5cfc351f66796d90ebb84 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 14:01:27 -0700 Subject: [PATCH 008/177] Fix code pointer line reveals --- .../src/components/editor/MarkdownPreview.tsx | 33 ++++--- .../src/components/editor/MonacoEditor.tsx | 85 +++++++++++-------- .../editor/MonacoGutterContextMenu.tsx | 9 +- .../components/editor/line-copy-path.test.ts | 10 +++ .../src/components/editor/line-copy-path.ts | 3 + .../editor/markdown-internal-links.test.ts | 11 +++ .../editor/markdown-internal-links.ts | 19 ++++- .../src/components/editor/monaco-reveal.ts | 2 + src/renderer/src/store/slices/editor.test.ts | 24 ++++++ src/renderer/src/store/slices/editor.ts | 41 +++++---- 10 files changed, 170 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/editor/line-copy-path.test.ts create mode 100644 src/renderer/src/components/editor/line-copy-path.ts diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 90d755ce0c2..29cda7cd53d 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -800,13 +800,18 @@ export default function MarkdownPreview({ return } const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) - if (classified?.kind === 'markdown') { + if ( + classified?.kind === 'markdown' || + (classified?.kind === 'file' && classified.line !== undefined) + ) { // Why: use the classifier's stripped absolutePath (no `:line:col` // or `#L10` suffix) so the OS handler receives a clean file URI. const cleanUri = absolutePathToFileUri(classified.absolutePath) void window.api.shell.pathExists(classified.absolutePath).then((exists) => { if (!exists) { - toast.error(`File not found: ${classified.relativePath}`) + toast.error( + `File not found: ${classified.relativePath ?? classified.absolutePath}` + ) return } void window.api.shell.openFileUri(cleanUri) @@ -832,12 +837,19 @@ export default function MarkdownPreview({ return } - const absolutePath = fileUrlToAbsolutePath(target) + const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) + const classifiedFileTarget = + classified?.kind === 'markdown' || classified?.kind === 'file' ? classified : null + const absolutePath = classifiedFileTarget?.absolutePath ?? fileUrlToAbsolutePath(target) if (!absolutePath) { return } + const lineTarget = + classifiedFileTarget?.line !== undefined + ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } + : parseLineTarget(target.hash) - if (absolutePath === filePath && target.hash) { + if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } @@ -874,13 +886,12 @@ export default function MarkdownPreview({ const relativePath = absolutePath.slice(targetWorktree.path.length + 1) const language = detectLanguage(absolutePath) - // Why: line-target fragments like #L10 or #L10C5 should open the - // source editor and reveal the line, not open a preview tab that - // treats "L10" as a heading anchor. - const lineTarget = parseLineTarget(target.hash) - if (language === 'markdown' && lineTarget) { - const fileId = absolutePath - setMarkdownViewMode(fileId, 'source') + // Why: line targets like #L10 and path.ts:10 should reveal in Monaco, + // not open a preview tab or a literal path with the suffix included. + if (lineTarget) { + if (language === 'markdown') { + setMarkdownViewMode(absolutePath, 'source') + } openFile({ filePath: absolutePath, relativePath, diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index fe966e7730b..82df9211ae0 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -11,7 +11,7 @@ import '@/lib/monaco-setup' import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { useContextualCopySetup } from './useContextualCopySetup' -import { performReveal } from './monaco-reveal' +import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal' import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' import { beginProgrammaticContentSync, @@ -104,6 +104,21 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + // Why: `keepCurrentModel` retains Monaco models across unmounts, and + // @monaco-editor/react skips its value→model sync on the first render after + // a remount. Without explicit sync, external file changes that arrived + // while the tab was unmounted leave the retained model showing stale text. + // contentRef lets handleMount read the current content without re-binding; + // lastSyncedContentRef lets the update effect distinguish our own onChange + // emissions from real prop drift. + // Invariant: the mount path (handleMount's syncContentOnMount call) MUST + // read `contentRef.current`, never `lastSyncedContentRef.current`. The + // useLayoutEffect below can run before mount with `editorRef.current === null` + // and bails without updating lastSyncedContentRef, so that ref may be stale + // pre-mount; only contentRef is guaranteed to reflect the latest prop. + const contentRef = useRef(content) + contentRef.current = content + const lastSyncedContentRef = useRef(content) const markdownComments = useMemo( () => (allDiffComments ?? []).filter((c) => c.filePath === relativePath && isMarkdownComment(c)), @@ -197,46 +212,46 @@ export default function MonacoEditor({ onApplied?: () => void ) => { cancelScheduledReveal() + let waitFrames = 0 - // Why: the search click path already waits two frames before publishing - // the reveal intent, but Monaco can still mount before its viewport math - // settles. Deferring the actual reveal by two editor-owned frames keeps - // scroll-to-match and inline highlight deterministic on fresh opens. - revealRafRef.current = requestAnimationFrame(() => { - revealInnerRafRef.current = requestAnimationFrame(() => { - performReveal( - editorInstance, - line, - column, - matchLength, - clearTransientRevealHighlight, - revealDecorationRef, - revealHighlightTimerRef - ) - onApplied?.() - revealRafRef.current = null - revealInnerRafRef.current = null + const schedule = (): void => { + // Why: the search click path already waits two frames before publishing + // the reveal intent, but Monaco can still mount before its viewport math + // settles. Deferring the actual reveal by two editor-owned frames keeps + // scroll-to-match and inline highlight deterministic on fresh opens. + revealRafRef.current = requestAnimationFrame(() => { + revealInnerRafRef.current = requestAnimationFrame(() => { + revealRafRef.current = null + revealInnerRafRef.current = null + const modelLineCount = editorInstance.getModel()?.getLineCount() ?? 0 + if (line > 1 && modelLineCount < line && waitFrames < MAX_REVEAL_CONTENT_WAIT_FRAMES) { + // Why: fresh file opens can mount Monaco against an empty one-line + // model before the async file read arrives. Waiting prevents the + // requested line from being clamped to 1 and then cleared. + waitFrames += 2 + schedule() + return + } + + performReveal( + editorInstance, + line, + column, + matchLength, + clearTransientRevealHighlight, + revealDecorationRef, + revealHighlightTimerRef + ) + onApplied?.() + }) }) - }) + } + + schedule() }, [cancelScheduledReveal, clearTransientRevealHighlight] ) - // Why: `keepCurrentModel` retains Monaco models across unmounts, and - // @monaco-editor/react skips its value→model sync on the first render after - // a remount. Without explicit sync, external file changes that arrived - // while the tab was unmounted leave the retained model showing stale text. - // contentRef lets handleMount read the current content without re-binding; - // lastSyncedContentRef lets the update effect distinguish our own onChange - // emissions from real prop drift. - // Invariant: the mount path (handleMount's syncContentOnMount call) MUST - // read `contentRef.current`, never `lastSyncedContentRef.current`. The - // useLayoutEffect below can run before mount with `editorRef.current === null` - // and bails without updating lastSyncedContentRef, so that ref may be stale - // pre-mount; only contentRef is guaranteed to reflect the latest prop. - const contentRef = useRef(content) - contentRef.current = content - const lastSyncedContentRef = useRef(content) // Why: Monaco model reconciliation reuses real edit operations so retained // models keep sane undo behavior. Those edits are programmatic, not user // typing, so split panes must suppress the resulting onChange callback or a diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx index a702e9d6d5b..1b6db18d423 100644 --- a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -10,6 +10,7 @@ import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getRuntimeGitRemoteFileUrl } from '@/runtime/runtime-git-client' +import { formatPathLineReference } from './line-copy-path' type MonacoGutterContextMenuProps = { open: boolean @@ -39,12 +40,16 @@ export function MonacoGutterContextMenu({ /> - window.api.ui.writeClipboardText(`${filePath}#L${line}`)}> + window.api.ui.writeClipboardText(formatPathLineReference(filePath, line))} + > Copy Path to Line window.api.ui.writeClipboardText(`${relativePath}#L${line}`)} + onSelect={() => + window.api.ui.writeClipboardText(formatPathLineReference(relativePath, line)) + } > Copy Rel. Path to Line diff --git a/src/renderer/src/components/editor/line-copy-path.test.ts b/src/renderer/src/components/editor/line-copy-path.test.ts new file mode 100644 index 00000000000..b4d90556fb4 --- /dev/null +++ b/src/renderer/src/components/editor/line-copy-path.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { formatPathLineReference } from './line-copy-path' + +describe('formatPathLineReference', () => { + it('uses the standard path:line format', () => { + expect(formatPathLineReference('src/components/PdfViewer.tsx', 142)).toBe( + 'src/components/PdfViewer.tsx:142' + ) + }) +}) diff --git a/src/renderer/src/components/editor/line-copy-path.ts b/src/renderer/src/components/editor/line-copy-path.ts new file mode 100644 index 00000000000..47623c1ff39 --- /dev/null +++ b/src/renderer/src/components/editor/line-copy-path.ts @@ -0,0 +1,3 @@ +export function formatPathLineReference(filePath: string, line: number): string { + return `${filePath}:${line}` +} diff --git a/src/renderer/src/components/editor/markdown-internal-links.test.ts b/src/renderer/src/components/editor/markdown-internal-links.test.ts index 11a9824e6eb..95e8e6407f5 100644 --- a/src/renderer/src/components/editor/markdown-internal-links.test.ts +++ b/src/renderer/src/components/editor/markdown-internal-links.test.ts @@ -44,6 +44,17 @@ describe('resolveMarkdownLinkTarget', () => { expect(r).toMatchObject({ kind: 'markdown', line: 10, column: 5 }) }) + it('extracts line+col from non-markdown file links', () => { + const r = resolveMarkdownLinkTarget('../src/PdfViewer.tsx:142:7', SOURCE, ROOT) + expect(r).toMatchObject({ + kind: 'file', + absolutePath: '/repo/src/PdfViewer.tsx', + relativePath: 'src/PdfViewer.tsx', + line: 142, + column: 7 + }) + }) + it('ignores non-line-col hashes', () => { const r = resolveMarkdownLinkTarget('./guide.md#intro', SOURCE, ROOT) expect(r).toMatchObject({ kind: 'markdown', line: undefined, column: undefined }) diff --git a/src/renderer/src/components/editor/markdown-internal-links.ts b/src/renderer/src/components/editor/markdown-internal-links.ts index c2512629a59..deac13b4c50 100644 --- a/src/renderer/src/components/editor/markdown-internal-links.ts +++ b/src/renderer/src/components/editor/markdown-internal-links.ts @@ -15,7 +15,14 @@ export type MarkdownLinkTarget = line?: number column?: number } - | { kind: 'file'; uri: string; absolutePath: string; relativePath?: string } + | { + kind: 'file' + uri: string + absolutePath: string + relativePath?: string + line?: number + column?: number + } // Why: renderer runs with sandbox + contextIsolation, so process.platform is // unavailable. navigator.userAgent is the portable fallback (AGENTS.md). @@ -210,8 +217,12 @@ export function resolveMarkdownLinkTarget( // approximation; for trailing-colon paths there's no clean URL form, // so we reconstruct from the stripped absolute path. const cleanUri = line === undefined ? resolved.toString() : toFileUrl(pathForClassification) - if (line === undefined) { - return { kind: 'file', uri: cleanUri, absolutePath: pathForClassification, relativePath } + return { + kind: 'file', + uri: cleanUri, + absolutePath: pathForClassification, + relativePath, + line, + column } - return { kind: 'file', uri: cleanUri, absolutePath: pathForClassification, relativePath } } diff --git a/src/renderer/src/components/editor/monaco-reveal.ts b/src/renderer/src/components/editor/monaco-reveal.ts index 3ab623da329..1a6d1510f5b 100644 --- a/src/renderer/src/components/editor/monaco-reveal.ts +++ b/src/renderer/src/components/editor/monaco-reveal.ts @@ -2,6 +2,8 @@ import type React from 'react' import type { editor } from 'monaco-editor' import { computeMonacoRevealRange } from './monaco-reveal-range' +export const MAX_REVEAL_CONTENT_WAIT_FRAMES = 120 + /** * Shared reveal logic used by both onMount and useEffect paths in MonacoEditor. * Positions the cursor, optionally selects the match range, scrolls into center, diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 875946f1d4a..9ec1e7a766a 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1457,6 +1457,30 @@ describe('createEditorSlice activateMarkdownLink', () => { expect(openFileUriMock).not.toHaveBeenCalled() }) + it('reveals line targets for non-markdown file links', async () => { + const store = createEditorStore() + await store.getState().activateMarkdownLink('../src/PdfViewer.tsx:142:7', { + sourceFilePath: '/repo/docs/note.md', + worktreeId: 'wt-1', + worktreeRoot: '/repo' + }) + + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + filePath: '/repo/src/PdfViewer.tsx', + relativePath: 'src/PdfViewer.tsx', + mode: 'edit', + isPreview: true + }) + ]) + expect(store.getState().pendingEditorReveal).toEqual({ + filePath: '/repo/src/PdfViewer.tsx', + line: 142, + column: 7, + matchLength: 0 + }) + }) + it('opens explicit file URLs inside the worktree in Orca', async () => { const store = createEditorStore() await store.getState().activateMarkdownLink('file:///repo/docs/image.png', { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 74fe667f474..b93ceeee6a6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -157,6 +157,27 @@ export type ClosedEditorTabSnapshot = Omit const MAX_RECENT_CLOSED_EDITOR_TABS = 10 +function scheduleEditorLineReveal( + get: () => AppState, + filePath: string, + line: number, + column?: number +): void { + // Why: openFile can replace a preview and remount Monaco asynchronously; the + // reveal must land after that remount or the old editor can clear it. + get().setPendingEditorReveal(null) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + get().setPendingEditorReveal({ + filePath, + line, + column: column ?? 1, + matchLength: 0 + }) + }) + }) +} + export type EditorSlice = { // Why: #300 originally kept EditorPanel mounted while hidden so unsaved // drafts and autosave timers could survive tab switches. Drafts live in the @@ -2226,6 +2247,7 @@ export const createEditorSlice: StateCreator = (s return } if (target.kind === 'file') { + const { line, column } = target if (target.relativePath === undefined) { if (sourceSettings?.activeRuntimeEnvironmentId?.trim()) { // Why: a file:// link outside the worktree is a client-local escape @@ -2255,6 +2277,9 @@ export const createEditorSlice: StateCreator = (s recordReplacedPreview: true } ) + if (line !== undefined) { + scheduleEditorLineReveal(get, target.absolutePath, line, column) + } return } @@ -2311,21 +2336,7 @@ export const createEditorSlice: StateCreator = (s } if (line !== undefined) { - // Why: double-RAF matches search-match-open.ts. openFile may replace a - // preview, remounting Monaco asynchronously; the mount's own - // setPendingEditorReveal(null) would otherwise clobber a reveal scheduled - // in the same tick. - get().setPendingEditorReveal(null) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - get().setPendingEditorReveal({ - filePath: absolutePath, - line, - column: column ?? 1, - matchLength: 0 - }) - }) - }) + scheduleEditorLineReveal(get, absolutePath, line, column) } }, From 0cb2da1b45dd7a12fde3b28df195f4bb1b456457 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 14:02:52 -0700 Subject: [PATCH 009/177] Add settings for task providers (#1977) --- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/persistence.test.ts | 17 +++ src/main/persistence.ts | 9 ++ src/renderer/src/components/TaskPage.tsx | 47 +++++- .../src/components/settings/Settings.tsx | 20 +++ .../src/components/settings/TasksPane.tsx | 142 ++++++++++++++++++ .../src/components/settings/tasks-search.ts | 9 ++ .../src/components/sidebar/SidebarNav.tsx | 120 +++++++++------ src/renderer/src/store/slices/settings.ts | 6 + src/renderer/src/store/slices/ui.ts | 14 +- src/shared/constants.ts | 2 + src/shared/task-providers.test.ts | 19 +++ src/shared/task-providers.ts | 35 +++++ src/shared/types.ts | 8 +- 15 files changed, 395 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/components/settings/TasksPane.tsx create mode 100644 src/renderer/src/components/settings/tasks-search.ts create mode 100644 src/shared/task-providers.test.ts create mode 100644 src/shared/task-providers.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 50b31ee9f5c..6dc357320bd 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -93,6 +93,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', + visibleTaskProviders: ['github', 'gitlab', 'linear'], defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 40532ad3c8e..41ac18b260f 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -86,6 +86,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', + visibleTaskProviders: ['github', 'gitlab', 'linear'], defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ec9aceee279..1b6b38cc064 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -165,6 +165,7 @@ describe('Store', () => { expect(settings.terminalFontWeight).toBe(500) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) + expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(settings.experimentalActivity).toBe(true) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) @@ -306,12 +307,28 @@ describe('Store', () => { expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) + expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(store.getSettings().experimentalActivity).toBe(true) expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('normalizes malformed visible task providers on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { visibleTaskProviders: ['gitlab', 'unknown', 'gitlab'] }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) + }) + it('migrates the legacy floating terminal disabled default to enabled', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 00bb9bc4409..5f1aab4c33f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -66,6 +66,7 @@ import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' +import { normalizeVisibleTaskProviders } from '../shared/task-providers' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardOpacity, @@ -989,6 +990,9 @@ export class Store { terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), + visibleTaskProviders: normalizeVisibleTaskProviders( + parsed.settings?.visibleTaskProviders + ), notifications: { ...getDefaultNotificationSettings(), ...parsed.settings?.notifications @@ -1741,6 +1745,11 @@ export class Store { updates.terminalQuickCommands ) } + if ('visibleTaskProviders' in updates) { + sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders( + updates.visibleTaskProviders + ) + } // Why: `telemetry` is deep-merged for the same reason `notifications` is — // partial updates from the Privacy pane / consent flow (e.g., flipping // only `optedIn`) must not clobber sibling fields like `installId` or diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 37cc7d74a79..f67d38c33a4 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -88,12 +88,17 @@ import type { LinearIssue, LinearTeam, Repo, + TaskProvider, TaskViewPresetId } from '../../../shared/types' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' import { linearCreateIssue, linearGetIssue, linearListTeams } from '@/runtime/runtime-linear-client' +import { + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider +} from '../../../shared/task-providers' -type TaskSource = 'github' | 'linear' | 'gitlab' +type TaskSource = TaskProvider type GitLabTaskFilter = 'opened' | 'merged' | 'closed' | 'all' @@ -605,6 +610,14 @@ export default function TaskPage(): React.JSX.Element { linearStatus.activeWorkspaceId ?? linearWorkspaces[0]?.id ?? null + const visibleTaskProviders = useMemo( + () => normalizeVisibleTaskProviders(settings?.visibleTaskProviders), + [settings?.visibleTaskProviders] + ) + const visibleSourceOptions = useMemo( + () => SOURCE_OPTIONS.filter((source) => visibleTaskProviders.includes(source.id)), + [visibleTaskProviders] + ) // Why: seed the preset + query from the user's saved default synchronously // so the first fetch effect issues exactly one request keyed to the final @@ -615,7 +628,9 @@ export default function TaskPage(): React.JSX.Element { const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset) const defaultTaskSource = settings?.defaultTaskSource ?? 'github' - const [taskSource, setTaskSource] = useState(pageData.taskSource ?? defaultTaskSource) + const [taskSource, setTaskSource] = useState( + resolveVisibleTaskProvider(pageData.taskSource ?? defaultTaskSource, visibleTaskProviders) + ) const taskResumeAppliedRef = useRef(false) const githubSearchPersistReadyRef = useRef(false) const linearSearchPersistReadyRef = useRef(false) @@ -626,9 +641,15 @@ export default function TaskPage(): React.JSX.Element { // initializes once, so sync from the store when the value changes. useEffect(() => { if (pageData.taskSource) { - setTaskSource(pageData.taskSource) + setTaskSource(resolveVisibleTaskProvider(pageData.taskSource, visibleTaskProviders)) } - }, [pageData.taskSource]) + }, [pageData.taskSource, visibleTaskProviders]) + + useEffect(() => { + if (!visibleTaskProviders.includes(taskSource)) { + setTaskSource(resolveVisibleTaskProvider(settings?.defaultTaskSource, visibleTaskProviders)) + } + }, [settings?.defaultTaskSource, taskSource, visibleTaskProviders]) // Why: Project mode is a sub-tab within the GitHub source. Visible whenever // the user is on the GitHub task source — actual entry into Project mode is @@ -876,7 +897,12 @@ export default function TaskPage(): React.JSX.Element { return } - setTaskSource(pageData.taskSource ?? settings.defaultTaskSource) + setTaskSource( + resolveVisibleTaskProvider( + pageData.taskSource ?? settings.defaultTaskSource, + visibleTaskProviders + ) + ) setRepoSelection(resolvedInitialSelection) const nextGithubMode = taskResumeState?.githubMode ?? 'items' @@ -906,7 +932,14 @@ export default function TaskPage(): React.JSX.Element { // Tasks context exactly once so later source/filter clicks remain local. taskResumeAppliedRef.current = true setTaskResumeApplied(true) - }, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState]) + }, [ + persistedUIReady, + settings, + pageData.taskSource, + resolvedInitialSelection, + taskResumeState, + visibleTaskProviders + ]) // Why: fetch the full team list from the Linear API so the selector shows // all teams the user belongs to, not just teams with issues in the current @@ -1925,7 +1958,7 @@ export default function TaskPage(): React.JSX.Element {
- {SOURCE_OPTIONS.map((source) => { + {visibleSourceOptions.map((source) => { const active = taskSource === source.id return ( diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 9d377436631..2938353c754 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -10,6 +10,7 @@ import { Globe, Info, Keyboard, + ListChecks, Lock, MousePointerClick, Network, @@ -56,6 +57,8 @@ import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search' import { AccountsPane, ACCOUNTS_PANE_SEARCH_ENTRIES } from './AccountsPane' import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane' +import { TasksPane } from './TasksPane' +import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search' import { DeveloperPermissionsPane, DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES @@ -80,6 +83,7 @@ type SettingsNavTarget = | 'accounts' | 'browser' | 'git' + | 'tasks' | 'appearance' | 'terminal' | 'notifications' @@ -447,6 +451,13 @@ function Settings(): React.JSX.Element { // "claude" or "thinking" still surfaces the section. searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES] }, + { + id: 'tasks', + title: 'Tasks', + description: 'Choose which task providers appear in the Tasks page and sidebar.', + icon: ListChecks, + searchEntries: TASKS_PANE_SEARCH_ENTRIES + }, { id: 'appearance', title: 'Appearance', @@ -831,6 +842,15 @@ function Settings(): React.JSX.Element { + + + + ) => void +} + +const TASK_PROVIDER_OPTIONS: readonly { + id: TaskProvider + label: string + description: string + Icon: (props: { className?: string }) => React.JSX.Element +}[] = [ + { + id: 'github', + label: 'GitHub', + description: 'Show GitHub in the Tasks source picker and sidebar shortcuts.', + Icon: ({ className }) => + }, + { + id: 'gitlab', + label: 'GitLab', + description: 'Show GitLab in the Tasks source picker and sidebar shortcuts.', + Icon: ({ className }) => + }, + { + id: 'linear', + label: 'Linear', + description: 'Show Linear in the Tasks source picker and sidebar shortcuts.', + Icon: ({ className }) => + } +] + +export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.JSX.Element { + const visibleProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) + + const toggleProvider = (provider: TaskProvider): void => { + const isVisible = visibleProviders.includes(provider) + if (isVisible && visibleProviders.length === 1) { + return + } + + const nextProviders = isVisible + ? visibleProviders.filter((entry) => entry !== provider) + : TASK_PROVIDERS.filter((entry) => entry === provider || visibleProviders.includes(entry)) + + updateSettings({ + visibleTaskProviders: nextProviders, + defaultTaskSource: resolveVisibleTaskProvider(settings.defaultTaskSource, nextProviders) + }) + } + + return ( +
+
+
+

Task Sources

+

+ Choose which task providers appear in the Tasks page source picker and sidebar + shortcuts. At least one provider must stay visible. +

+
+ + + {TASK_PROVIDER_OPTIONS.map((option) => { + const enabled = visibleProviders.includes(option.id) + const isLastEnabled = enabled && visibleProviders.length === 1 + const Icon = option.Icon + + return ( + + ) + })} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/tasks-search.ts b/src/renderer/src/components/settings/tasks-search.ts new file mode 100644 index 00000000000..a309c42412d --- /dev/null +++ b/src/renderer/src/components/settings/tasks-search.ts @@ -0,0 +1,9 @@ +import type { SettingsSearchEntry } from './settings-search' + +export const TASKS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Task Providers', + description: 'Choose which task providers appear in the Tasks page and sidebar shortcuts.', + keywords: ['tasks', 'provider', 'source', 'github', 'gitlab', 'linear', 'display', 'hide'] + } +] diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index febf70e7aea..2bc8c6f7200 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -7,6 +7,10 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace' import { LinearIcon } from '@/components/icons/LinearIcon' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' +import { + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider +} from '../../../../shared/task-providers' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -22,6 +26,16 @@ const SidebarNav = React.memo(function SidebarNav() { // Why: the setting is opt-out (default true). `!== false` keeps the button // visible for users whose persisted settings predate this field. const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) + const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders) + const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') + const visibleTaskProviders = React.useMemo( + () => normalizeVisibleTaskProviders(rawVisibleTaskProviders), + [rawVisibleTaskProviders] + ) + const resolvedDefaultTaskSource = React.useMemo( + () => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders), + [defaultTaskSource, visibleTaskProviders] + ) // Why: warm the GitHub work-item cache on hover/focus so by the time the // user's click finishes the round-trip has either completed or is already @@ -30,7 +44,7 @@ const SidebarNav = React.memo(function SidebarNav() { const activeRepoId = useAppStore((s) => s.activeRepoId) const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all') const handlePrefetch = React.useCallback(() => { - if (!canBrowseTasks) { + if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') { return } const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null @@ -48,7 +62,15 @@ const SidebarNav = React.memo(function SidebarNav() { getTaskPresetQuery(defaultTaskViewPreset) ) } - }, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, repos]) + }, [ + activeRepoId, + canBrowseTasks, + defaultTaskViewPreset, + prefetchWorkItems, + repoMap, + repos, + resolvedDefaultTaskSource + ]) const tasksActive = activeView === 'tasks' const automationsActive = activeView === 'automations' @@ -119,49 +141,57 @@ const SidebarNav = React.memo(function SidebarNav() { /> Tasks - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - openTaskPage({ taskSource: 'github' }) - }} - className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" - > - - - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - openTaskPage({ taskSource: 'gitlab' }) - }} - className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" - aria-label="Open GitLab tasks" - > - - - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - openTaskPage({ taskSource: 'linear' }) - }} - className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" - > - - + {visibleTaskProviders.includes('github') ? ( + { + e.stopPropagation() + if (!canBrowseTasks) { + return + } + openTaskPage({ taskSource: 'github' }) + }} + className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" + aria-label="Open GitHub tasks" + > + + + ) : null} + {visibleTaskProviders.includes('gitlab') ? ( + { + e.stopPropagation() + if (!canBrowseTasks) { + return + } + openTaskPage({ taskSource: 'gitlab' }) + }} + className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" + aria-label="Open GitLab tasks" + > + + + ) : null} + {visibleTaskProviders.includes('linear') ? ( + { + e.stopPropagation() + if (!canBrowseTasks) { + return + } + openTaskPage({ taskSource: 'linear' }) + }} + className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground" + aria-label="Open Linear tasks" + > + + + ) : null} ) : null} diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index eb11cbf64f6..4a30c09f905 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -8,6 +8,7 @@ import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' +import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers' export type SettingsSlice = { settings: GlobalSettings | null @@ -229,6 +230,11 @@ export const createSettingsSlice: StateCreator updates.terminalQuickCommands ) } + if ('visibleTaskProviders' in updates) { + sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders( + updates.visibleTaskProviders + ) + } await window.api.settings.set(sanitizedUpdates) set((s) => { if (!s.settings) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a6688a47dc5..a24fe6aa507 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -8,6 +8,7 @@ import type { PersistedTrustedOrcaHooks, PersistedUIState, StatusBarItem, + TaskProvider, TaskResumeState, TaskViewPresetId, TuiAgent, @@ -21,6 +22,10 @@ import { type WorkspaceCleanupDismissal } from '../../../../shared/workspace-cleanup' import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' +import { + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider +} from '../../../../shared/task-providers' import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES @@ -249,7 +254,7 @@ export type UISlice = { taskPageData: { preselectedRepoId?: string prefilledName?: string - taskSource?: 'github' | 'linear' | 'gitlab' + taskSource?: TaskProvider } taskResumeState: TaskResumeState | undefined setTaskResumeState: (updates: Partial) => void @@ -295,6 +300,7 @@ export type UISlice = { | 'general' | 'browser' | 'appearance' + | 'tasks' | 'terminal' | 'computer-use' | 'developer-permissions' @@ -504,7 +510,11 @@ export const createUISlice: StateCreator = (set, get) // be deduped. This removes ~300–800ms of perceived latency on initial // page load. const state = get() - const resolvedSource = data.taskSource ?? state.settings?.defaultTaskSource ?? 'github' + const visibleTaskProviders = normalizeVisibleTaskProviders(state.settings?.visibleTaskProviders) + const resolvedSource = resolveVisibleTaskProvider( + data.taskSource ?? state.settings?.defaultTaskSource, + visibleTaskProviders + ) const resolvedMode = state.taskResumeState?.githubMode ?? 'items' if (resolvedSource === 'github' && resolvedMode === 'items') { const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 777b7142f86..fbc518ccf16 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -14,6 +14,7 @@ import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' +import { TASK_PROVIDERS } from './task-providers' export const SCHEMA_VERSION = 1 export const DEFAULT_APP_FONT_FAMILY = 'Geist' @@ -230,6 +231,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', + visibleTaskProviders: [...TASK_PROVIDERS], defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/shared/task-providers.test.ts b/src/shared/task-providers.test.ts new file mode 100644 index 00000000000..6ef00ac85c2 --- /dev/null +++ b/src/shared/task-providers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from './task-providers' + +describe('task providers', () => { + it('normalizes provider lists while preserving supported order', () => { + expect(normalizeVisibleTaskProviders(['gitlab', 'unknown', 'gitlab', 'linear'])).toEqual([ + 'gitlab', + 'linear' + ]) + }) + + it('falls back to all providers when none are visible', () => { + expect(normalizeVisibleTaskProviders([])).toEqual(['github', 'gitlab', 'linear']) + }) + + it('resolves hidden preferred providers to the first visible provider', () => { + expect(resolveVisibleTaskProvider('github', ['linear'])).toBe('linear') + }) +}) diff --git a/src/shared/task-providers.ts b/src/shared/task-providers.ts new file mode 100644 index 00000000000..a94484ad60d --- /dev/null +++ b/src/shared/task-providers.ts @@ -0,0 +1,35 @@ +export type TaskProvider = 'github' | 'gitlab' | 'linear' + +export const TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear'] + +const TASK_PROVIDER_SET = new Set(TASK_PROVIDERS) + +export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] { + if (!Array.isArray(value)) { + return [...TASK_PROVIDERS] + } + + const normalized: TaskProvider[] = [] + for (const provider of value) { + if (!TASK_PROVIDER_SET.has(provider as TaskProvider)) { + continue + } + if (!normalized.includes(provider as TaskProvider)) { + normalized.push(provider as TaskProvider) + } + } + + // Why: at least one provider must remain visible so the Tasks surface always + // has a valid source to select after settings hydration or manual edits. + return normalized.length > 0 ? normalized : [...TASK_PROVIDERS] +} + +export function resolveVisibleTaskProvider( + preferred: TaskProvider | null | undefined, + visibleProviders: readonly TaskProvider[] +): TaskProvider { + if (preferred && visibleProviders.includes(preferred)) { + return preferred + } + return visibleProviders[0] ?? 'github' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9e8f171acca..688b137daf5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,10 +7,12 @@ import type { MigrationUnsupportedPtyEntry } from './agent-status-types' import type { VoiceSettings } from './speech-types' import type { WorkspaceCleanupUIState } from './workspace-cleanup' import type { GitLabProjectSettings } from './gitlab-types' +import type { TaskProvider } from './task-providers' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events' +export type { TaskProvider } from './task-providers' // ─── Shell PATH hydration ──────────────────────────────────────────── // Why: shared so the main-side `HydrationResult` discriminator and the @@ -1350,7 +1352,11 @@ export type GlobalSettings = { defaultTaskViewPreset: TaskViewPresetId /** Why: persists the user's last-used task source so the Tasks page * reopens to the same provider instead of always defaulting to GitHub. */ - defaultTaskSource: 'github' | 'linear' | 'gitlab' + defaultTaskSource: TaskProvider + /** Why: users may only work from one hosted task system. Persisting this + * list hides unused providers from Tasks chrome and sidebar shortcuts while + * leaving the chosen default source stable when it is still visible. */ + visibleTaskProviders: TaskProvider[] /** Why: persists the user's repo selection in the cross-repo tasks view. * `null` means sticky-all — every eligible repo is selected, including * repos added in future sessions, so the "All repos" label stays From a1a78d12d59f05a78c5b325bd3a54ca457186991 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 14:28:45 -0700 Subject: [PATCH 010/177] fix: address review findings (#1990) --- .../activity/ActivityPrototypePage.test.ts | 33 +++++ .../activity/ActivityPrototypePage.tsx | 121 ++++++++++++------ 2 files changed, 117 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index d56c546be6c..dfbe612c34a 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -5,6 +5,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { formatAgentTypeLabel } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' @@ -240,6 +241,38 @@ describe('buildActivityEvents', () => { }) }) + it('creates a thread for a repo-less floating terminal agent', () => { + const tab = makeTabWithIds('tab-1', FLOATING_TERMINAL_WORKTREE_ID, 'Claude') + const result = buildActivityEvents({ + agentStatusByPaneKey: { + [PANE_KEY]: makeWorkingEntryWithoutHistory() + }, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [tab] + }, + worktreeMap: new Map(), + repoMap: new Map(), + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + + const threads = makeThreads(result) + + expect(result.events).toHaveLength(0) + expect(threads).toHaveLength(1) + expect(threads[0]).toMatchObject({ + paneKey: PANE_KEY, + paneTitle: 'New run', + currentAgentState: 'working', + repo: null + }) + expect(threads[0].worktree).toMatchObject({ + id: FLOATING_TERMINAL_WORKTREE_ID, + displayName: 'Floating terminal' + }) + }) + it('matches a custom-titled live thread by its current prompt', () => { const tab = { ...makeTab(), customTitle: 'Pinned agent title' } const entry = { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 6188cadfbca..80c318e8d46 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -53,6 +53,7 @@ import { type ActivityTerminalPortalTarget } from './activity-terminal-portal' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStateHistoryEntry, @@ -145,6 +146,7 @@ const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [ 'done', 'interrupted' ] +const STANDALONE_ACTIVITY_WORKTREE_REPO_ID = '__activity_standalone__' const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { year: 'numeric', @@ -475,6 +477,30 @@ function freshActivityLiveAgentState( return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null } +function standaloneActivityWorktree(worktreeId: string): Worktree { + const displayName = + worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'Floating terminal' : 'Standalone terminal' + return { + id: worktreeId, + repoId: STANDALONE_ACTIVITY_WORKTREE_REPO_ID, + path: '', + head: '', + branch: displayName, + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + // Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history. const EVENTS_PER_PANE_CAP = 5 @@ -585,8 +611,8 @@ export function buildActivityEvents(args: { const tabContext = new Map() const liveAgentByPaneKey: Record = {} - for (const worktree of args.worktreeMap.values()) { - const tabs = args.tabsByWorktree[worktree.id] ?? [] + for (const [worktreeId, tabs] of Object.entries(args.tabsByWorktree)) { + const worktree = args.worktreeMap.get(worktreeId) ?? standaloneActivityWorktree(worktreeId) for (const tab of tabs) { tabContext.set(tab.id, { worktree, tab }) } @@ -671,7 +697,11 @@ export function buildActivityEvents(args: { if (!parsePaneKey(paneKey)) { continue } - const worktree = args.worktreeMap.get(retained.worktreeId) + const worktree = + args.worktreeMap.get(retained.worktreeId) ?? + (args.tabsByWorktree[retained.worktreeId] + ? standaloneActivityWorktree(retained.worktreeId) + : null) if (!worktree) { continue } @@ -1000,6 +1030,7 @@ function ThreadRow({ onSelect, onJump, onMarkUnread, + canJump, compactMode }: { thread: AgentPaneThread @@ -1007,6 +1038,7 @@ function ThreadRow({ onSelect: () => void onJump: () => void onMarkUnread: () => void + canJump: boolean compactMode: boolean }): React.JSX.Element { const renderedResponsePreview = activityThreadResponseRenderPreview({ @@ -1140,32 +1172,34 @@ function ThreadRow({ the worktree name. Reserved layout via `invisible` + `pointer-events-none` keeps the worktree-name's flex-1 width stable across hover. */} - - - - - - Jump to workspace - - + {canJump ? ( + + + + + + Jump to workspace + + + ) : null}
) @@ -1271,8 +1305,10 @@ export default function ActivityPrototypePage(): React.JSX.Element { ? (allThreads.find((thread) => thread.paneKey === selectedPaneKey) ?? null) : null const selectedTabId = selectedThread?.tab.id ?? null + // Why: repo-less terminal buckets can still produce Activity rows, but the + // workspace Terminal tree only portals real worktrees. const selectedHasLiveTab = - selectedThread && selectedTabId + selectedThread && selectedTabId && storeData.worktreeMap.has(selectedThread.worktree.id) ? (storeData.tabsByWorktree[selectedThread.worktree.id] ?? []).some( (tab) => tab.id === selectedTabId ) @@ -1282,7 +1318,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { : null const displayedTabId = displayedThread?.tab.id ?? null const displayedHasLiveTab = - displayedThread && displayedTabId + displayedThread && displayedTabId && storeData.worktreeMap.has(displayedThread.worktree.id) ? (storeData.tabsByWorktree[displayedThread.worktree.id] ?? []).some( (tab) => tab.id === displayedTabId ) @@ -1443,19 +1479,23 @@ export default function ActivityPrototypePage(): React.JSX.Element { const activateThreadTerminal = (thread: AgentPaneThread): void => { const state = useAppStore.getState() + const worktree = getWorktreeMapFromState(state).get(thread.worktree.id) + if (!worktree) { + return + } // Why: retained-agent threads can outlive their tab. With no live tab, the // right pane shows the empty-state placeholder; reorienting the workspace // and dispatching focus to a dead tab id would just confuse the user. - const liveTabs = state.tabsByWorktree[thread.worktree.id] ?? [] + const liveTabs = state.tabsByWorktree[worktree.id] ?? [] const hasLiveTab = liveTabs.some((t) => t.id === thread.tab.id) if (!hasLiveTab) { return } - if (state.activeRepoId !== thread.worktree.repoId) { - state.setActiveRepo(thread.worktree.repoId) + if (state.activeRepoId !== worktree.repoId) { + state.setActiveRepo(worktree.repoId) } - if (state.activeWorktreeId !== thread.worktree.id) { - state.setActiveWorktree(thread.worktree.id) + if (state.activeWorktreeId !== worktree.id) { + state.setActiveWorktree(worktree.id) } state.setActiveTabType('terminal') const parsed = parsePaneKey(thread.paneKey) @@ -1497,6 +1537,10 @@ export default function ActivityPrototypePage(): React.JSX.Element { ]) const jumpToWorkspace = (thread: AgentPaneThread): void => { + const state = useAppStore.getState() + if (!getWorktreeMapFromState(state).has(thread.worktree.id)) { + return + } markThreadRead(thread) activateAndRevealWorktree(thread.worktree.id) } @@ -1626,6 +1670,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { onSelect={() => selectThread(thread)} onJump={() => jumpToWorkspace(thread)} onMarkUnread={() => markThreadUnread(thread)} + canJump={storeData.worktreeMap.has(thread.worktree.id)} compactMode={compactMode} /> ))} @@ -1695,7 +1740,9 @@ export default function ActivityPrototypePage(): React.JSX.Element { return (
- Agent terminal closed. Open a new terminal in this workspace to continue. + {storeData.worktreeMap.has(selectedThread.worktree.id) + ? 'Agent terminal closed. Open a new terminal in this workspace to continue.' + : 'Standalone terminal unavailable in Activity.'}
) } From c393e4e779fbbe315ea3e3a9ae5b895191b52f02 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 14:29:37 -0700 Subject: [PATCH 011/177] Fix terminal code pointer reveals (#1991) --- .../terminal-link-handlers.test.ts | 95 ++++++++++++++++++- .../terminal-pane/terminal-link-handlers.ts | 20 +++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5aa8b5766e2..98a111a5009 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -28,6 +28,7 @@ const runtimeEnvironmentCallMock = vi.fn() const runtimeEnvironmentTransportCallMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() +const setPendingEditorRevealMock = vi.fn() const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' } const storeState = { @@ -36,7 +37,8 @@ const storeState = { | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, - openFile: openFileMock + openFile: openFileMock, + setPendingEditorReveal: setPendingEditorRevealMock } vi.mock('@/store', () => ({ @@ -65,6 +67,23 @@ function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function flushDoubleRaf(): Promise { + await flushAsyncWork() + await flushAsyncWork() +} + beforeEach(() => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() @@ -76,6 +95,7 @@ beforeEach(() => { storeState.settings = undefined registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { + dispatchEvent: vi.fn(), api: { shell: { openUrl: openUrlMock, @@ -90,6 +110,9 @@ beforeEach(() => { runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } } }) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + return setTimeout(() => callback(0), 0) as unknown as number + }) }) afterEach(() => { @@ -191,6 +214,7 @@ describe('handleOscLink', () => { // Why: .html should not open Monaco — it should render in the browser tab. expect(openFileMock).not.toHaveBeenCalled() + expect(setPendingEditorRevealMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith( 'wt-1', 'file:///tmp/report.html', @@ -205,6 +229,7 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(openFileMock).not.toHaveBeenCalled() + expect(setPendingEditorRevealMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith( 'wt-1', 'file:///tmp/legacy.HTM', @@ -212,6 +237,41 @@ describe('handleOscLink', () => { ) }) + it('schedules Monaco reveal with default column 1 for :line links', async () => { + setPlatform('Macintosh') + + openDetectedFilePath('/tmp/src/main.ts', 42, null, deps) + await flushAsyncWork() + await flushDoubleRaf() + + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/src/main.ts' }) + ) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/main.ts', + line: 42, + column: 1, + matchLength: 0 + }) + }) + + it('preserves explicit column for :line:column links', async () => { + setPlatform('Macintosh') + + openDetectedFilePath('/tmp/src/main.ts', 42, 7, deps) + await flushAsyncWork() + await flushDoubleRaf() + + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/main.ts', + line: 42, + column: 7, + matchLength: 0 + }) + }) + it('advertises the browser-open behavior in the html hover hint', () => { setPlatform('Macintosh') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open in browser') @@ -386,6 +446,39 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() }) + + it('ignores stale async completion so latest click wins for open and reveal', async () => { + setPlatform('Macintosh') + const firstStat = createDeferred<{ isDirectory: boolean }>() + const secondStat = createDeferred<{ isDirectory: boolean }>() + statMock + .mockImplementationOnce(() => firstStat.promise) + .mockImplementationOnce(() => secondStat.promise) + + openDetectedFilePath('/tmp/src/first.ts', 10, 2, deps) + openDetectedFilePath('/tmp/src/second.ts', 20, 3, deps) + + secondStat.resolve({ isDirectory: false }) + await flushAsyncWork() + await flushDoubleRaf() + + firstStat.resolve({ isDirectory: false }) + await flushAsyncWork() + await flushDoubleRaf() + + expect(openFileMock).toHaveBeenCalledTimes(1) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/src/second.ts' }) + ) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/second.ts', + line: 20, + column: 3, + matchLength: 0 + }) + expect(setPendingEditorRevealMock).toHaveBeenCalledTimes(2) + }) }) describe('createFilePathLinkProvider range bounds', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 582fac1e131..0e40e98f8e3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -87,6 +87,8 @@ function getTerminalFileContext( } } +let latestOpenDetectedFilePathRequestId = 0 + export function openDetectedFilePath( filePath: string, line: number | null, @@ -94,6 +96,7 @@ export function openDetectedFilePath( deps: Pick ): void { const { runtimeEnvironmentId, worktreeId, worktreePath } = deps + const requestId = ++latestOpenDetectedFilePathRequestId void (async () => { let statResult @@ -109,6 +112,10 @@ export function openDetectedFilePath( return } + if (requestId !== latestOpenDetectedFilePathRequestId) { + return + } + if (statResult.isDirectory) { const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) if (fileContext.connectionId || isRemoteRuntimeFileOperation(fileContext, filePath)) { @@ -158,11 +165,22 @@ export function openDetectedFilePath( }) if (line !== null) { + const targetColumn = column ?? 1 + store.setPendingEditorReveal(null) requestAnimationFrame(() => { requestAnimationFrame(() => { + if (requestId !== latestOpenDetectedFilePathRequestId) { + return + } + store.setPendingEditorReveal({ + filePath, + line, + column: targetColumn, + matchLength: 0 + }) window.dispatchEvent( new CustomEvent('orca:editor-reveal-location', { - detail: { filePath, line, column } + detail: { filePath, line, column: targetColumn } }) ) }) From 7ef3a59631055717a09317b9c0e9d9872eba9168 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:25:06 +0000 Subject: [PATCH 012/177] release: v1.4.2-rc.9 [rc-slot:2026-05-15-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd2cfa65bee..43f1355ea99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.8", + "version": "1.4.2-rc.9", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 03a12b82bf6b99749b40ae573a2027f502805845 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 15 May 2026 16:14:56 -0700 Subject: [PATCH 013/177] Fix agent pane identity migration bridge (#1993) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 184 +++++++++ src/main/agent-hooks/server.ts | 170 ++++++++- src/main/ipc/pty.test.ts | 61 ++- src/main/ipc/pty.ts | 78 ++-- src/main/persistence.test.ts | 563 +++++++++++++++++++++++++++- src/main/persistence.ts | 294 +++++++++++---- src/shared/constants.ts | 1 + src/shared/stable-pane-id.test.ts | 18 +- src/shared/stable-pane-id.ts | 22 ++ src/shared/types.ts | 8 + 10 files changed, 1253 insertions(+), 146 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index dbeaa3e5520..3cb87f33ecf 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -290,6 +290,50 @@ describe('AgentHookServer listener replay', () => { } }) + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + server.registerPaneKeyAlias('tab-1:0', PANE) + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { + hook_event_name: 'UserPromptSubmit', + prompt: 'legacy pane' + }, + { paneKey: 'tab-1:0' } + ) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'working', + prompt: 'legacy pane', + agentType: 'claude' + }) + }) + ) + } finally { + server.stop() + } + }) + it('tracks hook posts with an empty paneKey before dropping them', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -2127,6 +2171,112 @@ describe('Last-status persistence', () => { } }) + it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + prompt: 'legacy cached', + agentType: 'claude' + }) + ]) + } finally { + server.stop() + } + }) + + it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + const statusListener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1') + server.subscribeStatusChanges(statusListener) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toHaveLength(1) + + server.clearPaneKeyAliasesForPty('pty-1') + + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusListener).toHaveBeenCalledWith([]) + } finally { + server.stop() + } + }) + + it('does not clear a stable status when alias cleanup no longer owns that pane', () => { + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty') + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude' + }) + ]) + }) + it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync( @@ -2318,6 +2468,40 @@ describe('AgentHookServer ingestRemote', () => { expect(server.getStatusSnapshot()).toEqual([]) }) + it('maps registered legacy numeric relay pane keys to stable pane keys', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE) + server.setListener(listener) + server.ingestRemote( + { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + payload + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + state: 'working', + prompt: 'p' + }) + ]) + }) + it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 072874998e7..7fe76457992 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -39,7 +39,8 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' -import { parsePaneKey } from '../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import type { LegacyPaneKeyAliasEntry } from '../../shared/types' export type { AgentHookSource } @@ -63,6 +64,12 @@ export type AgentHookStatusChangeEntry = { } type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void +type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number +} // Why: name of the on-disk cache that survives Orca restart. Lives next to // the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- @@ -215,6 +222,8 @@ export class AgentHookServer { // Why: hydrated last-status rows are useful UI continuity, but they are not // evidence of live agent work in this main-process runtime. private runtimeObservedStatusPaneKeys = new Set() + private legacyPaneKeyAliases = new Map() + private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null // Why: full path to the on-disk last-status cache. Set in start() from // userDataPath. Null when the server runs without a userDataPath (e.g. // tests that skip the userDataPath option) — in that case, persistence is @@ -305,6 +314,121 @@ export class AgentHookServer { } } + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + private notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean } + ): void { + const legacy = parseLegacyNumericPaneKey(legacyPaneKey) + const stable = parsePaneKey(stablePaneKey) + if (!legacy || !stable || legacy.tabId !== stable.tabId) { + return + } + const existing = this.legacyPaneKeyAliases.get(legacy.paneKey) + if (existing && options?.overwriteExisting === false) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + if ( + existing && + existing.stablePaneKey === stablePaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt + ) { + return + } + this.legacyPaneKeyAliases.set(legacy.paneKey, { + stablePaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt + }) + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId === ptyId) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + } + if (shouldClearStablePaneKey) { + // Why: after hydrate, legacy rows are stored under the stable key. If + // this PTY is later proven dead before ptyPaneKey is rebuilt, alias + // cleanup is the only path that can evict that retained status. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + } + aliasChanged = true + } + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + private resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: pre-migration live shells keep posting their immutable numeric + // ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this + // bridge lets hook caches and renderer state use only the stable key. + return { ...record, paneKey: stablePaneKey } + } + /** Ingest a payload that arrived over the relay JSON-RPC channel rather * than the local HTTP server. `connectionId` is the SshChannelMultiplexer * identity Orca holds (the wire envelope carries connectionId: null and @@ -341,7 +465,7 @@ export class AgentHookServer { // Why: match the listener's HTTP path — `normalizeHookPayload` trims and // length-caps paneKey before caching, so the cache key here must follow // the same rule or remote-vs-local events for the same pane would diverge. - const paneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim()) const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) @@ -460,7 +584,12 @@ export class AgentHookServer { } trackEmptyPaneKeyHook(body) - const normalized = normalizeHookPayload(this.state, source, body, this.env) + const normalized = normalizeHookPayload( + this.state, + source, + this.normalizeHookBodyPaneKeyAlias(body), + this.env + ) if (normalized) { const enriched = this.attachStatusTiming(normalized) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) @@ -527,6 +656,7 @@ export class AgentHookServer { this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() } @@ -539,24 +669,37 @@ export class AgentHookServer { * lands. clearPaneState (which wipes all three caches) is the right shape * only for PTY-teardown. */ dropStatusEntry(paneKey: string): void { - if (!this.state.lastStatusByPaneKey.has(paneKey)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) { return } - this.state.lastStatusByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) // Why: only schedule a write when we actually evicted a status entry — // dropping prompt/tool caches for a pane that never produced a hook // event does not change the on-disk file, and skipping the write avoids // re-stat'ing on every dead-pane teardown. - const hadStatus = this.state.lastStatusByPaneKey.has(paneKey) - clearPaneCacheState(this.state, paneKey) + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { + if (stablePaneKey.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + clearedAlias = true + } + } + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } if (hadStatus) { - this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } @@ -655,9 +798,14 @@ export class AgentHookServer { // tick. const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { - const entry = sanitizeHydratedEntry(paneKey, rawEntry) + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { - this.state.lastStatusByPaneKey.set(paneKey, entry) + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) hydrated += 1 } else { dropped += 1 diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2cd81d45df2..b2c29bff0e3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -21,6 +21,7 @@ const { openCodeClearPtyMock, buildAgentHookEnvMock, clearAgentHookPaneStateMock, + registerPaneKeyAliasMock, piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, @@ -30,7 +31,8 @@ const { unregisterPtyMock, setMigrationUnsupportedPtyMock, clearMigrationUnsupportedPtyMock, - clearMigrationUnsupportedPtysForPaneKeyMock + clearMigrationUnsupportedPtysForPaneKeyMock, + clearPaneKeyAliasesForPtyMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -50,6 +52,7 @@ const { openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), + registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), @@ -58,7 +61,8 @@ const { unregisterPtyMock: vi.fn(), setMigrationUnsupportedPtyMock: vi.fn(), clearMigrationUnsupportedPtyMock: vi.fn(), - clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn() + clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(), + clearPaneKeyAliasesForPtyMock: vi.fn() })) vi.mock('electron', () => ({ @@ -101,7 +105,9 @@ vi.mock('../opencode/hook-service', () => ({ vi.mock('../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, - clearPaneState: clearAgentHookPaneStateMock + clearPaneState: clearAgentHookPaneStateMock, + registerPaneKeyAlias: registerPaneKeyAliasMock, + clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock } })) @@ -206,6 +212,7 @@ describe('registerPtyHandlers', () => { openCodeClearPtyMock.mockReset() buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() + registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() @@ -216,6 +223,7 @@ describe('registerPtyHandlers', () => { setMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtysForPaneKeyMock.mockReset() + clearPaneKeyAliasesForPtyMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -2326,9 +2334,10 @@ describe('registerPtyHandlers', () => { } }) - it('registers only validated stable pane keys in the local PTY memory registry', async () => { + it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => { registerPtyHandlers(mainWindow as never) const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2340,22 +2349,17 @@ describe('registerPtyHandlers', () => { expect(registerPtyMock).toHaveBeenLastCalledWith( expect.objectContaining({ - paneKey: null + paneKey: stablePaneKey }) ) - expect(setMigrationUnsupportedPtyMock).toHaveBeenCalledWith( - expect.objectContaining({ - ptyId: expect.any(String), - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId, - paneKey: makePaneKey('tab-1', leafId), - reason: 'legacy-numeric-pane-key', - source: 'local' - }) + expect(registerPaneKeyAliasMock).toHaveBeenCalledWith( + 'tab-1:0', + stablePaneKey, + expect.any(String) ) + expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey) + expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled() - const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2422,6 +2426,31 @@ describe('registerPtyHandlers', () => { expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey) }) + it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => { + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) + + const current = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + })) as { id: string } + + expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id) + clearPaneKeyAliasesForPtyMock.mockClear() + + clearProviderPtyState('old-pty-without-forward-pane-key') + + const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find( + ([ptyId]) => ptyId === 'old-pty-without-forward-pane-key' + )?.[1] + expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false) + }) + it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a0d4cc4c26e..58fdcc55876 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -38,11 +38,15 @@ import { } from '../../shared/telemetry-events' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { readShellStartupEnvVar } from '../pty/shell-startup-env' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey, - setMigrationUnsupportedPty + clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' // ─── Provider Registry ────────────────────────────────────────────── @@ -119,28 +123,6 @@ function isValidPaneKey(paneKey: unknown): paneKey is string { return parseValidPaneKey(paneKey) !== null } -function parseLegacyNumericPaneKey( - paneKey: unknown -): { tabId: string; numericPaneId: string } | null { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - const trimmed = paneKey.trim() - const delimiter = trimmed.indexOf(':') - if ( - delimiter <= 0 || - delimiter !== trimmed.lastIndexOf(':') || - delimiter === trimmed.length - 1 - ) { - return null - } - const numericPaneId = trimmed.slice(delimiter + 1) - if (!/^\d+$/.test(numericPaneId)) { - return null - } - return { tabId: trimmed.slice(0, delimiter), numericPaneId } -} - function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' if (!isValidPaneKey(normalizedPaneKey)) { @@ -462,19 +444,31 @@ export function clearProviderPtyState(id: string): void { openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) ptySizes.delete(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias + // ownership is our only proof. Once a newer PTY owns the same stable + // paneKey, alias teardown must not erase that newer status. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to // correlate a ptyId back to its paneKey. - const paneKey = ptyPaneKey.get(id) if (paneKey) { - const stillOwnsPaneKey = paneKeyPtyId.get(paneKey) === id if (stillOwnsPaneKey) { agentHookServer.clearPaneState(paneKey) paneKeyPtyId.delete(paneKey) @@ -1151,15 +1145,16 @@ export function registerPtyHandlers( isTerminalLeafId(args.leafId) ? makePaneKey(args.tabId, args.leafId) : null + const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey const baseEnv = baseEnvWithAuth - ? { ...baseEnvWithAuth, ...(verifiedPaneKey ? { ORCA_PANE_KEY: verifiedPaneKey } : {}) } + ? { ...baseEnvWithAuth, ...(stablePaneKey ? { ORCA_PANE_KEY: stablePaneKey } : {}) } : undefined - if (baseEnv && !verifiedPaneKey) { + if (baseEnv && !stablePaneKey) { // Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the // key proven to match this spawn's tab+leaf may leave the IPC boundary. delete baseEnv.ORCA_PANE_KEY } - const validatedPaneKey = verifiedPaneKey + const validatedPaneKey = stablePaneKey const validatedLeafId = verifiedLeafId ?? metadataLeafId let env: Record | undefined = baseEnv const preAllocatedHandle = @@ -1445,24 +1440,17 @@ export function registerPtyHandlers( const rememberedPaneKey = validatedPaneKey ? rememberPaneKeyForPty(result.id, validatedPaneKey) : null - if (validatedPaneKey) { + if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + legacySpawnPaneKey.paneKey, + migrationUnsupportedPaneKey, + result.id + ) + clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) + } else if (validatedPaneKey) { if (!result.isReattach) { clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) } - } else if (migrationUnsupportedPaneKey && legacySpawnPaneKey) { - // Why: old live PTYs can still carry `${tabId}:${numericPaneId}` in - // ORCA_PANE_KEY. Only surface a migration row when this spawn also - // proves the owning UUID leaf through the renderer IPC metadata. - setMigrationUnsupportedPty({ - ptyId: result.id, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - tabId: legacySpawnPaneKey.tabId, - leafId: args.leafId, - paneKey: migrationUnsupportedPaneKey, - reason: 'legacy-numeric-pane-key', - source: args.connectionId ? 'ssh' : 'local', - updatedAt: Date.now() - }) } // Why: register local PTYs (connectionId falsy) with the memory // collector so it can walk each PTY's process subtree and attribute diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 1b6b38cc064..5846a1e45a5 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync import { join } from 'path' import { tmpdir } from 'os' import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types' -import { isTerminalLeafId } from '../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' // Shared mutable state so the electron mock can reference a per-test directory @@ -1241,6 +1241,567 @@ describe('Store', () => { expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) }) + it('hydrates legacy numeric agent status cache through the pane identity migration', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() - 1000, + payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null + if (leafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', leafId) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + tabId: 'tab1', + worktreeId: 'wt1', + state: 'working', + prompt: 'legacy numeric prompt', + agentType: 'claude' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty-1', 'pane:2': 'local-pty-2' } + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left legacy prompt', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left legacy prompt', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right legacy prompt', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy status rows even when PTY leaf bindings are absent', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left no binding', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right no binding', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left no binding', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right no binding', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('persists legacy pane-key aliases after the layout has been normalized', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + + const firstStore = await createStore() + const root = firstStore.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + const stableLeafId = root?.type === 'leaf' ? root.leafId : null + if (stableLeafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', stableLeafId) + firstStore.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: [ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ] + }) + ) + + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'working', prompt: 'post-normalize legacy prompt' } + } + } + }), + 'utf-8' + ) + + await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + state: 'working', + prompt: 'post-normalize legacy prompt' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + + const store = await createStore() + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if ( + !firstLeafId || + !secondLeafId || + !isTerminalLeafId(firstLeafId) || + !isTerminalLeafId(secondLeafId) + ) { + throw new Error('Expected remapped split leaf ids') + } + const activePaneKey = makePaneKey('tab1', secondLeafId) + const firstPaneKey = makePaneKey('tab1', firstLeafId) + const secondPaneKey = makePaneKey('tab1', secondLeafId) + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey: activePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey: firstPaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:2', + stablePaneKey: secondPaneKey + }) + ]) + }) + ) + }) + + it('converts unambiguous dev migration rows into persisted aliases', async () => { + const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } + } + } + }, + migrationUnsupportedPtyEntries: [ + { + ptyId: 'local-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + paneKey: stablePaneKey, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + } + ] + }) + + const store = await createStore() + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + migrationUnsupportedPtyEntries: [], + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ]) + }) + ) + }) + it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5f1aab4c33f..30a242d7a6a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -37,6 +37,7 @@ import type { OnboardingChecklistState, OnboardingOutcome, OnboardingState, + LegacyPaneKeyAliasEntry, TerminalPaneLayoutNode, TerminalLayoutSnapshot, TerminalTab, @@ -57,11 +58,17 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' -import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from './agent-hooks/migration-unsupported-pty-state' +import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' @@ -413,64 +420,135 @@ function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): st return undefined } +type PaneIdentityMigrationEntries = { + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] +} + function collectMigrationUnsupportedPtyEntries(args: { session: WorkspaceSessionState tabId: string inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' -}): MigrationUnsupportedPtyEntry[] { - const entries: MigrationUnsupportedPtyEntry[] = [] +}): PaneIdentityMigrationEntries { const worktreeId = findWorktreeIdForTab(args.session, args.tabId) const tab = worktreeId ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) : undefined - const pushEntry = (ptyId: string, leafId: string): void => { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] + const registeredLegacyPaneKeys = new Set() + const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 + const fallbackPtyId = + !hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined + const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { if (!isTerminalLeafId(leafId)) { - return + return false } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return + return false } - entries.push({ - ptyId, - ...(worktreeId ? { worktreeId } : {}), - tabId: args.tabId, - leafId, - paneKey, - reason: 'legacy-numeric-pane-key', - source: args.sourceForPtyId(ptyId), - updatedAt: Date.now() - }) + const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] + if (!numeric) { + return false + } + // Why: persisted PaneManager ids are 1-based. A zero-based alias in split + // layouts would make tab:1 ambiguous and can route the first pane to the second. + const legacyPaneKey = `${args.tabId}:${numeric}` + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) + registeredLegacyPaneKeys.add(legacyPaneKey) + if (ptyId) { + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + return true + } + return false } - for (const [inputLeafId, ptyId] of Object.entries(args.inputLayout.ptyIdsByLeafId ?? {})) { + const inputLeafIds = new Set([ + ...collectLayoutLeafIdsInOrder(args.inputLayout.root), + ...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}) + ]) + for (const inputLeafId of inputLeafIds) { if (isTerminalLeafId(inputLeafId)) { continue } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - pushEntry(ptyId, leafId) + registerLegacyAlias( + inputLeafId, + leafId, + args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId + ) } } - if ( - entries.length === 0 && - tab?.ptyId && - Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length === 0 - ) { + if (tab?.ptyId && !hasLeafPtyBindings) { const fallbackLeafId = args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) - if (fallbackLeafId) { - // Why: older single-pane sessions can reattach from tab.ptyId only, with - // no leaf binding to migrate. Their live shell env can still hold the - // legacy pane key, so surface the restart-required row for that PTY. - pushEntry(tab.ptyId, fallbackLeafId) + if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) { + const paneKey = makePaneKey(args.tabId, fallbackLeafId) + for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) { + if (registeredLegacyPaneKeys.has(legacyPaneKey)) { + continue + } + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId) + legacyPaneKeyAliasEntries.push({ + ptyId: tab.ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + } } } - return entries + // Why: legacy numeric pane keys are now bridged by aliases instead of + // persisted as restart-required rows. Existing saved rows are pruned during + // normalizePersistedPaneIdentityState. + return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } +} + +function legacyMigrationUnsupportedRowsToAliasEntries( + entries: MigrationUnsupportedPtyEntry[] +): LegacyPaneKeyAliasEntry[] { + const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter( + (entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey) + ) + const entriesByTabId = new Map() + for (const entry of normalizedEntries) { + const tabId = entry.tabId + if (!tabId) { + continue + } + entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry]) + } + const aliasEntries: LegacyPaneKeyAliasEntry[] = [] + for (const [tabId, tabEntries] of entriesByTabId) { + if (tabEntries.length !== 1) { + continue + } + const [entry] = tabEntries + if (!entry.paneKey) { + continue + } + // Why: pre-stable dev/RC migration rows did not store the old numeric + // key. Only synthesize the single-pane aliases when the row is unambiguous + // for its tab; split rows need layout-derived aliases instead of a guess. + for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) { + aliasEntries.push({ + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.paneKey, + updatedAt: entry.updatedAt + }) + } + } + return aliasEntries } function normalizeTerminalLayoutSnapshotForPersistence( @@ -596,19 +674,20 @@ function normalizeTerminalLayoutSnapshotForPersistence( function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, - priorLayoutsByTabId: Record = {}, - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local' + priorLayoutsByTabId: Record = {} ): { session: WorkspaceSessionState changed: boolean leafIdByInputLeafIdByTabId: Map> leafIdByPtyIdByTabId: Map> migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const normalized = normalizeTerminalLayoutSnapshotForPersistence( @@ -617,16 +696,15 @@ function normalizeWorkspaceSessionPaneIdentities( ) terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) - migrationUnsupportedEntries.push( - ...collectMigrationUnsupportedPtyEntries({ - session, - tabId, - inputLayout: layout, - normalizedLayout: normalized.snapshot, - leafIdByInputLeafId: normalized.leafIdByInputLeafId, - sourceForPtyId - }) - ) + const migrationEntries = collectMigrationUnsupportedPtyEntries({ + session, + tabId, + inputLayout: layout, + normalizedLayout: normalized.snapshot, + leafIdByInputLeafId: normalized.leafIdByInputLeafId + }) + migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries) + legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries) const leafIdByPtyId = new Map() const duplicatePtyIds = new Set() for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { @@ -648,7 +726,8 @@ function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries + migrationUnsupportedEntries, + legacyPaneKeyAliasEntries } } @@ -686,31 +765,39 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { - const sshPtyIds = new Set((state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) - const normalizedSession = normalizeWorkspaceSessionPaneIdentities( - state.workspaceSession, - {}, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') - ) + const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], normalizedSession.leafIdByInputLeafIdByTabId, normalizedSession.leafIdByPtyIdByTabId ) - const mergedMigrationUnsupportedEntries = mergeMigrationUnsupportedPtyEntries([ - ...(state.migrationUnsupportedPtyEntries ?? []), - ...normalizedSession.migrationUnsupportedEntries + const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ + ...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries), + ...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []), + ...normalizedSession.legacyPaneKeyAliasEntries ]) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries ) - if (!normalizedSession.changed && !remappedLeases.changed && !migrationUnsupportedChanged) { + const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual( + state.legacyPaneKeyAliasEntries ?? [], + mergedLegacyPaneKeyAliasEntries + ) + if ( + !normalizedSession.changed && + !remappedLeases.changed && + !migrationUnsupportedChanged && + !legacyAliasesChanged + ) { return { state, changed: false, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } return { @@ -718,26 +805,15 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { ...state, workspaceSession: normalizedSession.session, sshRemotePtyLeases: remappedLeases.leases, - migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries }, changed: true, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } -function mergeMigrationUnsupportedPtyEntries( - entries: MigrationUnsupportedPtyEntry[] -): MigrationUnsupportedPtyEntry[] { - const byPtyId = new Map() - for (const entry of entries) { - const existing = byPtyId.get(entry.ptyId) - if (!existing || existing.updatedAt <= entry.updatedAt) { - byPtyId.set(entry.ptyId, entry) - } - } - return [...byPtyId.values()] -} - function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsupportedPtyEntry[] { if (!Array.isArray(value)) { return [] @@ -761,6 +837,57 @@ function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsup }) } +function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is LegacyPaneKeyAliasEntry => { + if (!entry || typeof entry !== 'object') { + return false + } + const candidate = entry as Partial + if ( + typeof candidate.ptyId !== 'string' || + candidate.ptyId.trim().length === 0 || + typeof candidate.legacyPaneKey !== 'string' || + typeof candidate.stablePaneKey !== 'string' || + !Number.isFinite(candidate.updatedAt) + ) { + return false + } + const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey) + const stable = parsePaneKey(candidate.stablePaneKey) + return Boolean(legacy && stable && legacy.tabId === stable.tabId) + }) +} + +function mergeLegacyPaneKeyAliasEntries( + entries: LegacyPaneKeyAliasEntry[] +): LegacyPaneKeyAliasEntry[] { + const byLegacyPaneKey = new Map() + for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) { + const existing = byLegacyPaneKey.get(entry.legacyPaneKey) + if (!existing || existing.updatedAt <= entry.updatedAt) { + byLegacyPaneKey.set(entry.legacyPaneKey, entry) + } + } + return [...byLegacyPaneKey.values()] +} + +function legacyPaneKeyAliasEntriesEqual( + left: LegacyPaneKeyAliasEntry[], + right: LegacyPaneKeyAliasEntry[] +): boolean { + if (left.length !== right.length) { + return false + } + const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry])) + return left.every((entry) => { + const other = rightByLegacyPaneKey.get(entry.legacyPaneKey) + return other ? JSON.stringify(entry) === JSON.stringify(other) : false + }) +} + function migrationUnsupportedEntriesEqual( left: MigrationUnsupportedPtyEntry[], right: MigrationUnsupportedPtyEntry[] @@ -815,10 +942,23 @@ export class Store { for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries this.scheduleSave() }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + this.scheduleSave() + }) if (normalized.changed) { // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at // the main persistence boundary so older renderer writes cannot revive them. @@ -1098,6 +1238,9 @@ export class Store { migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( + parsed.legacyPaneKeyAliasEntries + ), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [], onboarding: (() => { @@ -1872,15 +2015,22 @@ export class Store { // the durable binding and re-open the orphan window. Merge in any // existing bindings whenever the incoming snapshot's binding is empty. const prior = this.state.workspaceSession - const sshPtyIds = new Set((this.state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) const normalized = normalizeWorkspaceSessionPaneIdentities( session, - prior?.terminalLayoutsByTabId, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') + prior?.terminalLayoutsByTabId ) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } session = normalized.session const remappedLeases = remapSshRemotePtyLeaseLeafIds( this.state.sshRemotePtyLeases ?? [], diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fbc518ccf16..d451336ecef 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -322,6 +322,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { sshTargets: [], sshRemotePtyLeases: [], migrationUnsupportedPtyEntries: [], + legacyPaneKeyAliasEntries: [], automations: [], automationRuns: [], onboarding: getDefaultOnboardingState() diff --git a/src/shared/stable-pane-id.test.ts b/src/shared/stable-pane-id.test.ts index a380cfacb27..9c77a95539b 100644 --- a/src/shared/stable-pane-id.test.ts +++ b/src/shared/stable-pane-id.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { isStablePaneId, isTerminalLeafId, makePaneKey, parsePaneKey } from './stable-pane-id' +import { + isStablePaneId, + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -39,4 +45,14 @@ describe('stable pane ids', () => { expect(parsePaneKey(`:${LEAF_ID}`)).toBeNull() expect(parsePaneKey('tab-1:')).toBeNull() }) + + it('parses legacy numeric pane keys only for migration aliases', () => { + expect(parseLegacyNumericPaneKey(' tab-1:12 ')).toEqual({ + tabId: 'tab-1', + numericPaneId: '12', + paneKey: 'tab-1:12' + }) + expect(parseLegacyNumericPaneKey(`tab-1:${LEAF_ID}`)).toBeNull() + expect(parseLegacyNumericPaneKey('tab:1:12')).toBeNull() + }) }) diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts index 75da3a6a232..a68117f3d74 100644 --- a/src/shared/stable-pane-id.ts +++ b/src/shared/stable-pane-id.ts @@ -43,3 +43,25 @@ export function parsePaneKey( } return { tabId, leafId, stablePaneId: leafId } } + +export function parseLegacyNumericPaneKey( + paneKey: unknown +): { tabId: string; numericPaneId: string; paneKey: string } | null { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + const trimmed = paneKey.trim() + const delimiter = trimmed.indexOf(':') + if ( + delimiter <= 0 || + delimiter !== trimmed.lastIndexOf(':') || + delimiter === trimmed.length - 1 + ) { + return null + } + const numericPaneId = trimmed.slice(delimiter + 1) + if (!/^\d+$/.test(numericPaneId)) { + return null + } + return { tabId: trimmed.slice(0, delimiter), numericPaneId, paneKey: trimmed } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 688b137daf5..b85faf388a1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1804,6 +1804,13 @@ export type PersistedTrustedOrcaHookRepo = { export type PersistedTrustedOrcaHooks = Record +export type LegacyPaneKeyAliasEntry = { + ptyId: string + legacyPaneKey: string + stablePaneKey: string + updatedAt: number +} + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number @@ -1822,6 +1829,7 @@ export type PersistedState = { sshTargets: SshTarget[] sshRemotePtyLeases: SshRemotePtyLease[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] automations: Automation[] automationRuns: AutomationRun[] onboarding: OnboardingState From bce3ef17762a09ee799fc3286846ec769d3f71c3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 19:33:15 -0400 Subject: [PATCH 014/177] Add OpenCode usage analytics (#1986) Co-authored-by: Orca --- src/main/index.ts | 8 + src/main/ipc/opencode-usage.ts | 43 + src/main/ipc/register-core-handlers.test.ts | 12 + src/main/ipc/register-core-handlers.ts | 4 + src/main/opencode-usage/scanner.test.ts | 269 +++++ src/main/opencode-usage/scanner.ts | 931 ++++++++++++++++++ src/main/opencode-usage/store.test.ts | 309 ++++++ src/main/opencode-usage/store.ts | 464 +++++++++ src/main/opencode-usage/types.ts | 124 +++ src/preload/api-types.ts | 35 + src/preload/index.ts | 16 + .../src/components/settings/Settings.tsx | 2 +- .../components/stats/OpenCodeUsagePane.tsx | 371 +++++++ .../src/components/stats/StatsPane.tsx | 92 +- .../components/stats/UsageOverviewPane.tsx | 44 +- .../stats/usage-overview-model.test.ts | 79 +- .../components/stats/usage-overview-model.ts | 76 +- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 18 + .../src/store/slices/opencode-usage.ts | 154 +++ .../slices/store-session-cascades.test.ts | 18 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 18 + .../store/slices/terminals-hydration.test.ts | 16 + src/renderer/src/store/types.ts | 2 + src/shared/opencode-usage-types.ts | 64 ++ tests/e2e/usage-overview.spec.ts | 22 +- 27 files changed, 3142 insertions(+), 53 deletions(-) create mode 100644 src/main/ipc/opencode-usage.ts create mode 100644 src/main/opencode-usage/scanner.test.ts create mode 100644 src/main/opencode-usage/scanner.ts create mode 100644 src/main/opencode-usage/store.test.ts create mode 100644 src/main/opencode-usage/store.ts create mode 100644 src/main/opencode-usage/types.ts create mode 100644 src/renderer/src/components/stats/OpenCodeUsagePane.tsx create mode 100644 src/renderer/src/store/slices/opencode-usage.ts create mode 100644 src/shared/opencode-usage-types.ts diff --git a/src/main/index.ts b/src/main/index.ts index af7af86db47..65170134d19 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' +import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -78,6 +79,7 @@ let store: Store | null = null let stats: StatsCollector | null = null let claudeUsage: ClaudeUsageStore | null = null let codexUsage: CodexUsageStore | null = null +let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null let claudeAccounts: ClaudeAccountService | null = null @@ -190,6 +192,7 @@ if (hasSingleInstanceLock) { initStatsPath() initClaudeUsagePath() initCodexUsagePath() + initOpenCodeUsagePath() enableMainProcessGpuFeatures() } @@ -209,6 +212,9 @@ function openMainWindow(): BrowserWindow { if (!codexUsage) { throw new Error('Codex usage store must be initialized before opening the main window') } + if (!openCodeUsage) { + throw new Error('OpenCode usage store must be initialized before opening the main window') + } if (!rateLimits) { throw new Error('Rate limit service must be initialized before opening the main window') } @@ -272,6 +278,7 @@ function openMainWindow(): BrowserWindow { stats, claudeUsage, codexUsage, + openCodeUsage, codexAccounts, claudeAccounts, rateLimits, @@ -664,6 +671,7 @@ app.whenReady().then(async () => { stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) + openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts new file mode 100644 index 00000000000..180403f370b --- /dev/null +++ b/src/main/ipc/opencode-usage.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../shared/opencode-usage-types' + +export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { + ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) + ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => + openCodeUsage.setEnabled(args.enabled) + ) + ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => + openCodeUsage.refresh(args?.force ?? false) + ) + ipcMain.handle( + 'openCodeUsage:getSummary', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getSummary(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getDaily', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getBreakdown', + ( + _event, + args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + } + ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + 'openCodeUsage:getRecentSessions', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => + openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f81db6fddf8..82e700e592b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -7,6 +7,7 @@ const { registerPreflightHandlersMock, registerClaudeUsageHandlersMock, registerCodexUsageHandlersMock, + registerOpenCodeUsageHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -48,6 +49,7 @@ const { registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), registerCodexUsageHandlersMock: vi.fn(), + registerOpenCodeUsageHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -110,6 +112,10 @@ vi.mock('./codex-usage', () => ({ registerCodexUsageHandlers: registerCodexUsageHandlersMock })) +vi.mock('./opencode-usage', () => ({ + registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +})) + vi.mock('./github', () => ({ registerGitHubHandlers: registerGitHubHandlersMock })) @@ -245,6 +251,7 @@ describe('registerCoreHandlers', () => { registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() registerCodexUsageHandlersMock.mockReset() + registerOpenCodeUsageHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -288,6 +295,7 @@ describe('registerCoreHandlers', () => { const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } + const openCodeUsage = { marker: 'openCodeUsage' } const codexAccounts = { marker: 'codexAccounts' } const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } @@ -298,6 +306,7 @@ describe('registerCoreHandlers', () => { stats as never, claudeUsage as never, codexUsage as never, + openCodeUsage as never, codexAccounts as never, claudeAccounts as never, rateLimits as never @@ -305,6 +314,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) + expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerPetHandlersMock).toHaveBeenCalled() @@ -348,6 +358,7 @@ describe('registerCoreHandlers', () => { const stats2 = { marker: 'stats2' } const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } + const openCodeUsage2 = { marker: 'openCodeUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } const claudeAccounts2 = { marker: 'claudeAccounts2' } const rateLimits2 = { marker: 'rateLimits2' } @@ -358,6 +369,7 @@ describe('registerCoreHandlers', () => { stats2 as never, claudeUsage2 as never, codexUsage2 as never, + openCodeUsage2 as never, codexAccounts2 as never, claudeAccounts2 as never, rateLimits2 as never, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f3e5bcce61a..8d2c6a50642 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -11,6 +11,7 @@ import { import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' +import { registerOpenCodeUsageHandlers } from './opencode-usage' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' @@ -50,6 +51,7 @@ import { } from '../window/attach-main-window-services' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { RateLimitService } from '../rate-limits/service' import type { CodexAccountService } from '../codex-accounts/service' import type { ClaudeAccountService } from '../claude-accounts/service' @@ -64,6 +66,7 @@ export function registerCoreHandlers( stats: StatsCollector, claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, + openCodeUsage: OpenCodeUsageStore, codexAccounts: CodexAccountService, claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, @@ -88,6 +91,7 @@ export function registerCoreHandlers( registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) + registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() registerAgentTrustHandlers() diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts new file mode 100644 index 00000000000..625effd4ce6 --- /dev/null +++ b/src/main/opencode-usage/scanner.test.ts @@ -0,0 +1,269 @@ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' + +const WORKTREE = '/workspace/repo' + +let tempDirs: string[] = [] + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function worktrees() { + return [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: WORKTREE, + displayName: 'Repo', + canonicalPath: WORKTREE + } + ] +} + +describe('parseOpenCodeUsageRow', () => { + it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { + const parsed = parseOpenCodeUsageRow({ + id: 'message-1', + session_id: 'session-1', + time_created: 1_777_777_700_000, + time_updated: null, + directory: null, + title: null, + worktree: null, + session_model: null, + data: JSON.stringify({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + path: { cwd: `${WORKTREE}/packages/app` }, + cost: 0.0123, + tokens: { + input: 1000, + output: 250, + reasoning: 100, + total: 1350, + cache: { read: 400, write: 25 } + }, + time: { + completed: 1_777_777_800_000 + } + }) + }) + + expect(parsed).toEqual({ + sessionId: 'session-1', + timestamp: new Date(1_777_777_800_000).toISOString(), + cwd: `${WORKTREE}/packages/app`, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.0123, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + }) + }) +}) + +describe('parseOpenCodeUsageDatabase', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] + }) + + it('uses materialized session token totals when the OpenCode DB has them', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE) + db.prepare( + `INSERT INTO session ( + id, project_id, directory, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'session-1', + 'project-1', + `${WORKTREE}/packages/app`, + 'Build feature', + JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }), + 0.06, + 1000, + 500, + 100, + 250, + 1_777_777_700_000, + 1_777_777_800_000 + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]).toMatchObject({ + sessionId: 'session-1', + primaryModel: 'anthropic/claude-sonnet-4-5', + primaryProjectLabel: 'Repo', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 250, + totalOutputTokens: 500, + totalReasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + expect(parsed.dailyAggregates).toEqual([ + expect.objectContaining({ + projectLabel: 'Repo', + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + ]) + }) + + it('supports session_message tables without a type column', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + cost: 0.03, + tokens: { + input: 800, + output: 200, + reasoning: 50, + cache: { read: 100, write: 0 } + } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]).toMatchObject({ + primaryModel: 'openai/gpt-5.5', + primaryProjectLabel: 'Repo', + totalTokens: 1050, + estimatedCostUsd: 0.03 + }) + }) + + it('prefers session_message rows over legacy message rows to avoid double counting', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + type TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + 'session-message-1', + 'session-1', + 'assistant', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } } + }) + ) + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'legacy-message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + role: 'assistant', + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]?.totalTokens).toBe(120) + expect(parsed.sessions[0]?.eventCount).toBe(1) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts new file mode 100644 index 00000000000..3066d707e7b --- /dev/null +++ b/src/main/opencode-usage/scanner.ts @@ -0,0 +1,931 @@ +/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ +import Database from 'better-sqlite3' +import { existsSync } from 'fs' +import { readdir, realpath, stat } from 'fs/promises' +import { homedir } from 'os' +import { isAbsolute, join, posix, win32 } from 'path' +import type { Repo } from '../../shared/types' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import type { + OpenCodeUsageAttributedEvent, + OpenCodeUsageDailyAggregate, + OpenCodeUsageLocationBreakdown, + OpenCodeUsageLocationModelBreakdown, + OpenCodeUsageModelBreakdown, + OpenCodeUsageParsedEvent, + OpenCodeUsagePersistedDatabase, + OpenCodeUsageProcessedDatabase, + OpenCodeUsageSession +} from './types' + +export type OpenCodeUsageWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +const YIELD_EVERY_DATABASES = 2 + +function ensureNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function normalizeComparablePath(pathValue: string, platform = process.platform): string { + const normalized = pathValue.replace(/\\/g, '/') + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? normalized.toLowerCase() + : normalized +} + +function normalizeFsPath(pathValue: string, platform = process.platform): string { + if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { + return win32.normalize(win32.resolve(pathValue)) + } + return posix.normalize(posix.resolve(pathValue)) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getXdgDataHome(): string { + if (process.env.XDG_DATA_HOME?.trim()) { + return process.env.XDG_DATA_HOME.trim() + } + if (process.platform === 'win32') { + return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') + } + return join(homedir(), '.local', 'share') +} + +function getOpenCodeDataDirectory(): string { + return join(getXdgDataHome(), 'opencode') +} + +function getOpenCodeDatabasePathFromEnv(): string | null { + const raw = process.env.OPENCODE_DB?.trim() + if (!raw) { + return null + } + if (raw === ':memory:') { + return null + } + return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) +} + +export async function listOpenCodeDatabases(): Promise { + const envPath = getOpenCodeDatabasePathFromEnv() + if (envPath) { + return existsSync(envPath) ? [envPath] : [] + } + + try { + const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .sort() + } catch { + return [] + } +} + +export async function getProcessedDatabaseInfo( + dbPath: string +): Promise { + const dbStat = await stat(dbPath) + return { + path: dbPath, + mtimeMs: dbStat.mtimeMs, + size: dbStat.size + } +} + +async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} + +function parseJsonObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string') { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function extractModelLabel(data: Record, sessionModel: unknown): string | null { + const directModel = extractString(data.modelID) ?? extractString(data.modelId) + const directProvider = extractString(data.providerID) ?? extractString(data.providerId) + if (directModel) { + return directProvider ? `${directProvider}/${directModel}` : directModel + } + + const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) + if (!modelObject) { + return null + } + const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) + const providerID = extractString(modelObject.providerID) + if (!modelID) { + return null + } + return providerID ? `${providerID}/${modelID}` : modelID +} + +function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { + const pathData = parseJsonObject(data.path) + return ( + extractString(pathData?.cwd) ?? + extractString(row.directory) ?? + extractString(row.worktree) ?? + null + ) +} + +function normalizeMillis(value: unknown): number | null { + const numeric = ensureNumber(value) + if (numeric <= 0) { + return null + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric +} + +function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { + const timeData = parseJsonObject(data.time) + const millis = + normalizeMillis(timeData?.completed) ?? + normalizeMillis(timeData?.created) ?? + normalizeMillis(row.time_updated) ?? + normalizeMillis(row.time_created) + return millis ? new Date(millis).toISOString() : null +} + +export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { + const data = parseJsonObject(row.data) + if (!data) { + return null + } + + const tokens = parseJsonObject(data.tokens) + if (!tokens) { + return null + } + const cache = parseJsonObject(tokens.cache) + const inputTokens = ensureNumber(tokens.input) + const outputTokens = ensureNumber(tokens.output) + const reasoningOutputTokens = ensureNumber(tokens.reasoning) + const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) + const totalTokens = + ensureNumber(tokens.total) > 0 + ? ensureNumber(tokens.total) + : inputTokens + outputTokens + reasoningOutputTokens + + if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { + return null + } + + const timestamp = extractTimestamp(data, row) + if (!timestamp) { + return null + } + + return { + sessionId: row.session_id, + timestamp, + cwd: extractCwd(data, row), + model: extractModelLabel(data, row.session_model), + estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + } +} + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' +} + +async function buildWorktreesWithCanonicalPaths( + worktrees: OpenCodeUsageWorktreeRef[] +): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { + const canonicalized = await Promise.all( + worktrees.map(async (worktree) => ({ + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + })) + ) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +} + +async function canonicalizePath(pathValue: string): Promise { + try { + return normalizeFsPath(await realpath(pathValue)) + } catch { + return normalizeFsPath(pathValue) + } +} + +function findContainingWorktree( + cwd: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): OpenCodeUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeOpenCodeUsageEvent( + event: OpenCodeUsageParsedEvent, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession { + return { + sessionId: event.sessionId, + firstTimestamp: event.timestamp, + lastTimestamp: event.timestamp, + primaryModel: event.model, + hasMixedModels: false, + primaryProjectLabel: event.projectLabel, + hasMixedLocations: false, + primaryWorktreeId: event.worktreeId, + primaryRepoId: event.repoId, + eventCount: 0, + totalInputTokens: 0, + totalCachedInputTokens: 0, + totalOutputTokens: 0, + totalReasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + locationBreakdown: [], + modelBreakdown: [], + locationModelBreakdown: [] + } +} + +function createEmptyDailyAggregate( + event: OpenCodeUsageAttributedEvent +): OpenCodeUsageDailyAggregate { + return { + day: event.day, + model: event.model, + projectKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } +} + +function mergeLocationBreakdown( + target: OpenCodeUsageLocationBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeModelBreakdown( + target: OpenCodeUsageModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const key = event.model ?? 'unknown' + const existing = target.find((entry) => entry.modelKey === key) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + modelKey: key, + modelLabel: event.model ?? 'Unknown model', + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeLocationModelBreakdown( + target: OpenCodeUsageLocationModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const modelKey = event.model ?? 'unknown' + const existing = + target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? + null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + modelKey, + modelLabel: event.model ?? 'Unknown model', + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} { + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const event of events) { + const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) + if (!sessionsById.has(event.sessionId)) { + sessionsById.set(event.sessionId, session) + } + if (event.timestamp < session.firstTimestamp) { + session.firstTimestamp = event.timestamp + } + if (event.timestamp >= session.lastTimestamp) { + session.lastTimestamp = event.timestamp + } + session.eventCount++ + session.totalInputTokens += event.inputTokens + session.totalCachedInputTokens += event.cachedInputTokens + session.totalOutputTokens += event.outputTokens + session.totalReasoningOutputTokens += event.reasoningOutputTokens + session.totalTokens += event.totalTokens + session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd) + mergeLocationBreakdown(session.locationBreakdown, event) + mergeModelBreakdown(session.modelBreakdown, event) + mergeLocationModelBreakdown(session.locationModelBreakdown, event) + + const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') + const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) + if (!dailyByKey.has(dailyKey)) { + dailyByKey.set(dailyKey, daily) + } + daily.eventCount++ + daily.inputTokens += event.inputTokens + daily.cachedInputTokens += event.cachedInputTokens + daily.outputTokens += event.outputTokens + daily.reasoningOutputTokens += event.reasoningOutputTokens + daily.totalTokens += event.totalTokens + daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd) + } + + return { + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +function finalizeSessions(sessionsById: Map): OpenCodeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + const primaryLocation = session.locationBreakdown[0] ?? null + const primaryModel = session.modelBreakdown[0] ?? null + session.primaryProjectLabel = + session.locationBreakdown.length <= 1 + ? (primaryLocation?.projectLabel ?? 'Unknown location') + : 'Multiple locations' + session.hasMixedLocations = session.locationBreakdown.length > 1 + session.primaryWorktreeId = primaryLocation?.worktreeId ?? null + session.primaryRepoId = primaryLocation?.repoId ?? null + session.primaryModel = + session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' + session.hasMixedModels = session.modelBreakdown.length > 1 + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + +function mergeSessions( + target: Map, + sessions: OpenCodeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + existing.firstTimestamp = + session.firstTimestamp < existing.firstTimestamp + ? session.firstTimestamp + : existing.firstTimestamp + existing.lastTimestamp = + session.lastTimestamp > existing.lastTimestamp + ? session.lastTimestamp + : existing.lastTimestamp + existing.eventCount += session.eventCount + existing.totalInputTokens += session.totalInputTokens + existing.totalCachedInputTokens += session.totalCachedInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens + existing.totalTokens += session.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd) + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.eventCount += location.eventCount + existingLocation.inputTokens += location.inputTokens + existingLocation.cachedInputTokens += location.cachedInputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.reasoningOutputTokens += location.reasoningOutputTokens + existingLocation.totalTokens += location.totalTokens + existingLocation.estimatedCostUsd = addCost( + existingLocation.estimatedCostUsd, + location.estimatedCostUsd + ) + } else { + existing.locationBreakdown.push({ ...location }) + } + } + + for (const model of session.modelBreakdown) { + const existingModel = + existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null + if (existingModel) { + existingModel.eventCount += model.eventCount + existingModel.inputTokens += model.inputTokens + existingModel.cachedInputTokens += model.cachedInputTokens + existingModel.outputTokens += model.outputTokens + existingModel.reasoningOutputTokens += model.reasoningOutputTokens + existingModel.totalTokens += model.totalTokens + existingModel.estimatedCostUsd = addCost( + existingModel.estimatedCostUsd, + model.estimatedCostUsd + ) + } else { + existing.modelBreakdown.push({ ...model }) + } + } + + for (const locationModel of session.locationModelBreakdown) { + const existingLocationModel = + existing.locationModelBreakdown.find( + (entry) => + entry.locationKey === locationModel.locationKey && + entry.modelKey === locationModel.modelKey + ) ?? null + if (existingLocationModel) { + existingLocationModel.eventCount += locationModel.eventCount + existingLocationModel.inputTokens += locationModel.inputTokens + existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens + existingLocationModel.outputTokens += locationModel.outputTokens + existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens + existingLocationModel.totalTokens += locationModel.totalTokens + existingLocationModel.estimatedCostUsd = addCost( + existingLocationModel.estimatedCostUsd, + locationModel.estimatedCostUsd + ) + } else { + existing.locationModelBreakdown.push({ ...locationModel }) + } + } + } +} + +function mergeDailyAggregates( + target: Map, + dailyAggregates: OpenCodeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.eventCount += aggregate.eventCount + existing.inputTokens += aggregate.inputTokens + existing.cachedInputTokens += aggregate.cachedInputTokens + existing.outputTokens += aggregate.outputTokens + existing.reasoningOutputTokens += aggregate.reasoningOutputTokens + existing.totalTokens += aggregate.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd) + } +} + +export async function parseOpenCodeUsageDatabase( + dbPath: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const processedDatabase = await getProcessedDatabaseInfo(dbPath) + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + db.pragma('query_only = ON') + const events: OpenCodeUsageAttributedEvent[] = [] + for (const row of selectUsageRows(db)) { + const parsed = parseOpenCodeUsageRow(row) + if (!parsed) { + continue + } + const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) + if (attributed) { + events.push(attributed) + } + } + return { + ...processedDatabase, + ...aggregateOpenCodeUsage(events) + } + } finally { + db.close() + } +} + +export async function scanOpenCodeUsageDatabases( + worktrees: OpenCodeUsageWorktreeRef[], + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] +): Promise<{ + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +}> { + const dbPaths = await listOpenCodeDatabases() + const previousByPath = new Map( + previousProcessedDatabases.map((database) => [database.path, database]) + ) + const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] + const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const [index, dbPath] of dbPaths.entries()) { + const databaseInfo = await getProcessedDatabaseInfo(dbPath) + const previous = previousByPath.get(dbPath) + const canReuse = + previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size + const processed = canReuse + ? previous + : await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths) + + processedDatabases.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + + if ((index + 1) % YIELD_EVERY_DATABASES === 0) { + await yieldToEventLoop() + } + } + + return { + processedDatabases, + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +export function createWorktreeRefs( + repos: Repo[], + worktreesByRepo: Map +): OpenCodeUsageWorktreeRef[] { + const refs: OpenCodeUsageWorktreeRef[] = [] + for (const repo of repos) { + for (const worktree of worktreesByRepo.get(repo.id) ?? []) { + refs.push({ + repoId: repo.id, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + } + } + return refs +} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts new file mode 100644 index 00000000000..bca001390c2 --- /dev/null +++ b/src/main/opencode-usage/store.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedState, + OpenCodeUsageSession +} from './types' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { OpenCodeUsageStore, normalizePersistedState } from './store' + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +function createStoreWithState(state: Partial): OpenCodeUsageStore { + const store = new OpenCodeUsageStore({ + getRepos: () => [], + getWorktreeMeta: () => undefined + } as never) + + ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { + ...getDefaultState(), + ...state + } + + return store +} + +function makeSession(overrides: Partial = {}): OpenCodeUsageSession { + const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo' + const repoId = overrides.primaryRepoId ?? 'repo-1' + const projectLabel = overrides.primaryProjectLabel ?? 'Repo' + const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5' + return { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T10:00:00.000Z', + lastTimestamp: '2026-04-09T10:10:00.000Z', + primaryModel: model, + hasMixedModels: false, + primaryProjectLabel: projectLabel, + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: repoId, + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + locationBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel, + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + modelBreakdown: [ + { + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + locationModelBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + ...overrides + } +} + +function makeDaily( + overrides: Partial = {} +): OpenCodeUsageDailyAggregate { + const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo' + return { + day: '2026-04-09', + model: 'anthropic/claude-sonnet-4-5', + projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel: worktreeId ? 'Repo' : 'outside/repo', + repoId: worktreeId ? 'repo-1' : null, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + ...overrides + } +} + +describe('OpenCodeUsageStore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ + primaryProjectLabel: 'outside/repo', + primaryWorktreeId: null, + primaryRepoId: null + }) + ], + dailyAggregates: [ + makeDaily({ + projectKey: 'cwd:/outside/repo', + projectLabel: 'outside/repo', + repoId: null, + worktreeId: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.hasAnyOpenCodeData).toBe(false) + expect(summary.sessions).toBe(0) + expect(summary.events).toBe(0) + }) + + it('uses recorded OpenCode costs and token totals without model pricing inference', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ sessionId: 'session-1' }), + makeSession({ + sessionId: 'session-2', + primaryModel: 'openai/gpt-5.5', + totalTokens: 2000, + estimatedCostUsd: null, + modelBreakdown: [ + { + modelKey: 'openai/gpt-5.5', + modelLabel: 'openai/gpt-5.5', + eventCount: 1, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + } + ] + }) + ], + dailyAggregates: [ + makeDaily(), + makeDaily({ + model: 'openai/gpt-5.5', + eventCount: 2, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + const daily = await store.getDaily('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary).toMatchObject({ + sessions: 2, + events: 3, + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350, + estimatedCostUsd: 0.05, + topModel: 'openai/gpt-5.5', + topProject: 'Repo', + hasAnyOpenCodeData: true + }) + expect(daily).toEqual([ + { + day: '2026-04-09', + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350 + } + ]) + expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({ + sessions: 1, + estimatedCostUsd: null + }) + }) + + it('returns recent sessions with OpenCode event and token fields', async () => { + const store = createStoreWithState({ + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + + const sessions = await store.getRecentSessions('orca', '30d', 5) + + expect(sessions).toEqual([ + { + sessionId: 'session-1', + lastActiveAt: '2026-04-09T10:10:00.000Z', + durationMinutes: 10, + projectLabel: 'Repo', + model: 'anthropic/claude-sonnet-4-5', + events: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + } + ]) + }) + + it('normalizes persisted OpenCode state by schema version', () => { + expect( + normalizePersistedState({ + ...getDefaultState(), + schemaVersion: 0, + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + } + ], + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + ).toEqual(getDefaultState()) + + expect( + normalizePersistedState({ + ...getDefaultState(), + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [], + dailyAggregates: [] + } + ] + }).processedDatabases + ).toHaveLength(1) + }) +}) diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts new file mode 100644 index 00000000000..1fd7d50830c --- /dev/null +++ b/src/main/opencode-usage/store.ts @@ -0,0 +1,464 @@ +/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +import { app } from 'electron' +import { dirname, join } from 'path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../shared/opencode-usage-types' +import type { Store } from '../persistence' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' +import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' + +const SCHEMA_VERSION = 1 +const STALE_MS = 5 * 60_000 + +let _openCodeUsageFile: string | null = null + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizePersistedState( + state: OpenCodeUsagePersistedState +): OpenCodeUsagePersistedState { + if (state.schemaVersion !== SCHEMA_VERSION) { + return getDefaultState() + } + return { + ...state, + processedDatabases: (state.processedDatabases ?? []).map((database) => ({ + ...database, + sessions: (database.sessions ?? []).map(normalizeSessionCost), + dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) + })), + sessions: state.sessions.map(normalizeSessionCost), + dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) + } +} + +export function initOpenCodeUsagePath(): void { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') +} + +function getOpenCodeUsageFile(): string { + if (!_openCodeUsageFile) { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') + } + return _openCodeUsageFile +} + +function getRangeCutoff(range: OpenCodeUsageRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const now = new Date() + now.setHours(0, 0, 0, 0) + now.setDate(now.getDate() - (days - 1)) + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getLocalDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getWorktreeFingerprint(worktreesByRepo: Map): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function normalizeDailyAggregateCost( + entry: OpenCodeUsageDailyAggregate +): OpenCodeUsageDailyAggregate { + return { + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + } +} + +function normalizeSessionCost( + session: OpenCodeUsagePersistedState['sessions'][number] +): OpenCodeUsagePersistedState['sessions'][number] { + return { + ...session, + estimatedCostUsd: session.estimatedCostUsd ?? null, + locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })) + } +} + +export class OpenCodeUsageStore { + private state: OpenCodeUsagePersistedState + private readonly store: Store + private scanPromise: Promise | null = null + + constructor(store: Store) { + this.store = store + this.state = this.load() + } + + private load(): OpenCodeUsagePersistedState { + try { + const usageFile = getOpenCodeUsageFile() + if (!existsSync(usageFile)) { + return getDefaultState() + } + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState + return normalizePersistedState({ + ...getDefaultState(), + ...parsed, + scanState: { + ...getDefaultState().scanState, + ...parsed.scanState + } + }) + } catch (error) { + console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) + return getDefaultState() + } + } + + private writeToDisk(): void { + const usageFile = getOpenCodeUsageFile() + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) + } + + async setEnabled(enabled: boolean): Promise { + this.state.scanState.enabled = enabled + this.writeToDisk() + return this.getScanState() + } + + getScanState(): OpenCodeUsageScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } + } + + async refresh(force = false): Promise { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) + const result = await scanOpenCodeUsageDatabases( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state.processedDatabases + : [] + ) + this.state.processedDatabases = result.processedDatabases + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + this.writeToDisk() + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + async getSummary( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd: number | null = null + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + } + + const topModel = + [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd, + topModel, + topProject, + hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } + } + + async getDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const byDay = new Map() + for (const row of this.getFilteredDaily(scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + } + + async getBreakdown( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + kind: OpenCodeUsageBreakdownKind + ): Promise { + await this.refresh(false) + const rows = new Map() + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) + rows.set(key, existing) + } + + if (kind === 'model') { + for (const session of filteredSessions) { + for (const entry of session.modelBreakdown) { + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + } + } + } else { + for (const session of filteredSessions) { + for (const entry of session.locationBreakdown) { + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + } + } + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + } + + async getRecentSessions( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + limit = 10 + ): Promise { + await this.refresh(false) + return this.getFilteredSessions(scope, range) + .slice(0, limit) + .map( + (session): OpenCodeUsageSessionRow => ({ + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - + new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ), + projectLabel: session.primaryProjectLabel, + model: session.primaryModel, + events: session.eventCount, + inputTokens: session.totalInputTokens, + cachedInputTokens: session.totalCachedInputTokens, + outputTokens: session.totalOutputTokens, + reasoningOutputTokens: session.totalReasoningOutputTokens, + totalTokens: session.totalTokens + }) + ) + } + + private getFilteredDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): OpenCodeUsageDailyAggregate[] { + const cutoff = getRangeCutoff(range) + return this.state.dailyAggregates.filter((row) => { + if (scope === 'orca' && !row.worktreeId) { + return false + } + if (cutoff && row.day < cutoff) { + return false + } + return true + }) + } + + private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { + const cutoff = getRangeCutoff(range) + return this.state.sessions.filter((session) => { + if (scope === 'orca' && !session.primaryWorktreeId) { + return false + } + if (cutoff) { + const day = getLocalDay(session.lastTimestamp) + if (!day || day < cutoff) { + return false + } + } + return true + }) + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts new file mode 100644 index 00000000000..ed7f3d2143c --- /dev/null +++ b/src/main/opencode-usage/types.ts @@ -0,0 +1,124 @@ +export type OpenCodeUsageProcessedDatabase = { + path: string + mtimeMs: number + size: number +} + +export type OpenCodeUsageLocationBreakdown = { + locationKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageModelBreakdown = { + modelKey: string + modelLabel: string + estimatedCostUsd: number | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageLocationModelBreakdown = { + locationKey: string + modelKey: string + modelLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSession = { + sessionId: string + firstTimestamp: string + lastTimestamp: string + primaryModel: string | null + hasMixedModels: boolean + primaryProjectLabel: string + hasMixedLocations: boolean + primaryWorktreeId: string | null + primaryRepoId: string | null + eventCount: number + totalInputTokens: number + totalCachedInputTokens: number + totalOutputTokens: number + totalReasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + locationBreakdown: OpenCodeUsageLocationBreakdown[] + modelBreakdown: OpenCodeUsageModelBreakdown[] + locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[] +} + +export type OpenCodeUsageDailyAggregate = { + day: string + model: string | null + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} + +export type OpenCodeUsagePersistedState = { + schemaVersion: number + worktreeFingerprint: string | null + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] + scanState: { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + } +} + +export type OpenCodeUsageParsedEvent = { + sessionId: string + timestamp: string + model: string | null + cwd: string | null + estimatedCostUsd: number | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index df7aaa2b59c..0fdce54eb6b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -211,6 +211,16 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { @@ -449,6 +459,30 @@ export type CodexUsageApi = { }) => Promise } +export type OpenCodeUsageApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSummary: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getDaily: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getBreakdown: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + }) => Promise + getRecentSessions: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + limit?: number + }) => Promise +} + export type AppApi = { /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; * in packaged builds this is file:// resources. Renderer appends filenames. */ @@ -1162,6 +1196,7 @@ export type PreloadApi = { memory: MemoryApi claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi + openCodeUsage: OpenCodeUsageApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index e7330fd1b9c..f6348d1b7b6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2418,6 +2418,22 @@ const api = { ipcRenderer.invoke('codexUsage:getRecentSessions', args) }, + openCodeUsage: { + getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), + setEnabled: (args: { enabled: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:setEnabled', args), + refresh: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:refresh', args), + getSummary: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getSummary', args), + getDaily: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getDaily', args), + getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getBreakdown', args), + getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => + ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2938353c754..050eb72bb44 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -570,7 +570,7 @@ function Settings(): React.JSX.Element { { id: 'stats', title: 'Stats & Usage', - description: 'Orca stats plus Claude and Codex usage analytics.', + description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.', icon: BarChart3, searchEntries: STATS_PANE_SEARCH_ENTRIES }, diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx new file mode 100644 index 00000000000..a8718b62b1c --- /dev/null +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -0,0 +1,371 @@ +import { useEffect } from 'react' +import { + Activity, + Brain, + Coins, + DatabaseZap, + FolderKanban, + RefreshCw, + SlidersHorizontal, + Sparkles +} from 'lucide-react' +import type { + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../../../shared/opencode-usage-types' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { StatCard } from './StatCard' + +const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] +const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ + { value: 'orca', label: 'Orca worktrees only' }, + { value: 'all', label: 'All local OpenCode usage' } +] +const RANGE_LABELS: Record = { + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + all: 'All time' +} + +function formatTokens(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + return value.toLocaleString() +} + +function formatCost(value: number | null): string { + if (value === null) { + return 'n/a' + } + return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}` +} + +function formatUpdatedAt(timestamp: number | null): string { + if (!timestamp) { + return 'Not scanned yet' + } + return `Updated ${new Date(timestamp).toLocaleString()}` +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function OpenCodeUsagePane(): React.JSX.Element { + const scanState = useAppStore((state) => state.openCodeUsageScanState) + const summary = useAppStore((state) => state.openCodeUsageSummary) + const daily = useAppStore((state) => state.openCodeUsageDaily) + const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown) + const projectBreakdown = useAppStore((state) => state.openCodeUsageProjectBreakdown) + const recentSessions = useAppStore((state) => state.openCodeUsageRecentSessions) + const scope = useAppStore((state) => state.openCodeUsageScope) + const range = useAppStore((state) => state.openCodeUsageRange) + const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage) + const setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) + const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope) + const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange) + + useEffect(() => { + void fetchOpenCodeUsage() + }, [fetchOpenCodeUsage]) + + if (!scanState?.enabled) { + return ( +
+
+
+

OpenCode Usage Tracking

+

+ Reads local OpenCode usage logs to show token, model, and session stats. +

+
+ +
+
+ ) + } + + if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { + return ( + + ) + } + + const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData + + return ( +
+
+
+

OpenCode Usage Tracking

+

+ {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''} +

+
+
+ + + + + + + + + + Filters + + + + + Scope + void setOpenCodeUsageScope(value as OpenCodeUsageScope)} + > + {SCOPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Range + void setOpenCodeUsageRange(value as OpenCodeUsageRange)} + > + {RANGE_OPTIONS.map((option) => ( + + {RANGE_LABELS[option]} + + ))} + + + + + + + + + + Refresh + + + + +
+
+ +
+

+ {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} +

+
+ + {!hasAnyData ? ( +
+ No local OpenCode usage found yet for this scope. +
+ ) : ( + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ Cost comes from the local OpenCode database when the assistant message recorded one. +

+ + + +
+
+
+

By model

+

+ Top model: {summary?.topModel ?? 'n/a'} +

+
+
+ {modelBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events + {row.estimatedCostUsd !== null + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+
+ +
+
+

By project

+

+ Top project: {summary?.topProject ?? 'n/a'} +

+
+
+ {projectBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events +
+
+ ))} +
+
+
+ +
+
+

Recent sessions

+

+ Most recent local OpenCode sessions in this scope. +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
Last activeProjectModelEventsInputOutputTotal
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel}{row.model ?? 'Unknown'}{row.events} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.totalTokens)} +
+
+
+ + )} +
+ ) +} diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 266b18a0e9a..80e9e2862f4 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -1,18 +1,26 @@ import { useEffect, useState } from 'react' -import { Bot, Clock, GitPullRequest } from 'lucide-react' +import { BarChart3, Bot, Check, ChevronDown, Clock, GitPullRequest } from 'lucide-react' import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { OpenCodeUsagePane } from './OpenCodeUsagePane' import { UsageOverviewPane } from './UsageOverviewPane' import type { SettingsSearchEntry } from '../settings/settings-search' -import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Stats & Usage', description: - 'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.', + 'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.', keywords: [ 'stats', 'usage', @@ -23,6 +31,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'tracking', 'claude', 'codex', + 'opencode', 'tokens', 'cache' ] @@ -57,10 +66,29 @@ function formatTrackingSince(timestamp: number | null): string { return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } +type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' + +const USAGE_ANALYTICS_OPTIONS = [ + { id: 'overview', label: 'Overview' }, + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'opencode', label: 'OpenCode' } +] as const satisfies readonly { id: UsageTab; label: string }[] + +function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element { + if (tab === 'overview') { + return + } + return +} + export function StatsPane(): React.JSX.Element { const summary = useAppStore((s) => s.statsSummary) const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary) - const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview') + const [activeUsageTab, setActiveUsageTab] = useState('overview') + const activeUsageOption = + USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ?? + USAGE_ANALYTICS_OPTIONS[0] useEffect(() => { void fetchStatsSummary() @@ -106,28 +134,40 @@ export function StatsPane(): React.JSX.Element {

Usage Analytics

-
- {(['overview', 'claude', 'codex'] as const).map((tab) => ( - - ))} -
+ + + {activeUsageOption.label} + + + + + + {USAGE_ANALYTICS_OPTIONS.map((option) => ( + setActiveUsageTab(option.id)}> + + + {option.label} + + + + ))} + +
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the @@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element { ) : activeUsageTab === 'claude' ? ( - ) : ( + ) : activeUsageTab === 'codex' ? ( + ) : ( + )}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 202fcdf09d4..f0f7c820975 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the + provider fetch wiring so the combined usage surface stays easy to audit. */ import { useEffect, useMemo } from 'react' import { Activity, @@ -143,7 +145,7 @@ function DailyIntensityGrid({

Daily intensity

- Recent combined Claude and Codex token activity. + Recent combined Claude, Codex, and OpenCode token activity.

{bestDay && bestDay.totalTokens > 0 ? ( @@ -248,17 +250,24 @@ export function UsageOverviewPane(): React.JSX.Element { const codexScanState = useAppStore((state) => state.codexUsageScanState) const codexSummary = useAppStore((state) => state.codexUsageSummary) const codexDaily = useAppStore((state) => state.codexUsageDaily) + const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState) + const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary) + const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily) const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage) const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage) + const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage) const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage) const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage) const enableCodexUsage = useAppStore((state) => state.enableCodexUsage) + const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage) useEffect(() => { void fetchClaudeUsage() void fetchCodexUsage() - }, [fetchClaudeUsage, fetchCodexUsage]) + void fetchOpenCodeUsage() + }, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage]) const overview = useMemo( () => @@ -272,9 +281,24 @@ export function UsageOverviewPane(): React.JSX.Element { scanState: codexScanState, summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: openCodeScanState, + summary: openCodeSummary, + daily: openCodeDaily } }), - [claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary] + [ + claudeDaily, + claudeScanState, + claudeSummary, + codexDaily, + codexScanState, + codexSummary, + openCodeDaily, + openCodeScanState, + openCodeSummary + ] ) const recentDays = useMemo( () => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT), @@ -285,7 +309,8 @@ export function UsageOverviewPane(): React.JSX.Element { const handleRefresh = (): void => { void Promise.all([ claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(), - codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve() + codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(), + openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve() ]) } @@ -334,6 +359,9 @@ export function UsageOverviewPane(): React.JSX.Element { + @@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element { {!overview.hasAnyData ? (
- No local Claude or Codex usage found yet. The overview will populate after the next - agent session writes token logs. + No local Claude, Codex, or OpenCode usage found yet. The overview will populate + after the next agent session writes token logs.
) : (
@@ -399,8 +427,10 @@ export function UsageOverviewPane(): React.JSX.Element { onEnable={() => { if (provider.id === 'claude') { void enableClaudeUsage() - } else { + } else if (provider.id === 'codex') { void enableCodexUsage() + } else { + void enableOpenCodeUsage() } }} /> diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts index 490bcc1b8f4..8fa9354d898 100644 --- a/src/renderer/src/components/stats/usage-overview-model.test.ts +++ b/src/renderer/src/components/stats/usage-overview-model.test.ts @@ -9,6 +9,11 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' import { buildUsageOverview, formatUsageCost, @@ -38,8 +43,19 @@ function enabledCodexScanState(): CodexUsageScanState { } } +function enabledOpenCodeScanState(): OpenCodeUsageScanState { + return { + enabled: true, + isScanning: false, + lastScanStartedAt: 500, + lastScanCompletedAt: 600, + lastScanError: null, + hasAnyOpenCodeData: true + } +} + describe('usage overview model', () => { - it('combines Claude and Codex totals without double-counting Codex cached input', () => { + it('combines provider totals without double-counting cached input', () => { const claudeSummary: ClaudeUsageSummary = { scope: 'orca', range: '30d', @@ -71,6 +87,21 @@ describe('usage overview model', () => { topProject: 'orca-secondary', hasAnyCodexData: true } + const openCodeSummary: OpenCodeUsageSummary = { + scope: 'orca', + range: '30d', + sessions: 1, + events: 2, + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600, + estimatedCostUsd: 0.03, + topModel: 'anthropic/claude-sonnet-4-5', + topProject: 'orca-third', + hasAnyOpenCodeData: true + } const claudeDaily: ClaudeUsageDailyPoint[] = [ { day: '2026-05-13', @@ -105,6 +136,16 @@ describe('usage overview model', () => { totalTokens: 1_200 } ] + const openCodeDaily: OpenCodeUsageDailyPoint[] = [ + { + day: '2026-05-15', + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600 + } + ] const overview = buildUsageOverview({ claude: { @@ -116,24 +157,30 @@ describe('usage overview model', () => { scanState: enabledCodexScanState(), summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: enabledOpenCodeScanState(), + summary: openCodeSummary, + daily: openCodeDaily } }) - expect(overview.totalTokens).toBe(9_200) - expect(overview.newInputTokens).toBe(2_200) - expect(overview.cacheTokens).toBe(5_300) - expect(overview.outputTokens).toBe(1_700) - expect(overview.reasoningTokens).toBe(300) - expect(overview.sessions).toBe(3) - expect(overview.activityCount).toBe(7) + expect(overview.totalTokens).toBe(10_800) + expect(overview.newInputTokens).toBe(2_950) + expect(overview.cacheTokens).toBe(5_550) + expect(overview.outputTokens).toBe(2_200) + expect(overview.reasoningTokens).toBe(400) + expect(overview.sessions).toBe(4) + expect(overview.activityCount).toBe(9) expect(overview.activeDays).toBe(3) - expect(overview.estimatedCostUsd).toBeCloseTo(0.06) - expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500) + expect(overview.estimatedCostUsd).toBeCloseTo(0.09) + expect(overview.cacheShare).toBeCloseTo(5_550 / 8_500) expect(overview.bestDay).toMatchObject({ day: '2026-05-14', totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }) expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({ @@ -141,6 +188,11 @@ describe('usage overview model', () => { cacheTokens: 800, totalTokens: 3_200 }) + expect(overview.providers.find((provider) => provider.id === 'opencode')).toMatchObject({ + newInputTokens: 750, + cacheTokens: 250, + totalTokens: 1_600 + }) }) it('pads recent usage days with zero-token cells', () => { @@ -151,6 +203,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 } ], @@ -164,6 +217,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 }, { @@ -171,6 +225,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }, { @@ -178,6 +233,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ]) @@ -186,7 +242,8 @@ describe('usage overview model', () => { it('reports disabled providers as an empty overview', () => { const overview = buildUsageOverview({ claude: { scanState: null, summary: null, daily: [] }, - codex: { scanState: null, summary: null, daily: [] } + codex: { scanState: null, summary: null, daily: [] }, + opencode: { scanState: null, summary: null, daily: [] } }) expect(overview.hasAnyEnabledProvider).toBe(false) diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts index 47104aec739..ba7be85d717 100644 --- a/src/renderer/src/components/stats/usage-overview-model.ts +++ b/src/renderer/src/components/stats/usage-overview-model.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share + one tested model so the overview UI cannot drift from the math. */ import type { ClaudeUsageDailyPoint, ClaudeUsageScanState, @@ -8,8 +10,13 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' -export type UsageProviderId = 'claude' | 'codex' +export type UsageProviderId = 'claude' | 'codex' | 'opencode' export type UsageProviderOverview = { id: UsageProviderId @@ -38,6 +45,7 @@ export type UsageOverviewDailyPoint = { totalTokens: number claudeTokens: number codexTokens: number + openCodeTokens: number intensity: 0 | 1 | 2 | 3 | 4 } @@ -74,6 +82,11 @@ export type UsageOverviewInput = { summary: CodexUsageSummary | null daily: CodexUsageDailyPoint[] } + opencode: { + scanState: OpenCodeUsageScanState | null + summary: OpenCodeUsageSummary | null + daily: OpenCodeUsageDailyPoint[] + } } function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { @@ -87,6 +100,13 @@ function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) } +function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { if (totalTokens <= 0 || maxTokens <= 0) { return 0 @@ -169,6 +189,34 @@ function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderO } } +function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'opencode', + label: 'OpenCode', + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getOpenCodeNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { const byDay = new Map>() @@ -177,7 +225,8 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } const total = getClaudeDailyTotal(entry) current.totalTokens += total @@ -190,13 +239,27 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } current.totalTokens += entry.totalTokens current.codexTokens += entry.totalTokens byDay.set(entry.day, current) } + for (const entry of input.opencode.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + current.totalTokens += entry.totalTokens + current.openCodeTokens += entry.totalTokens + byDay.set(entry.day, current) + } + const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens)) return [...byDay.values()] .sort((left, right) => left.day.localeCompare(right.day)) @@ -234,6 +297,7 @@ export function getRecentUsageDays( totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ) @@ -242,7 +306,11 @@ export function getRecentUsageDays( } export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel { - const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)] + const providers = [ + createClaudeProvider(input.claude), + createCodexProvider(input.codex), + createOpenCodeProvider(input.opencode) + ] const daily = buildDailyOverview(input) const bestDay = daily.length === 0 diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3337e94403a..3d91c806575 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -16,6 +16,7 @@ import { createMemorySlice } from './slices/memory' import { createWorkspaceSpaceSlice } from './slices/workspace-space' import { createClaudeUsageSlice } from './slices/claude-usage' import { createCodexUsageSlice } from './slices/codex-usage' +import { createOpenCodeUsageSlice } from './slices/opencode-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' @@ -45,6 +46,7 @@ export const useAppStore = create()((...a) => ({ ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 7f7018d7231..8b19aeb58f4 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -81,6 +81,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -103,6 +119,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -131,6 +148,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts new file mode 100644 index 00000000000..b388053bc3a --- /dev/null +++ b/src/renderer/src/store/slices/opencode-usage.ts @@ -0,0 +1,154 @@ +import type { StateCreator } from 'zustand' +import type { + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' +import type { AppState } from '../types' + +export type OpenCodeUsageSlice = { + openCodeUsageScope: OpenCodeUsageScope + openCodeUsageRange: OpenCodeUsageRange + openCodeUsageScanState: OpenCodeUsageScanState | null + openCodeUsageSummary: OpenCodeUsageSummary | null + openCodeUsageDaily: OpenCodeUsageDailyPoint[] + openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageRecentSessions: OpenCodeUsageSessionRow[] + setOpenCodeUsageEnabled: (enabled: boolean) => Promise + setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise + setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise + fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise + enableOpenCodeUsage: () => Promise + refreshOpenCodeUsage: () => Promise +} + +export const createOpenCodeUsageSlice: StateCreator = ( + set, + get +) => ({ + openCodeUsageScope: 'orca', + openCodeUsageRange: '30d', + openCodeUsageScanState: null, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [], + + setOpenCodeUsageEnabled: async (enabled) => { + try { + const nextScanState = (await window.api.openCodeUsage.setEnabled({ + enabled + })) as OpenCodeUsageScanState + set({ + openCodeUsageScanState: enabled + ? { + ...nextScanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : nextScanState, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [] + }) + if (enabled) { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } + } catch (error) { + console.error('Failed to update OpenCode usage setting:', error) + } + }, + + setOpenCodeUsageScope: async (scope) => { + set({ openCodeUsageScope: scope }) + await get().fetchOpenCodeUsage() + }, + + setOpenCodeUsageRange: async (range) => { + set({ openCodeUsageRange: range }) + await get().fetchOpenCodeUsage() + }, + + fetchOpenCodeUsage: async (opts) => { + try { + const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState + const currentScanState = get().openCodeUsageScanState + const shouldPreserveLoadingState = + opts?.forceRefresh === true && + currentScanState?.enabled === true && + get().openCodeUsageSummary === null + set({ + openCodeUsageScanState: shouldPreserveLoadingState + ? { + ...scanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : scanState + }) + if (!scanState.enabled) { + return + } + + const nextScanState = (await window.api.openCodeUsage.refresh({ + force: opts?.forceRefresh ?? false + })) as OpenCodeUsageScanState + const { openCodeUsageScope, openCodeUsageRange } = get() + + const [summary, daily, modelBreakdown, projectBreakdown, recentSessions] = await Promise.all([ + window.api.openCodeUsage.getSummary({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getDaily({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'model' + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'project' + }) as Promise, + window.api.openCodeUsage.getRecentSessions({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + limit: 10 + }) as Promise + ]) + + set({ + openCodeUsageScanState: nextScanState, + openCodeUsageSummary: summary, + openCodeUsageDaily: daily, + openCodeUsageModelBreakdown: modelBreakdown, + openCodeUsageProjectBreakdown: projectBreakdown, + openCodeUsageRecentSessions: recentSessions + }) + } catch (error) { + console.error('Failed to fetch OpenCode usage:', error) + } + }, + + enableOpenCodeUsage: async () => { + await get().setOpenCodeUsageEnabled(true) + }, + + refreshOpenCodeUsage: async () => { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } +}) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index bff2a4ff2ae..70524a3c070 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -84,6 +84,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -106,6 +122,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -134,6 +151,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index ab8b589ce24..e154a25224c 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -24,6 +24,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -60,6 +61,7 @@ export function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index a68f34b135f..8f19e24692a 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -78,6 +78,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -100,6 +116,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -130,6 +147,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 7be0c90bb17..ace73b30b77 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -69,6 +69,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index ec246109a53..bed69db258c 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -14,6 +14,7 @@ import type { MemorySlice } from './slices/memory' import type { WorkspaceSpaceSlice } from './slices/workspace-space' import type { ClaudeUsageSlice } from './slices/claude-usage' import type { CodexUsageSlice } from './slices/codex-usage' +import type { OpenCodeUsageSlice } from './slices/opencode-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' @@ -40,6 +41,7 @@ export type AppState = RepoSlice & WorkspaceSpaceSlice & ClaudeUsageSlice & CodexUsageSlice & + OpenCodeUsageSlice & BrowserSlice & RateLimitSlice & SshSlice & diff --git a/src/shared/opencode-usage-types.ts b/src/shared/opencode-usage-types.ts new file mode 100644 index 00000000000..d583fd0a931 --- /dev/null +++ b/src/shared/opencode-usage-types.ts @@ -0,0 +1,64 @@ +export type OpenCodeUsageScope = 'orca' | 'all' +export type OpenCodeUsageRange = '7d' | '30d' | '90d' | 'all' +export type OpenCodeUsageBreakdownKind = 'model' | 'project' + +export type OpenCodeUsageScanState = { + enabled: boolean + isScanning: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageSummary = { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + topModel: string | null + topProject: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageDailyPoint = { + day: string + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageBreakdownRow = { + key: string + label: string + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSessionRow = { + sessionId: string + lastActiveAt: string + durationMinutes: number + projectLabel: string + model: string | null + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} diff --git a/tests/e2e/usage-overview.spec.ts b/tests/e2e/usage-overview.spec.ts index 07559cbb535..6759a1a875c 100644 --- a/tests/e2e/usage-overview.spec.ts +++ b/tests/e2e/usage-overview.spec.ts @@ -19,17 +19,29 @@ test.describe('usage overview', () => { .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) .toBe('settings') await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible() - const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' }) - await expect( - providerTabs.getByRole('button', { name: 'Overview', exact: true }) - ).toHaveAttribute('aria-pressed', 'true') + const providerDropdown = orcaPage.getByTestId('usage-provider-select') + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: Overview' + ) await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Enable OpenCode' })).toBeVisible() - await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click() + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'Codex', exact: true }).click() await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute('aria-label', 'Usage analytics provider: Codex') + + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'OpenCode', exact: true }).click() + await expect(orcaPage.getByRole('heading', { name: 'OpenCode Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: OpenCode' + ) }) }) From 97f276cea8912aa88ed93c4bb7b3d027f3867f0c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 16:50:46 -0700 Subject: [PATCH 015/177] Place diff note popover below annotated line (#1948) * Place diff note popover below annotated line * Remove diff note popover design doc --- .../diff-comment-popover-position.test.ts | 47 +++++++++++++++++++ .../diff-comment-popover-position.ts | 25 ++++++++++ .../diff-comments/useDiffCommentDecorator.tsx | 6 ++- .../src/components/editor/DiffSectionItem.tsx | 12 ++++- .../src/components/editor/DiffViewer.tsx | 14 ++++-- 5 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts create mode 100644 src/renderer/src/components/diff-comments/diff-comment-popover-position.ts diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts b/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts new file mode 100644 index 00000000000..94b44652e81 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { editor as monacoEditor } from 'monaco-editor' +import { getDiffCommentPopoverTop } from './diff-comment-popover-position' + +function makeEditor({ + lineCount = 10, + scrollTop = 15, + topForLine = (lineNumber: number) => lineNumber * 20 +}: { + lineCount?: number + scrollTop?: number + topForLine?: (lineNumber: number) => number +} = {}): Parameters[0] { + return { + getModel: () => ({ getLineCount: () => lineCount }) as monacoEditor.ITextModel, + getScrollTop: () => scrollTop, + getTopForLineNumber: topForLine + } +} + +describe('getDiffCommentPopoverTop', () => { + it('positions the popover below the anchor line', () => { + const top = getDiffCommentPopoverTop(makeEditor(), 3, 20) + + expect(top).toBe(65) + }) + + it('uses a fallback line height when Monaco does not return a positive number', () => { + const top = getDiffCommentPopoverTop(makeEditor(), 3, 0) + + expect(top).toBe(64) + }) + + it('returns null when the editor has no model', () => { + const editor = { + ...makeEditor(), + getModel: () => null + } + + expect(getDiffCommentPopoverTop(editor, 3, 20)).toBeNull() + }) + + it('returns null for out-of-range line numbers', () => { + expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 3, 20)).toBeNull() + expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 0, 20)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts b/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts new file mode 100644 index 00000000000..fa2f7c9e545 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts @@ -0,0 +1,25 @@ +import type { editor as monacoEditor } from 'monaco-editor' + +type DiffCommentPopoverEditor = Pick< + monacoEditor.ICodeEditor, + 'getModel' | 'getScrollTop' | 'getTopForLineNumber' +> + +const FALLBACK_LINE_HEIGHT_PX = 19 + +export function getDiffCommentPopoverTop( + editor: DiffCommentPopoverEditor, + lineNumber: number, + lineHeight: unknown +): number | null { + const model = editor.getModel() + if (!model) { + return null + } + if (lineNumber < 1 || lineNumber > model.getLineCount()) { + return null + } + const resolvedLineHeight = + typeof lineHeight === 'number' && lineHeight > 0 ? lineHeight : FALLBACK_LINE_HEIGHT_PX + return editor.getTopForLineNumber(lineNumber) - editor.getScrollTop() + resolvedLineHeight +} diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index a05fcaacbb0..f1f5d8d7911 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -4,6 +4,7 @@ import type { editor as monacoEditor, IDisposable } from 'monaco-editor' import { createRoot, type Root } from 'react-dom/client' import type { DiffComment } from '../../../../shared/types' import { DiffCommentCard } from './DiffCommentCard' +import { getDiffCommentPopoverTop } from './diff-comment-popover-position' // Why: Monaco glyph-margin *decorations* don't expose click events in a way // that lets us show a polished popover anchored to a line. So instead we own a @@ -203,7 +204,10 @@ export function useDiffCommentDecorator({ } const startLine = Math.min(currentDrag.startLine, currentDrag.endLine) const lineNumber = Math.max(currentDrag.startLine, currentDrag.endLine) - const top = editor.getTopForLineNumber(lineNumber) - editor.getScrollTop() + const top = getDiffCommentPopoverTop(editor, lineNumber, getLineHeight()) + if (top == null) { + return + } onAddCommentClickRef.current({ lineNumber, startLine: startLine === lineNumber ? undefined : startLine, diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index f30e6266691..751d4c9e548 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -19,6 +19,7 @@ import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { computeLineStats } from './diff-line-stats' import type { DiffComment, GitDiffResult } from '../../../../shared/types' @@ -161,8 +162,15 @@ export function DiffSectionItem({ return } const update = (): void => { - const top = - modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop() + const top = getDiffCommentPopoverTop( + modifiedEditor, + popover.lineNumber, + modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight) + ) + if (top == null) { + setPopover(null) + return + } setPopover((prev) => (prev ? { ...prev, top } : prev)) } const scrollSub = modifiedEditor.onDidScrollChange(update) diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index c25f22235c0..4c3ff0d521e 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -3,12 +3,13 @@ import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import type { editor } from 'monaco-editor' import { useAppStore } from '@/store' import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache' -import '@/lib/monaco-setup' +import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { useContextualCopySetup } from './useContextualCopySetup' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' @@ -129,8 +130,15 @@ export default function DiffViewer({ return } const update = (): void => { - const top = - modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop() + const top = getDiffCommentPopoverTop( + modifiedEditor, + popover.lineNumber, + modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight) + ) + if (top == null) { + setPopover(null) + return + } setPopover((prev) => (prev ? { ...prev, top } : prev)) } const scrollSub = modifiedEditor.onDidScrollChange(update) From 7ffec887f7d4ccc6d81497d6b6d01620d2d8bb94 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 16:56:18 -0700 Subject: [PATCH 016/177] fix: pr-bug-scan validated finding from #1893 (#1966) Co-authored-by: orca-bug-scan-bot --- .../src/components/editor/rich-markdown-key-handler.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index c1bbd9851e1..19aafc228d6 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -232,7 +232,16 @@ export function createRichMarkdownKeyHandler( return false } + const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current + if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) { + // Why: when the current query has no matching commands the slash menu UI + // is already hidden; intercepting further keystrokes would silently + // swallow them. Fall through so ProseMirror inserts the character and + // syncSlashMenu refreshes the query from the document. + if (currentFilteredSlashCommands.length === 0) { + return false + } event.preventDefault() ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu)) ctx.setSelectedCommandIndex(0) @@ -246,7 +255,6 @@ export function createRichMarkdownKeyHandler( return true } - const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current if (currentFilteredSlashCommands.length === 0) { return false } From 08c2463b6f006b4862569931743700eee68b990f Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:20 -0700 Subject: [PATCH 017/177] fix: pr-bug-scan validated finding from #1878 (#1962) Co-authored-by: orca-bug-scan-bot --- src/cli/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 00a96e9043f..d80789c4772 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -47,11 +47,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + // Why: pass `null` (not `undefined`) when remote selection is suppressed + // so the RuntimeClient default parameter does not re-activate the + // ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands + // that must run locally (environment / serve). const client = new RuntimeClient( undefined, undefined, - typeof pairingCode === 'string' ? pairingCode : undefined, - typeof environmentSelector === 'string' ? environmentSelector : undefined + typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined, + typeof environmentSelector === 'string' + ? environmentSelector + : ignoreRemoteSelection + ? null + : undefined ) await dispatch(parsed.commandPath, { flags: parsed.flags, From 014cc1e4b323bc52ebb48cc4c2cc5138e936ad6e Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:01:11 -0700 Subject: [PATCH 018/177] fix: pr-bug-scan validated finding from #1917 (#1961) Co-authored-by: orca-bug-scan-bot --- src/main/linear/issues.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index afc59809973..cafff393e8f 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -100,6 +100,12 @@ export async function searchIssues( } }) ) + // Why: searchIssues returns Linear's relevance ranking. Re-sorting by + // updatedAt would discard relevance order for single-workspace results, + // diverging from Linear's web UI and pre-PR behavior. + if (entries.length === 1) { + return results.flat().slice(0, limit) + } return sortAndLimitIssues(results.flat(), limit) } From 8f39484dd20da9f6d39176372590da4cc579a685 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:01:51 -0700 Subject: [PATCH 019/177] fix: pr-bug-scan validated finding from #1946 (#1964) Co-authored-by: orca-bug-scan-bot --- .../src/components/right-sidebar/SourceControl.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 1e05fb0ab0b..f94822f20dd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -491,8 +491,16 @@ function SourceControlInner(): React.JSX.Element { const linkedGitHubPR = activeWorktree?.linkedPR ?? null const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null + // Why: when activeRepo.connectionId is truthy, neither the SourceControl + // effect below nor WorktreeCard.tsx fetches hostedReview for this branch, + // so hostedReviewEntry would stay undefined forever and would permanently + // block Publish Branch on SSH-backed worktrees with a linkedPR/linkedGitLabMR + // and no upstream. Skip the loading state for those repos so the publish + // gate doesn't latch. const isHostedReviewStateLoading = - (linkedGitHubPR !== null || linkedGitLabMR !== null) && hostedReviewEntry === undefined + !activeRepo?.connectionId && + (linkedGitHubPR !== null || linkedGitLabMR !== null) && + hostedReviewEntry === undefined useEffect(() => { if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') { return From be761951590b4f056902db9752f1f293dcb1096b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 17:07:55 -0700 Subject: [PATCH 020/177] Report automation usage costs (#1994) * feat: report automation usage costs * fix: satisfy max-lines lint --- src/main/automations/service.test.ts | 101 +++++++++++ src/main/automations/service.ts | 118 ++++++++++++- src/main/claude-usage/store.test.ts | 62 +++++++ src/main/claude-usage/store.ts | 126 ++++++++++++++ src/main/codex-usage/store.test.ts | 97 +++++++++++ src/main/codex-usage/store.ts | 157 ++++++++++++++++++ src/main/index.ts | 2 +- src/main/ipc/automations.ts | 3 +- src/main/persistence.ts | 2 + .../automations/AutomationDetail.tsx | 57 ++++++- .../automations/AutomationsPage.tsx | 17 ++ .../ExternalAutomationManagers.tsx | 1 + .../automation-usage-model.test.ts | 95 +++++++++++ .../automations/automation-usage-model.ts | 93 +++++++++++ .../src/components/editor/DiffSectionItem.tsx | 1 + src/shared/automations-types.ts | 32 ++++ 16 files changed, 952 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/automations/automation-usage-model.test.ts create mode 100644 src/renderer/src/components/automations/automation-usage-model.ts diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index ef016d486ca..acfc596339a 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -88,4 +88,105 @@ describe('AutomationService', () => { new Date('2026-05-14T09:00:00').getTime() ) }) + + it('attaches provider usage when a completed run can be attributed', async () => { + vi.setSystemTime(new Date('2026-05-13T10:00:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Costed check', + prompt: 'Check spend', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + const usage = { + status: 'known' as const, + provider: 'claude' as const, + model: 'claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 25, + cacheWriteTokens: 10, + reasoningOutputTokens: null, + totalTokens: 185, + estimatedCostUsd: 0.001, + estimatedCostSource: 'api_equivalent' as const, + providerSessionId: 'provider-session-1', + attribution: 'provider_session_time_window' as const, + collectedAt: Date.now(), + unavailableReason: null, + unavailableMessage: null + } + const getAutomationRunUsage = vi.fn().mockResolvedValue(usage) + const service = new AutomationService(store, { + tickMs: 60_000, + claudeUsage: { getAutomationRunUsage } as never + }) + + const updated = await service.markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + + expect(updated.usage).toEqual(usage) + expect(getAutomationRunUsage).toHaveBeenCalledWith({ + worktreeId: 'wt1', + terminalSessionId: 'tab-1', + startedAt: expect.any(Number), + completedAt: expect.any(Number) + }) + }) + + it('records unsupported usage cleanly for completed agents without local usage stores', async () => { + vi.setSystemTime(new Date('2026-05-13T10:00:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Gemini check', + prompt: 'Check spend', + agentId: 'gemini', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + const service = new AutomationService(store, { tickMs: 60_000 }) + + const updated = await service.markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + + expect(updated.usage?.status).toBe('unavailable') + expect(updated.usage?.unavailableReason).toBe('provider_unsupported') + }) }) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index ac3111f725b..888fa9d580d 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -4,8 +4,12 @@ import type { Automation, AutomationDispatchRequest, AutomationDispatchResult, - AutomationRun + AutomationRun, + AutomationRunStatus, + AutomationRunUsage } from '../../shared/automations-types' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' const DEFAULT_TICK_MS = 60 * 1000 @@ -16,10 +20,17 @@ export class AutomationService { private webContents: WebContents | null = null private rendererReady = false private evaluating = false + private readonly claudeUsage: ClaudeUsageStore | null + private readonly codexUsage: CodexUsageStore | null - constructor(store: Store, opts: { tickMs?: number } = {}) { + constructor( + store: Store, + opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {} + ) { this.store = store this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS + this.claudeUsage = opts.claudeUsage ?? null + this.codexUsage = opts.codexUsage ?? null } setWebContents(webContents: WebContents | null): void { @@ -62,8 +73,97 @@ export class AutomationService { return run } - markDispatchResult(result: AutomationDispatchResult): AutomationRun { - return this.store.updateAutomationRun(result) + async markDispatchResult(result: AutomationDispatchResult): Promise { + const run = this.store.updateAutomationRun(result) + if (!isFinalRunStatus(run.status)) { + return run + } + const usage = await this.collectRunUsage(run) + return this.store.updateAutomationRun({ + runId: run.id, + status: run.status, + workspaceId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + usage, + error: run.error + }) + } + + private async collectRunUsage(run: AutomationRun): Promise { + const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId) + const collectedAt = Date.now() + const unavailable = ( + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider, + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!automation || run.status !== 'completed') { + return unavailable( + automation?.agentId === 'codex' + ? 'codex' + : automation?.agentId === 'claude' + ? 'claude' + : null, + 'run_not_finished', + 'Usage is only collected for completed automation runs.' + ) + } + if (automation.executionTargetType === 'ssh') { + return unavailable( + automation.agentId === 'codex' + ? 'codex' + : automation.agentId === 'claude' + ? 'claude' + : null, + 'remote_usage_unavailable', + 'Remote automation usage is not available from local usage logs.' + ) + } + if (automation.agentId === 'claude') { + if (!this.claudeUsage) { + return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') + } + return this.claudeUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + if (automation.agentId === 'codex') { + if (!this.codexUsage) { + return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') + } + return this.codexUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + return unavailable( + null, + 'provider_unsupported', + 'This agent does not report usage to Orca yet.' + ) } private async evaluateDueRuns(): Promise { @@ -128,3 +228,13 @@ export class AutomationService { webContents.send('automations:dispatchRequested', payload) } } + +function isFinalRunStatus(status: AutomationRunStatus): boolean { + return ( + status === 'completed' || + status === 'dispatch_failed' || + status === 'skipped_missed' || + status === 'skipped_unavailable' || + status === 'skipped_needs_interactive_auth' + ) +} diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 2ae6a09990c..2baabbd6e98 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -257,4 +257,66 @@ describe('ClaudeUsageStore', () => { expect(summary.estimatedCostUsd).toBeCloseTo(8.07) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo-a' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T15:00:00.000Z', + lastTimestamp: '2026-04-09T15:05:00.000Z', + model: 'claude-sonnet-4-6', + lastCwd: '/workspace/repo-a', + lastGitBranch: 'feature/a', + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + turnCount: 1, + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCacheReadTokens: 200, + totalCacheWriteTokens: 100, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId, + turnCount: 1, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheWriteTokens: 100 + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyClaudeData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-09T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.totalTokens).toBe(1800) + expect(usage.estimatedCostUsd).toBeCloseTo(0.010935) + }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index dbc2add004f..cadb45e84b5 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -12,6 +12,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../../shared/claude-usage-types' +import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' @@ -19,6 +20,7 @@ import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. @@ -36,6 +38,13 @@ type ClaudeModelPricing = { cacheWriteAboveThreshold?: number } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 const SONNET_LONG_CONTEXT_PRICING = { thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, @@ -622,6 +631,123 @@ export class ClaudeUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Claude usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Claude usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.turns += entry.turnCount + acc.inputTokens += entry.inputTokens + acc.outputTokens += entry.outputTokens + acc.cacheReadTokens += entry.cacheReadTokens + acc.cacheWriteTokens += entry.cacheWriteTokens + return acc + }, + { + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + ) + const estimatedCostUsd = estimateCostUsd( + session.model, + totals.inputTokens, + totals.outputTokens, + totals.cacheReadTokens, + totals.cacheWriteTokens + ) + + return { + status: 'known', + provider: 'claude', + model: session.model, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens, + reasoningOutputTokens: null, + totalTokens: + totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens, + estimatedCostUsd, + estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent', + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Claude usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 00686de5971..5012b7db68e 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -661,4 +661,101 @@ describe('CodexUsageStore', () => { } }) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-10T15:00:00.000Z', + lastTimestamp: '2026-04-10T15:05:00.000Z', + primaryModel: 'gpt-5', + hasMixedModels: false, + primaryProjectLabel: 'Repo', + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + modelBreakdown: [ + { + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + locationModelBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyCodexData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-10T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.cacheReadTokens).toBe(400) + expect(usage.reasoningOutputTokens).toBe(100) + expect(usage.estimatedCostUsd).toBeCloseTo(0.0033) + }) }) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 8699e4f49d1..cfd1f2908fc 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -12,6 +12,7 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../../shared/codex-usage-types' +import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' @@ -19,6 +20,7 @@ import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -32,6 +34,13 @@ type CodexModelPricing = { outputTiers?: TieredPrice[] } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 const MODEL_PRICING: Record = { @@ -635,6 +644,154 @@ export class CodexUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'codex', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Codex usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Codex usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.events += entry.eventCount + acc.inputTokens += entry.inputTokens + acc.cachedInputTokens += entry.cachedInputTokens + acc.outputTokens += entry.outputTokens + acc.reasoningOutputTokens += entry.reasoningOutputTokens + acc.totalTokens += entry.totalTokens + return acc + }, + { + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + ) + const scopedModelRows = session.locationModelBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown + const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))] + let estimatedCostUsd = 0 + let hasKnownCost = false + if (scopedModelRows.length > 0) { + for (const modelRow of scopedModelRows) { + const cost = estimateCostUsd( + modelRow.modelKey, + modelRow.inputTokens, + modelRow.cachedInputTokens, + modelRow.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + } else if (!session.hasMixedModels) { + const cost = estimateCostUsd( + session.primaryModel, + totals.inputTokens, + totals.cachedInputTokens, + totals.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + + return { + status: 'known', + provider: 'codex', + model: + modelLabels.length === 1 + ? modelLabels[0] + : session.hasMixedModels + ? 'Mixed models' + : session.primaryModel, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cachedInputTokens, + cacheWriteTokens: null, + reasoningOutputTokens: totals.reasoningOutputTokens, + totalTokens: totals.totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, + estimatedCostSource: hasKnownCost ? 'api_equivalent' : null, + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Codex usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/index.ts b/src/main/index.ts index 65170134d19..8179af9a443 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -700,7 +700,7 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) - automations = new AutomationService(store) + automations = new AutomationService(store, { claudeUsage, codexUsage }) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({ stats, diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index f5c762d265c..4e1b6094f02 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -49,7 +49,8 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ ) ipcMain.handle( 'automations:markDispatchResult', - (_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result) + (_event, result: AutomationDispatchResult): Promise => + service.markDispatchResult(result) ) ipcMain.handle('automations:rendererReady', (): void => { service.setRendererReady() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 30a242d7a6a..e400700b6da 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1799,6 +1799,7 @@ export class Store { sessionKind: 'terminal', chatSessionId: null, terminalSessionId: null, + usage: null, error: null, startedAt: null, dispatchedAt: null, @@ -1821,6 +1822,7 @@ export class Store { status: result.status, workspaceId: result.workspaceId ?? current.workspaceId, terminalSessionId: result.terminalSessionId ?? current.terminalSessionId, + usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 562f7ea5634..fe9228c4434 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -13,6 +13,12 @@ import { getAutomationRunStatusLabel, getAutomationRunStatusVariant } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + getAutomationUsageStatusLabel, + summarizeAutomationRunUsage +} from './automation-usage-model' type AutomationDetailProps = { automation: Automation | null @@ -129,6 +135,13 @@ export function AutomationDetail({
) } + const usageSummary = summarizeAutomationRunUsage(runs) + const usageCoverage = + usageSummary.knownRuns > 0 + ? `${usageSummary.knownRuns}/${runs.length} runs` + : usageSummary.unavailableRuns > 0 + ? 'Unavailable' + : 'No runs' return (
@@ -175,8 +188,7 @@ export function AutomationDetail({
) : null} -
- +
+ + +
@@ -228,9 +246,11 @@ export function AutomationDetail({
Run history
{runs.length} runs
-
+
Run
Workspace
+
Spend
+
Tokens
Status
@@ -240,13 +260,16 @@ export function AutomationDetail({ ? (runWorktree?.displayName ?? 'Missing workspace') : 'Not launched' const rowClassName = - 'grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors' + 'grid grid-cols-[minmax(9rem,1fr)_minmax(11rem,1.2fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors' + const usageLabel = getAutomationUsageStatusLabel(run.usage) const rowContent = ( <>
{formatAutomationDateTime(run.scheduledFor)}
- {run.error ? ( -
{run.error}
+ {run.error || run.usage?.status === 'unavailable' ? ( +
+ {run.error ?? run.usage?.unavailableMessage} +
) : null}
{workspaceLabel}
+
+ {formatAutomationCost(run.usage?.estimatedCostUsd)} +
+
+ {run.usage?.status === 'known' + ? formatAutomationTokens(run.usage.totalTokens) + : 'n/a'} +
{getAutomationRunStatusLabel(run.status)} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 0027ee7d9eb..3d9cbc5371f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -37,6 +37,11 @@ import type { import type { Worktree } from '../../../../shared/types' import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/automation-schedules' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' import { AutomationDetail } from './AutomationDetail' import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog' import { ExternalAutomationManagers } from './ExternalAutomationManagers' @@ -739,6 +744,17 @@ export default function AutomationsPage(): React.JSX.Element { automation.workspaceMode === 'new_per_run' ? 'New workspace each run' : (automationWorktree?.displayName ?? 'Missing workspace') + const usageSummary = summarizeAutomationRunUsage( + runs.filter((run) => run.automationId === automation.id) + ) + const usageText = + usageSummary.knownRuns > 0 + ? `${formatAutomationCost( + usageSummary.estimatedCostUsd + )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` + : usageSummary.unavailableRuns > 0 + ? 'Usage unavailable' + : 'No run usage yet' return ( @@ -774,6 +790,7 @@ export default function AutomationsPage(): React.JSX.Element { )}` : 'Paused'} + {usageText} diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index 865422a30cb..81d23b91c7d 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -121,6 +121,7 @@ export function ExternalAutomationManagers({ {job.enabled ? 'Active' : 'Paused'} + Usage unsupported
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)} diff --git a/src/renderer/src/components/automations/automation-usage-model.test.ts b/src/renderer/src/components/automations/automation-usage-model.test.ts new file mode 100644 index 00000000000..1e5facd27cc --- /dev/null +++ b/src/renderer/src/components/automations/automation-usage-model.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' + +function makeRun(overrides: Partial): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'completed', + trigger: 'manual', + workspaceId: 'wt1', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-1', + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1, + ...overrides + } +} + +describe('automation usage model', () => { + it('rolls known run usage up while preserving unavailable coverage', () => { + const summary = summarizeAutomationRunUsage([ + makeRun({ + usage: { + status: 'known', + provider: 'codex', + model: 'gpt-5.4', + inputTokens: 1000, + outputTokens: 300, + cacheReadTokens: 400, + cacheWriteTokens: null, + reasoningOutputTokens: 50, + totalTokens: 1350, + estimatedCostUsd: 0.0042, + estimatedCostSource: 'api_equivalent', + providerSessionId: 'session-1', + attribution: 'provider_session_time_window', + collectedAt: 1, + unavailableReason: null, + unavailableMessage: null + } + }), + makeRun({ + id: 'run-2', + usage: { + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt: 2, + unavailableReason: 'no_matching_session', + unavailableMessage: 'No matching session' + } + }) + ]) + + expect(summary).toEqual({ + knownRuns: 1, + unavailableRuns: 1, + inputTokens: 1000, + outputTokens: 300, + cacheTokens: 400, + reasoningOutputTokens: 50, + totalTokens: 1350, + estimatedCostUsd: 0.0042 + }) + }) + + it('formats compact token and cost labels', () => { + expect(formatAutomationTokens(1250)).toBe('1.3k') + expect(formatAutomationTokens(1_250_000)).toBe('1.3M') + expect(formatAutomationCost(null)).toBe('n/a') + expect(formatAutomationCost(0.0042)).toBe('$0.0042') + expect(formatAutomationCost(1.234)).toBe('$1.23') + }) +}) diff --git a/src/renderer/src/components/automations/automation-usage-model.ts b/src/renderer/src/components/automations/automation-usage-model.ts new file mode 100644 index 00000000000..fcc43e0703e --- /dev/null +++ b/src/renderer/src/components/automations/automation-usage-model.ts @@ -0,0 +1,93 @@ +import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations-types' + +export type AutomationUsageSummary = { + knownRuns: number + unavailableRuns: number + inputTokens: number + outputTokens: number + cacheTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export function summarizeAutomationRunUsage( + runs: readonly AutomationRun[] +): AutomationUsageSummary { + let knownRuns = 0 + let unavailableRuns = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let estimatedCostUsd = 0 + let hasKnownCost = false + + for (const run of runs) { + const usage = run.usage + if (!usage) { + unavailableRuns++ + continue + } + if (usage.status !== 'known') { + unavailableRuns++ + continue + } + knownRuns++ + inputTokens += usage.inputTokens ?? 0 + outputTokens += usage.outputTokens ?? 0 + cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 + totalTokens += usage.totalTokens ?? 0 + if (usage.estimatedCostUsd !== null) { + estimatedCostUsd += usage.estimatedCostUsd + hasKnownCost = true + } + } + + return { + knownRuns, + unavailableRuns, + inputTokens, + outputTokens, + cacheTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null + } +} + +export function formatAutomationTokens(value: number | null | undefined): string { + if (!value) { + return '0' + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k` + } + return value.toLocaleString() +} + +export function formatAutomationCost(value: number | null | undefined): string { + if (value === null || value === undefined) { + return 'n/a' + } + if (value > 0 && value < 0.01) { + return `$${value.toFixed(4)}` + } + return `$${value.toFixed(2)}` +} + +export function getAutomationUsageStatusLabel( + usage: AutomationRunUsage | null | undefined +): string { + if (!usage || usage.status === 'unavailable') { + return usage?.unavailableMessage ?? 'Usage unavailable' + } + const cost = formatAutomationCost(usage.estimatedCostUsd) + const tokens = formatAutomationTokens(usage.totalTokens) + return `${tokens} tokens · ${cost}` +} diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 751d4c9e548..f7cb804a819 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this component owns diff rendering, image previews, comment popovers, and expansion state as one synchronized editor row. */ import { lazy, useCallback, diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index fcbc5d32dba..a9e4883ecbb 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -16,6 +16,36 @@ export type AutomationRunStatus = export type AutomationRunTrigger = 'scheduled' | 'manual' export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly' +export type AutomationRunUsageProvider = 'claude' | 'codex' +export type AutomationRunUsageStatus = 'known' | 'unavailable' +export type AutomationRunUsageAttribution = 'provider_session_time_window' +export type AutomationRunUsageUnavailableReason = + | 'run_not_finished' + | 'provider_unsupported' + | 'remote_usage_unavailable' + | 'usage_not_enabled' + | 'scan_failed' + | 'no_matching_session' + | 'ambiguous_session' + +export type AutomationRunUsage = { + status: AutomationRunUsageStatus + provider: AutomationRunUsageProvider | null + model: string | null + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + reasoningOutputTokens: number | null + totalTokens: number | null + estimatedCostUsd: number | null + estimatedCostSource: 'api_equivalent' | null + providerSessionId: string | null + attribution: AutomationRunUsageAttribution | null + collectedAt: number + unavailableReason: AutomationRunUsageUnavailableReason | null + unavailableMessage: string | null +} export type Automation = { id: string @@ -52,6 +82,7 @@ export type AutomationRun = { sessionKind: 'terminal' chatSessionId: string | null terminalSessionId: string | null + usage: AutomationRunUsage | null error: string | null startedAt: number | null dispatchedAt: number | null @@ -101,6 +132,7 @@ export type AutomationDispatchResult = { status: AutomationRunStatus workspaceId?: string | null terminalSessionId?: string | null + usage?: AutomationRunUsage | null error?: string | null } From 82f99ee6ef14e81475a0327f95e7735fa9250698 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 17:14:02 -0700 Subject: [PATCH 021/177] Guide workspace creation for empty repositories (#1926) * Guide empty repository workspace creation * fix: address CI failures --- .../components/NewWorkspaceComposerCard.tsx | 18 ++++++-- .../components/editor/CombinedDiffViewer.tsx | 17 +------- .../src/components/editor/DiffSectionItem.tsx | 17 +------- .../components/editor/diff-section-types.ts | 15 +++++++ src/renderer/src/hooks/useComposerState.ts | 21 ++++++---- .../lib/workspace-create-error-format.test.ts | 41 +++++++++++++++++++ .../src/lib/workspace-create-error-format.ts | 28 +++++++++++++ 7 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/editor/diff-section-types.ts create mode 100644 src/renderer/src/lib/workspace-create-error-format.test.ts create mode 100644 src/renderer/src/lib/workspace-create-error-format.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6c8ba28f167..05b6fd7a591 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -28,6 +28,7 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -65,7 +66,7 @@ type NewWorkspaceComposerCardProps = { onSetupDecisionChange: (value: 'run' | 'skip') => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null - createError: string | null + createError: WorkspaceCreateErrorDisplay | null canUseSparseCheckout: boolean sparsePresets: SparsePreset[] sparseSelectedPresetId: string | null @@ -549,8 +550,19 @@ export default function NewWorkspaceComposerCard({
{createError ? ( -
- {createError} +
+ {createError.help ? ( +
+

{createError.title}

+

{createError.message}

+

{createError.help}

+
+ ) : ( + createError.message + )}
) : null} diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index b444d713d54..ac5a3587d30 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -16,23 +16,10 @@ import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-gi import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import type { OpenFile } from '@/store/slices/editor' -import type { GitBranchChangeEntry, GitDiffResult, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/types' import { DiffSectionItem } from './DiffSectionItem' import { getCombinedUncommittedEntries } from './combined-diff-entries' - -type DiffSection = { - key: string - path: string - status: string - area?: GitStatusEntry['area'] - oldPath?: string - originalContent: string - modifiedContent: string - collapsed: boolean - loading: boolean - dirty: boolean - diffResult: GitDiffResult | null -} +import type { DiffSection } from './diff-section-types' type CachedCombinedDiffViewState = { entrySignature: string diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index f7cb804a819..e2b12eb65c6 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -23,25 +23,12 @@ import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { computeLineStats } from './diff-line-stats' -import type { DiffComment, GitDiffResult } from '../../../../shared/types' +import type { DiffSection } from './diff-section-types' +import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) -type DiffSection = { - key: string - path: string - status: string - area?: 'staged' | 'unstaged' | 'untracked' - oldPath?: string - originalContent: string - modifiedContent: string - collapsed: boolean - loading: boolean - dirty: boolean - diffResult: GitDiffResult | null -} - export function DiffSectionItem({ section, index, diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts new file mode 100644 index 00000000000..d74266c0b27 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -0,0 +1,15 @@ +import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' + +export type DiffSection = { + key: string + path: string + status: string + area?: GitStatusEntry['area'] + oldPath?: string + originalContent: string + modifiedContent: string + collapsed: boolean + loading: boolean + dirty: boolean + diffResult: GitDiffResult | null +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3ff303c7bb5..a401775b61b 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -61,6 +61,11 @@ import { readRuntimeIssueCommand, type HookCheckResult } from '@/runtime/runtime-hooks-client' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage, + type WorkspaceCreateErrorDisplay +} from '@/lib/workspace-create-error-format' export type UseComposerStateOptions = { initialRepoId?: string @@ -167,7 +172,7 @@ export type ComposerCardProps = { onSetupDecisionChange: (value: 'run' | 'skip') => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null - createError: string | null + createError: WorkspaceCreateErrorDisplay | null canUseSparseCheckout: boolean /** Saved presets for the currently-selected repo. Empty array when no * presets exist or when the repo is remote. */ @@ -375,7 +380,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState(null) + const [createError, setCreateError] = useState(null) const [advancedOpen, setAdvancedOpen] = useState( persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false ) @@ -1664,9 +1669,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } onCreated?.() } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setCreateError(message) - toast.error(message) + const formattedError = formatWorkspaceCreateError(error) + setCreateError(formattedError) + toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) } finally { setCreating(false) } @@ -1896,9 +1901,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } onCreated?.() } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setCreateError(message) - toast.error(message) + const formattedError = formatWorkspaceCreateError(error) + setCreateError(formattedError) + toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) } finally { setCreating(false) } diff --git a/src/renderer/src/lib/workspace-create-error-format.test.ts b/src/renderer/src/lib/workspace-create-error-format.test.ts new file mode 100644 index 00000000000..4f8fc7a591a --- /dev/null +++ b/src/renderer/src/lib/workspace-create-error-format.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage +} from './workspace-create-error-format' + +describe('formatWorkspaceCreateError', () => { + it('returns guidance for missing default base ref failures', () => { + const error = new Error( + 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' + ) + + const formatted = formatWorkspaceCreateError(error) + + expect(formatted).toEqual({ + title: 'No base branch found', + message: 'Orca could not resolve a usable base ref for this workspace.', + help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.' + }) + expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('No base branch found') + }) + + it('matches missing base ref failures case-insensitively', () => { + const formatted = formatWorkspaceCreateError( + new Error('COULD NOT RESOLVE A DEFAULT BASE REF from remote provider') + ) + + expect(formatted.title).toBe('No base branch found') + expect(formatted.help).toBeDefined() + }) + + it('passes unknown errors through unchanged', () => { + const formatted = formatWorkspaceCreateError(new Error('fatal: not a git repository')) + + expect(formatted).toEqual({ + title: 'fatal: not a git repository', + message: 'fatal: not a git repository' + }) + expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('fatal: not a git repository') + }) +}) diff --git a/src/renderer/src/lib/workspace-create-error-format.ts b/src/renderer/src/lib/workspace-create-error-format.ts new file mode 100644 index 00000000000..98c427d4510 --- /dev/null +++ b/src/renderer/src/lib/workspace-create-error-format.ts @@ -0,0 +1,28 @@ +export type WorkspaceCreateErrorDisplay = { + title: string + message: string + help?: string +} + +const MISSING_BASE_REF_ANCHOR = 'could not resolve a default base ref' + +export function formatWorkspaceCreateError(error: unknown): WorkspaceCreateErrorDisplay { + const message = error instanceof Error ? error.message : 'Failed to create worktree.' + + if (message.toLowerCase().includes(MISSING_BASE_REF_ANCHOR)) { + return { + title: 'No base branch found', + message: 'Orca could not resolve a usable base ref for this workspace.', + help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.' + } + } + + return { + title: message, + message + } +} + +export function getWorkspaceCreateErrorToastMessage(error: WorkspaceCreateErrorDisplay): string { + return error.help ? error.title : error.message +} From bf85d51bc1739f533906cce31e25ce3d0fde1c54 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 15 May 2026 17:21:00 -0700 Subject: [PATCH 022/177] Add workspace lineage grouping (#1885) Co-authored-by: Orca --- src/cli/format.test.ts | 91 ++ src/cli/format.ts | 20 +- src/cli/handlers/orchestration.ts | 8 +- src/cli/handlers/worktree.ts | 106 +- src/cli/help.ts | 9 +- src/cli/index.test.ts | 435 ++++++- src/cli/specs/core.ts | 18 +- src/cli/test-fixtures.ts | 6 + src/main/ipc/worktree-logic.ts | 1 + src/main/ipc/worktree-remote.ts | 9 + src/main/ipc/worktrees.test.ts | 144 ++- src/main/ipc/worktrees.ts | 66 ++ src/main/persistence.test.ts | 76 +- src/main/persistence.ts | 31 + src/main/runtime/orca-runtime.test.ts | 1040 ++++++++++++++++- src/main/runtime/orca-runtime.ts | 574 ++++++++- src/main/runtime/orchestration/db.test.ts | 11 + src/main/runtime/orchestration/db.ts | 34 +- src/main/runtime/orchestration/types.ts | 1 + src/main/runtime/rpc/errors.test.ts | 32 + src/main/runtime/rpc/errors.ts | 14 + .../runtime/rpc/methods/orchestration.test.ts | 10 + src/main/runtime/rpc/methods/orchestration.ts | 6 +- src/main/runtime/rpc/methods/worktree.test.ts | 78 +- src/main/runtime/rpc/methods/worktree.ts | 139 ++- src/preload/api-types.ts | 7 + src/preload/index.ts | 9 + src/renderer/src/App.tsx | 5 + .../src/components/sidebar/SidebarHeader.tsx | 11 + .../src/components/sidebar/WorktreeCard.tsx | 78 +- .../sidebar/WorktreeContextMenu.test.ts | 61 + .../sidebar/WorktreeContextMenu.tsx | 165 ++- .../src/components/sidebar/WorktreeList.tsx | 396 ++++++- .../sidebar/worktree-list-groups.test.ts | 242 +++- .../sidebar/worktree-list-groups.ts | 229 +++- src/renderer/src/hooks/useIpcEvents.ts | 5 +- .../src/lib/pane-manager/pane-manager.ts | 7 +- src/renderer/src/lib/startup-ui-hydration.ts | 1 + .../src/store/slices/settings.test.ts | 28 +- src/renderer/src/store/slices/settings.ts | 2 + src/renderer/src/store/slices/ui.ts | 8 +- .../src/store/slices/worktree-helpers.ts | 7 + .../src/store/slices/worktrees.test.ts | 333 +++++- src/renderer/src/store/slices/worktrees.ts | 159 ++- src/renderer/src/web/web-preload-api.ts | 20 +- src/shared/constants.ts | 2 + src/shared/runtime-types.ts | 18 +- src/shared/types.ts | 53 +- tests/e2e/worktree-lineage.spec.ts | 127 ++ 49 files changed, 4725 insertions(+), 207 deletions(-) create mode 100644 src/cli/format.test.ts create mode 100644 src/main/runtime/rpc/errors.test.ts create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts create mode 100644 tests/e2e/worktree-lineage.spec.ts diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts new file mode 100644 index 00000000000..6bed14237ac --- /dev/null +++ b/src/cli/format.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeRpcFailureError } from './runtime-client' +import { formatCliError, formatWorktreeList } from './format' +import type { RuntimeWorktreeRecord } from '../shared/runtime-types' + +function worktree(overrides: Partial = {}): RuntimeWorktreeRecord { + const base: RuntimeWorktreeRecord = { + id: 'repo::/tmp/repo/child', + repoId: 'repo', + path: '/tmp/repo/child', + head: 'abc123', + branch: 'feature/child', + isBare: false, + isMainWorktree: false, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + git: { + path: '/tmp/repo/child', + head: 'abc123', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + } + return { ...base, ...overrides } +} + +describe('formatCliError', () => { + it('prints runtime next steps for structured lineage errors', () => { + const error = new RuntimeRpcFailureError({ + id: 'req_1', + ok: false, + error: { + code: 'LINEAGE_PARENT_NOT_FOUND', + message: 'Parent workspace was not found.', + data: { + nextSteps: [ + 'Run `orca worktree list` and pass a valid --parent-worktree selector.', + 'Retry with --no-parent to create without lineage.', + 123 + ] + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + + expect(formatCliError(error)).toBe( + [ + 'Parent workspace was not found.', + 'Next step: Run `orca worktree list` and pass a valid --parent-worktree selector.', + 'Next step: Retry with --no-parent to create without lineage.' + ].join('\n') + ) + }) +}) + +describe('formatWorktreeList', () => { + it('includes parent and child workspace relationships in text output', () => { + const output = formatWorktreeList({ + worktrees: [ + worktree({ + id: 'repo::/tmp/repo/parent', + path: '/tmp/repo/parent', + branch: 'feature/parent', + childWorktreeIds: ['repo::/tmp/repo/child'] + }), + worktree({ + parentWorktreeId: 'repo::/tmp/repo/parent' + }) + ], + totalCount: 2, + truncated: false + }) + + expect(output).toContain('parentWorktreeId: null') + expect(output).toContain('childWorktreeIds: repo::/tmp/repo/child') + expect(output).toContain('parentWorktreeId: repo::/tmp/repo/parent') + expect(output).toContain('childWorktreeIds: []') + }) +}) diff --git a/src/cli/format.ts b/src/cli/format.ts index f032bb1803f..9518dfde602 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -60,6 +60,18 @@ export function formatCliError(error: unknown): string { ) { return `${message}\nOrca is not running. Run 'orca open' first.` } + if (error instanceof RuntimeRpcFailureError) { + const data = error.response.error.data + const nextSteps = + data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps) + ? (data as { nextSteps: unknown[] }).nextSteps.filter( + (step): step is string => typeof step === 'string' + ) + : [] + if (nextSteps.length > 0) { + return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` + } + } return message } @@ -250,10 +262,10 @@ export function formatWorktreeList(result: RuntimeWorktreeListResult): string { return 'No worktrees found.' } const body = result.worktrees - .map( - (worktree) => - `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` - ) + .map((worktree) => { + const childCount = worktree.childWorktreeIds?.length ?? 0 + return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` + }) .join('\n\n') return result.truncated ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 4520d33888a..5398c55aadf 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -207,12 +207,18 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration task-create': async ({ flags, client, json }) => { + const callerTerminalHandle = + typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && + process.env.ORCA_TERMINAL_HANDLE.length > 0 + ? process.env.ORCA_TERMINAL_HANDLE + : undefined const result = await client.call<{ task: { id: string; status: string } }>( 'orchestration.taskCreate', { spec: getRequiredStringFlag(flags, 'spec'), deps: getOptionalStringFlag(flags, 'deps'), - parent: getOptionalStringFlag(flags, 'parent') + parent: getOptionalStringFlag(flags, 'parent'), + callerTerminalHandle } ) printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 5dfe9873d60..b4065387b66 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -1,10 +1,12 @@ import type { RuntimeWorktreeListResult, RuntimeWorktreePsResult, - RuntimeWorktreeRecord + RuntimeWorktreeRecord, + RuntimeWorktreeCreateResult } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' import { getOptionalNullableNumberFlag, getOptionalNumberFlag, @@ -12,7 +14,11 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' -import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' +import { + getOptionalWorktreeSelector, + getRequiredWorktreeSelector, + resolveCurrentWorktreeSelector +} from '../selectors' type HookWarningResult = { warning?: string @@ -24,6 +30,48 @@ function printHookWarning(result: HookWarningResult, json: boolean): void { } } +function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void { + if (json) { + return + } + for (const warning of result.warnings ?? []) { + console.error(`warning: ${warning.message}`) + } + if (result.lineage) { + const source = + result.lineage.capture.source === 'terminal-context' + ? 'terminal' + : result.lineage.capture.source === 'cwd-context' + ? 'cwd' + : result.lineage.capture.source === 'orchestration-context' + ? 'orchestration' + : result.lineage.capture.source === 'explicit-cli-flag' + ? 'explicit flag' + : 'manual action' + console.error( + `parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})` + ) + } else { + console.error('parent: none') + } +} + +function assertParentFlagsCompatible(flags: Map): void { + if (flags.has('parent-worktree') && flags.get('no-parent') === true) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose either --parent-worktree or --no-parent, not both.' + ) + } + const parentWorktree = flags.get('parent-worktree') + if ( + flags.has('parent-worktree') && + (typeof parentWorktree !== 'string' || parentWorktree === '') + ) { + throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree') + } +} + export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { const result = await client.call('worktree.ps', { @@ -50,28 +98,56 @@ export const WORKTREE_HANDLERS: Record = { }) printResult(result, json, formatWorktreeShow) }, - 'worktree create': async ({ flags, client, json }) => { - const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>( - 'worktree.create', - { - repo: getRequiredStringFlag(flags, 'repo'), - name: getRequiredStringFlag(flags, 'name'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - linkedIssue: getOptionalNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true, - activate: flags.get('activate') === true || flags.get('run-hooks') === true - } + 'worktree create': async ({ flags, client, cwd, json }) => { + assertParentFlagsCompatible(flags) + const callerTerminalHandle = + typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && + process.env.ORCA_TERMINAL_HANDLE.length > 0 + ? process.env.ORCA_TERMINAL_HANDLE + : undefined + const explicitParentWorktree = await getOptionalWorktreeSelector( + flags, + 'parent-worktree', + cwd, + client ) + const noParent = flags.get('no-parent') === true + let cwdParentWorktree: string | undefined + if (!explicitParentWorktree && !noParent) { + try { + // Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running + // inside an Orca worktree. Cwd keeps CLI-created children nestable. + cwdParentWorktree = await resolveCurrentWorktreeSelector(cwd, client) + } catch { + cwdParentWorktree = undefined + } + } + const result = await client.call('worktree.create', { + repo: getRequiredStringFlag(flags, 'repo'), + name: getRequiredStringFlag(flags, 'name'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + linkedIssue: getOptionalNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true, + activate: flags.get('activate') === true || flags.get('run-hooks') === true, + parentWorktree: explicitParentWorktree, + ...(cwdParentWorktree ? { cwdParentWorktree } : {}), + noParent, + callerTerminalHandle + }) printHookWarning(result.result, json) + printLineageSummary(result.result, json) printResult(result, json, formatWorktreeShow) }, 'worktree set': async ({ flags, client, cwd, json }) => { + assertParentFlagsCompatible(flags) const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), displayName: getOptionalStringFlag(flags, 'display-name'), linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment') + comment: getOptionalStringFlag(flags, 'comment'), + parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client), + noParent: flags.get('no-parent') === true }) printResult(result, json, formatWorktreeShow) }, diff --git a/src/cli/help.ts b/src/cli/help.ts index b129306953f..9c54fed812b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -152,10 +152,10 @@ Common Commands: orca environment show --environment [--json] orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json] orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] @@ -178,6 +178,8 @@ Selectors: --repo Registered repo selector such as id:, name:, or path: --worktree Worktree selector such as id:, branch:, issue:, path:, or active/current --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` + --parent-worktree Parent worktree selector; create infers one from the Orca terminal or current directory by default + --no-parent Force worktree creation/update to have no parent lineage Terminal Send Options: --text Text to send to the terminal @@ -344,8 +346,11 @@ export function formatFlagHelp(flag: string): string { limit: '--limit Maximum number of rows to return', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', name: '--name Name for the new worktree', + 'no-parent': '--no-parent Force no parent lineage', 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', + 'parent-worktree': + '--parent-worktree Parent worktree selector; create infers one by default', path: '--path Filesystem path to the repo', query: '--query Search text for matching refs', ref: '--ref Base ref to persist for the repo', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6758745bd0d..b5d8b372e07 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -107,6 +107,7 @@ describe('orca cli worktree awareness', () => { beforeEach(() => { callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE serveOrcaAppMock.mockReset() getDefaultUserDataPathMock.mockClear() addEnvironmentFromPairingCodeMock.mockReset() @@ -246,13 +247,175 @@ describe('orca cli worktree awareness', () => { worktree: `path:${path.resolve('/tmp/repo/feature')}`, displayName: undefined, linkedIssue: undefined, - comment: 'hello' + comment: 'hello', + parentWorktree: undefined, + noParent: false + }) + }) + + it('passes parent lineage through worktree.set', async () => { + queueFixtures( + callMock, + okFixture('req_set_parent', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + parentWorktreeId: 'repo::/tmp/repo/parent', + childWorktreeIds: [], + lineage: { + worktreeId: 'repo::/tmp/repo/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo::/tmp/repo/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--parent-worktree', + 'id:repo::/tmp/repo/parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + comment: undefined, + parentWorktree: 'id:repo::/tmp/repo/parent', + noParent: false + }) + }) + + it('resolves current for explicit parent-worktree on set', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent')]), + okFixture('req_set_parent', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + parentWorktreeId: 'repo::/tmp/repo/parent', + childWorktreeIds: [], + lineage: null + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--parent-worktree', + 'current', + '--json' + ], + '/tmp/repo/parent/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + comment: undefined, + parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`, + noParent: false + }) + }) + + it('rejects contradictory parent flags on worktree.set before resolving selectors', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--parent-worktree', + 'current', + '--no-parent', + '--json' + ], + '/tmp/not-managed' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Choose either --parent-worktree or --no-parent, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects bare parent-worktree on worktree.set', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--parent-worktree', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Missing required --parent-worktree' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('passes parent removal through worktree.set', async () => { + queueFixtures( + callMock, + okFixture('req_clear_parent', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--no-parent', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + comment: undefined, + parentWorktree: undefined, + noParent: true }) }) it('passes explicit activation through worktree.create', async () => { queueFixtures( callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), okFixture('req_create', { worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') }) @@ -264,14 +427,247 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('worktree.create', { + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', baseBranch: undefined, linkedIssue: undefined, comment: undefined, runHooks: false, - activate: true + activate: true, + parentWorktree: undefined, + cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + noParent: false, + callerTerminalHandle: undefined + }) + }) + + it('passes an explicit parent through worktree.create without cwd inference', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'), + parentWorktreeId: 'repo-1::/tmp/repo/parent', + lineage: { + worktreeId: 'repo-1::/tmp/repo/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/tmp/repo/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + }, + lineage: { + worktreeId: 'repo-1::/tmp/repo/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/tmp/repo/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + }, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'child', + '--parent-worktree', + 'id:repo-1::/tmp/repo/parent', + '--json' + ], + '/tmp/repo/parent/src' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'child', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: 'id:repo-1::/tmp/repo/parent', + noParent: false, + callerTerminalHandle: undefined + }) + }) + + it('resolves current for explicit parent-worktree on create', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent', 'abc', 'repo-1')]), + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'child', + '--parent-worktree', + 'current', + '--json' + ], + '/tmp/repo/parent/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { + repo: 'id:repo-1', + name: 'child', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`, + noParent: false, + callerTerminalHandle: undefined + }) + }) + + it('rejects contradictory parent flags on worktree.create before resolving selectors', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'child', + '--parent-worktree', + 'current', + '--no-parent', + '--json' + ], + '/tmp/not-managed' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Choose either --parent-worktree or --no-parent, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects bare parent-worktree on worktree.create', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'child', + '--parent-worktree', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Missing required --parent-worktree' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('passes no-parent through worktree.create and skips cwd inference', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'], + '/tmp/repo/parent/src' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'child', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + noParent: true, + callerTerminalHandle: undefined + }) + }) + + it('passes caller terminal handle through worktree.create with cwd fallback', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_parent' + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledTimes(2) + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { + repo: 'id:repo-1', + name: 'child', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + noParent: false, + callerTerminalHandle: 'term_parent' }) }) @@ -464,6 +860,7 @@ describe('orca cli worktree awareness', () => { it('opts into setup and activation when worktree.create runs hooks', async () => { queueFixtures( callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), okFixture('req_create', { worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') }) @@ -475,14 +872,18 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('worktree.create', { + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', baseBranch: undefined, linkedIssue: undefined, comment: undefined, runHooks: true, - activate: true + activate: true, + parentWorktree: undefined, + cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`, + noParent: false, + callerTerminalHandle: undefined }) }) @@ -598,6 +999,30 @@ describe('orca cli worktree awareness', () => { errSpy.mockRestore() }) + it('passes the caller terminal handle through orchestration task-create', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_creator' + callMock.mockResolvedValueOnce({ + id: 'req_task_create', + ok: true, + result: { + task: { id: 'task_1', status: 'ready' } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'task-create', '--spec', 'spawn child workspace'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', { + spec: 'spawn child workspace', + deps: undefined, + parent: undefined, + callerTerminalHandle: 'term_creator' + }) + }) + it('passes dev mode to injected orchestration dispatches', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_sender' process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev' diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index afbb9dcd9ae..bcfd3695c1c 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -91,7 +91,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json]', + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -99,10 +99,14 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'base-branch', 'issue', 'comment', + 'parent-worktree', + 'no-parent', 'run-hooks', 'activate' ], notes: [ + 'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.', + 'Pass --parent-worktree to choose a parent explicitly, or --no-parent to force no lineage.', 'By default this creates the worktree and its first terminal without switching the active Orca workspace.', 'Repo-defined setup hooks follow the repository setup policy; pass --run-hooks to force them.', 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', @@ -113,8 +117,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'set'], summary: 'Update Orca metadata for a worktree', usage: - 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment'] + 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'worktree', + 'display-name', + 'issue', + 'comment', + 'parent-worktree', + 'no-parent' + ] }, { path: ['worktree', 'rm'], diff --git a/src/cli/test-fixtures.ts b/src/cli/test-fixtures.ts index 637041bbb6d..a8e7d8f91f2 100644 --- a/src/cli/test-fixtures.ts +++ b/src/cli/test-fixtures.ts @@ -12,6 +12,9 @@ type WorktreeFixture = { repoId: string path: string branch: string + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: null linkedIssue: null git: { path: string @@ -35,6 +38,9 @@ export function buildWorktree( repoId, path, branch, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, linkedIssue: null, git: { path, head, branch, isBare: false, isMainWorktree: false }, displayName: '', diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 266718fb96f..570c74e21e4 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -176,6 +176,7 @@ export function mergeWorktree( const branchShort = git.branch.replace(/^refs\/heads\//, '') return { id: `${repoId}::${git.path}`, + ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, path: git.path, head: git.head, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 27883eec850..90ebf6fe534 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -10,6 +10,7 @@ import type { BrowserWindow } from 'electron' import { join } from 'path' import { existsSync } from 'fs' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { CreateWorktreeArgs, @@ -430,6 +431,10 @@ export async function createRemoteWorktree( ) } const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), lastActivityAt: now, // Why: grants the new worktree a short grace window at the top of the // Recent sort. During worktree creation (git fetch + add can take several @@ -716,6 +721,10 @@ export async function createLocalWorktree( const worktreeId = `${repo.id}::${created.path}` const now = Date.now() const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 71d3b459c71..3a004a009e2 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -155,7 +155,9 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn() + removeWorktreeMeta: vi.fn(), + getAllWorktreeLineage: vi.fn(), + removeWorktreeLineage: vi.fn() } let runtimeStub: { resolveRemoteTrackingBase: ReturnType @@ -205,6 +207,8 @@ describe('registerWorktreeHandlers', () => { store.getAllWorktreeMeta, store.setWorktreeMeta, store.removeWorktreeMeta, + store.getAllWorktreeLineage, + store.removeWorktreeLineage, killAllProcessesForWorktreeMock, getLocalPtyProviderMock ]) { @@ -244,6 +248,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) + store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') @@ -628,6 +633,104 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live', + head: 'abc123', + branch: 'refs/heads/live', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/missing-child': { + parentWorktreeId: 'repo-ssh::/remote/live' + }, + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + }, + 'repo-ssh::/remote/live': { + parentWorktreeId: 'other-repo::/elsewhere' + } + }) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === 'repo-ssh::/remote/missing-parent' + ? { instanceId: 'old-parent-instance' } + : undefined + ) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.removeWorktreeLineage).toHaveBeenCalledWith('repo-ssh::/remote/missing-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live') + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + + it('does not repeatedly rotate already-invalid missing parent metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + } + }) + store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' }) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + it('does not await a cold fetch when the remote-tracking base exists locally', async () => { const remoteBase = { remote: 'origin', @@ -1098,6 +1201,7 @@ describe('registerWorktreeHandlers', () => { comment: '', linkedIssue: null, linkedPR: null, + instanceId: 'existing-instance', isArchived: false, isUnread: false, isPinned: false, @@ -1114,6 +1218,44 @@ describe('registerWorktreeHandlers', () => { expect(listed[0].lastActivityAt).toBe(42) }) + it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'new-instance', + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + instanceId?: string + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/existing-wt', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + expect(listed[0].instanceId).toBe('new-instance') + }) + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { // Why: folder repos produce a synthetic worktree that flows through the // same list path. Without the stamp, adding a folder puts its card at the diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a22ad1d258a..5f6c9b2b1f8 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { rm } from 'fs/promises' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { deleteWorktreeHistoryDir } from '../terminal-history' @@ -66,6 +67,12 @@ import { function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) if (existing) { + if (!existing.instanceId) { + // Why: profiles created before lineage shipped already have WorktreeMeta + // rows. Backfill on authoritative discovery so upgraded workspaces can + // immediately participate in instance-validated lineage. + return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + } return existing } return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) @@ -104,6 +111,40 @@ function rememberLocalWorktreeRoots( ]) } +function pruneLineageForMissingRepoWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if ( + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: path-derived IDs can disappear and later be reused by a different + // checkout. Once a successful scan proves the child is gone, drop its + // lineage so a future same-path worktree cannot inherit it. Missing + // parents stay readable so the UI can show the repairable "Missing + // parent" state. + store.removeWorktreeLineage(childId) + } + if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: keep the child lineage so the UI can show "Missing parent", but + // rotate the absent parent's stale identity once. If a different + // checkout later reuses that path, the old lineage stays invalid. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } +} + type SshWorktreeMetaCandidate = { path: string meta: WorktreeMeta @@ -179,6 +220,8 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:remove') ipcMain.removeHandler('worktrees:updateMeta') + ipcMain.removeHandler('worktrees:listLineage') + ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:createIssueCommandRunner') @@ -225,6 +268,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -292,6 +336,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -743,6 +788,27 @@ export function registerWorktreeHandlers( } ) + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return store.getAllWorktreeLineage() + }) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + // Why: the renderer continuously snapshots the computed sidebar order into // sortOrder so that it can be restored on cold start (when ephemeral signals // like running jobs and live terminals are gone). A single batch call avoids diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5846a1e45a5..1bea0d38d6b 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types' +import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' @@ -79,6 +79,17 @@ const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ( ...overrides }) +const makeWorktreeLineage = (overrides: Partial = {}): WorktreeLineage => ({ + worktreeId: 'r1::/path/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'r1::/path/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1, + ...overrides +}) + function makeSessionWithTerminalBuffers(): WorkspaceSessionState { return { activeRepoId: 'local-repo', @@ -471,6 +482,40 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('removeRepo deletes child and parent lineage for the repo', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) + + store.setWorktreeLineage( + 'r1::/path/child', + makeWorktreeLineage({ + worktreeId: 'r1::/path/child', + parentWorktreeId: 'r1::/path/parent' + }) + ) + store.setWorktreeLineage( + 'r2::/other-child', + makeWorktreeLineage({ + worktreeId: 'r2::/other-child', + parentWorktreeId: 'r1::/path/parent' + }) + ) + store.setWorktreeLineage( + 'r2::/other', + makeWorktreeLineage({ + worktreeId: 'r2::/other', + parentWorktreeId: 'r2::/parent' + }) + ) + + store.removeRepo('r1') + + expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() + expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() + expect(store.getWorktreeLineage('r2::/other')).toBeDefined() + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { @@ -3001,6 +3046,35 @@ describe('Store', () => { expect(store.getWorktreeMeta('b')).toBeDefined() }) + it('stores and removes worktree lineage independently from metadata', async () => { + const store = await createStore() + const lineage = makeWorktreeLineage() + + store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) + store.setWorktreeLineage(lineage.worktreeId, lineage) + + expect(store.getWorktreeLineage(lineage.worktreeId)).toEqual(lineage) + expect(store.getAllWorktreeLineage()).toEqual({ [lineage.worktreeId]: lineage }) + + store.removeWorktreeLineage(lineage.worktreeId) + + expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() + expect(store.getWorktreeMeta(lineage.worktreeId)).toBeDefined() + }) + + it('removeWorktreeMeta deletes that worktree lineage entry', async () => { + const store = await createStore() + const lineage = makeWorktreeLineage() + + store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) + store.setWorktreeLineage(lineage.worktreeId, lineage) + + store.removeWorktreeMeta(lineage.worktreeId) + + expect(store.getWorktreeMeta(lineage.worktreeId)).toBeUndefined() + expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() + }) + // ── Rolling backups (issue #1158) ────────────────────────────────── describe('rolling backups', () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e400700b6da..5df4b254f5b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -33,6 +33,7 @@ import type { Repo, SparsePreset, WorktreeMeta, + WorktreeLineage, GlobalSettings, OnboardingChecklistState, OnboardingOutcome, @@ -1112,6 +1113,7 @@ export class Store { result = { ...defaults, ...parsed, + worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { ...defaults.settings, ...parsed.settings, @@ -1585,6 +1587,11 @@ export class Store { delete this.state.worktreeMeta[key] } } + for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { + if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) { + delete this.state.worktreeLineageById[childId] + } + } this.scheduleSave() } @@ -1867,6 +1874,9 @@ export class Store { setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } this.state.worktreeMeta[worktreeId] = updated this.scheduleSave() return updated @@ -1874,6 +1884,26 @@ export class Store { removeWorktreeMeta(worktreeId: string): void { delete this.state.worktreeMeta[worktreeId] + delete this.state.worktreeLineageById[worktreeId] + this.scheduleSave() + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this.state.worktreeLineageById[worktreeId] = lineage + this.scheduleSave() + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this.state.worktreeLineageById[worktreeId] this.scheduleSave() } @@ -2559,6 +2589,7 @@ export class Store { function getDefaultWorktreeMeta(): WorktreeMeta { return { + instanceId: randomUUID(), displayName: '', comment: '', linkedIssue: null, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0b89b417f0e..62e4e355118 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeMeta } from '../../shared/types' +import type { WorktreeLineage, WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' import { createSetupRunnerScript, @@ -25,23 +25,41 @@ const { removeWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, + sshGitProviders, + getSshGitProviderMock, + registerSshGitProviderMock, + unregisterSshGitProviderMock, invalidateAuthorizedRootsCacheMock -} = vi.hoisted(() => ({ - MOCK_GIT_WORKTREES: [ - { - path: '/tmp/worktree-a', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - } - ], - addWorktreeMock: vi.fn(), - removeWorktreeMock: vi.fn(), - computeWorktreePathMock: vi.fn(), - ensurePathWithinWorkspaceMock: vi.fn(), - invalidateAuthorizedRootsCacheMock: vi.fn() -})) +} = vi.hoisted(() => { + // Why: SSH runtime tests register providers through the public dispatcher API, + // so the mock needs the same registry semantics as the real module. + const sshGitProviders = new Map() + + return { + MOCK_GIT_WORKTREES: [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ], + addWorktreeMock: vi.fn(), + removeWorktreeMock: vi.fn(), + computeWorktreePathMock: vi.fn(), + ensurePathWithinWorkspaceMock: vi.fn(), + sshGitProviders, + getSshGitProviderMock: vi.fn((connectionId: string) => sshGitProviders.get(connectionId)), + registerSshGitProviderMock: vi.fn((connectionId: string, provider: unknown) => { + sshGitProviders.set(connectionId, provider) + }), + unregisterSshGitProviderMock: vi.fn((connectionId: string) => { + sshGitProviders.delete(connectionId) + }), + invalidateAuthorizedRootsCacheMock: vi.fn() + } +}) vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), @@ -49,6 +67,12 @@ vi.mock('../git/worktree', () => ({ removeWorktree: removeWorktreeMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + registerSshGitProvider: registerSshGitProviderMock, + unregisterSshGitProvider: unregisterSshGitProviderMock +})) + vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), @@ -93,6 +117,19 @@ afterEach(() => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() vi.mocked(removeWorktree).mockReset() + sshGitProviders.clear() + getSshGitProviderMock.mockReset() + getSshGitProviderMock.mockImplementation((connectionId: string) => + sshGitProviders.get(connectionId) + ) + registerSshGitProviderMock.mockReset() + registerSshGitProviderMock.mockImplementation((connectionId: string, provider: unknown) => { + sshGitProviders.set(connectionId, provider) + }) + unregisterSshGitProviderMock.mockReset() + unregisterSshGitProviderMock.mockImplementation((connectionId: string) => { + sshGitProviders.delete(connectionId) + }) vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(hasHooksFile).mockReset() @@ -152,6 +189,24 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + function deferred(): { promise: Promise resolve: (value: T) => void @@ -1955,6 +2010,8 @@ describe('OrcaRuntimeService', () => { repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', + parentWorktreeId: null, + childWorktreeIds: [], displayName: 'foo', linkedIssue: 123, linkedPR: null, @@ -2151,6 +2208,955 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) + it('resolves SSH worktrees when manually updating lineage', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const childId = `${remoteRepo.id}::/home/user/repo-child` + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const metaById: Record = { + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const listSshWorktrees = vi.fn().mockResolvedValue([ + { + path: '/home/user/repo-child', + head: 'abc', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: '/home/user/repo-parent', + head: 'def', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual' + }) + ) + }) + + it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { + const parentPath = '/tmp/worktree-a' + const childPath = '/tmp/worktree-b' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => + worktreeId === parentId + ? { + worktreeId: parentId, + worktreeInstanceId: 'old-parent-instance', + parentWorktreeId: childId, + parentWorktreeInstanceId: 'child-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + : undefined, + setWorktreeLineage + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/a', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/b', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'new-parent-instance' + }) + ) + }) + + it('backfills instanceId during runtime selector resolution for upgraded metadata', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta(), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { + ...metaById[worktreeId], + ...meta, + instanceId: meta.instanceId ?? metaById[worktreeId]?.instanceId ?? 'backfilled-instance' + } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage: vi.fn((_worktreeId: string, lineage) => lineage) + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(runtimeStore.setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'backfilled-instance' + }) + ) + }) + + it('rotates a missing parent instance during runtime selector scans before same-path reuse', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'old-parent-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId], + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage: vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }), + setWorktreeLineage + } + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValue([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.showManagedWorktree(`id:${childId}`) + const rotatedParentInstance = metaById[parentId].instanceId + expect(rotatedParentInstance).toBeTruthy() + expect(rotatedParentInstance).not.toBe('old-parent-instance') + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'rescanned' }) + expect(metaById[parentId].instanceId).toBe(rotatedParentInstance) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'touch' }) + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: rotatedParentInstance + }) + ) + }) + + it('does not prune lineage when a runtime local worktree scan fails', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + vi.mocked(listWorktrees).mockRejectedValueOnce(new Error('git unavailable')) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('does not prune lineage when an SSH runtime provider is unavailable', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const childId = `${remoteRepo.id}::/home/user/repo-child` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('exposes valid parent and child lineage in CLI worktree records', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'parent' + }), + [childId]: makeWorktreeMeta({ + instanceId: 'child-instance', + displayName: 'child' + }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const listed = await runtime.listManagedWorktrees('id:repo-1') + const parent = listed.worktrees.find((worktree) => worktree.id === parentId) + const child = listed.worktrees.find((worktree) => worktree.id === childId) + + expect(parent).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + expect(child).toMatchObject({ + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: lineageById[childId] + }) + await expect(runtime.showManagedWorktree(`id:${childId}`)).resolves.toMatchObject({ + id: childId, + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: lineageById[childId] + }) + }) + + it('keeps valid orchestration lineage when caller terminal context is stale', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/worker-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }) + } + const setWorktreeLineage = vi.fn((worktreeId: string, lineage) => { + metaById[worktreeId] = metaById[worktreeId] ?? makeWorktreeMeta() + return lineage + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta({ instanceId: 'child-instance' }) + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'abc', + branch: 'feature/coordinator', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { + callerTerminalHandle: 'term_stale', + orchestrationContext: { + parentWorktreeId: parentId, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + } + } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + }) + expect(result.lineage).not.toHaveProperty('createdByTerminalHandle') + expect(result.warnings).toEqual([]) + expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + }) + + it('enriches caller-terminal lineage with active orchestration dispatch context', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }), + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn(() => ({ + task_id: 'task-1' + })), + getActiveCoordinatorRun: vi.fn(() => ({ + id: 'run-1', + coordinator_handle: coordinatorHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + title: 'Coordinator', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-coordinator', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + ...MOCK_GIT_WORKTREES, + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { callerTerminalHandle: workerHandle } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle, + createdByTerminalHandle: workerHandle + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: expect.not.stringMatching(/^old-/), + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('falls back to cwd lineage when the caller terminal handle is stale', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/cwd-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'cwd-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cwd-child', + lineage: { + callerTerminalHandle: 'term_stale', + cwdParentWorktree: `id:${parentId}` + } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' } + }) + expect(result.worktree).toMatchObject({ + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: result.lineage + }) + expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + }) + + it('keeps cwd-inferred lineage best-effort when the cwd parent cannot be resolved', async () => { + const childPath = '/tmp/workspaces/no-cwd-parent' + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce(MOCK_GIT_WORKTREES) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'no-cwd-parent', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(store) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'no-cwd-parent', + lineage: { + cwdParentWorktree: 'id:repo-1::/tmp/missing-parent' + } + }) + + expect(result.lineage).toBeNull() + expect(result.worktree).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + }) + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the current directory as a parent workspace.' + }) + ]) + }) + + it('infers orchestration lineage from task-id comments when dispatch is completed', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => ({ + task_id: 'task_abc123', + assignee_handle: workerHandle, + status: 'completed' + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + comment: 'Created via orchestration task task_abc123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_abc123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('infers orchestration lineage from task creator when no dispatch context exists', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/parent-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = `${TEST_REPO_ID}::${parentPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'parent' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const parentHandle = runtime.preAllocateHandleForPty('pty-parent') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => undefined), + getTask: vi.fn(() => ({ + id: 'task_creator123', + created_by_terminal_handle: parentHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + title: 'Parent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-parent', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'fed', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'parent-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'parent-child', + comment: 'Created via orchestration task task_creator123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_creator123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'parent-instance' + }) + ) + }) + it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f52bc0ebcfa..44ba0c6499a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -22,6 +22,8 @@ import type { Repo, StatsSummary, Worktree, + WorktreeLineage, + WorktreeLineageWarning, WorktreeMeta, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, @@ -286,6 +288,10 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getWorktreeLineage?: Store['getWorktreeLineage'] + getAllWorktreeLineage?: Store['getAllWorktreeLineage'] + setWorktreeLineage?: Store['setWorktreeLineage'] + removeWorktreeLineage?: Store['removeWorktreeLineage'] getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { @@ -470,7 +476,69 @@ function omitUndefinedProperties>(value: T): P ) as Partial } -type ResolvedWorktree = Worktree & { git: GitWorktreeInfo } +type ResolvedWorktree = Worktree & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null + git: GitWorktreeInfo +} + +type WorktreeLineageInput = { + parentWorktree?: string + cwdParentWorktree?: string + noParent?: boolean + callerTerminalHandle?: string + comment?: string + orchestrationContext?: { + parentWorktreeId?: string + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + } +} + +type WorktreeLineageResolution = + | { + kind: 'lineage' + parent: ResolvedWorktree + origin: WorktreeLineage['origin'] + capture: WorktreeLineage['capture'] + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + } + | { + kind: 'none' + warnings: WorktreeLineageWarning[] + } + +type RuntimeWorktreeScanResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; worktrees: [] } + +type WorktreeLineageCandidate = { + source: 'cwd-context' | 'terminal-context' | 'orchestration-context' + parent: ResolvedWorktree + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string +} + +function extractOrchestrationTaskId(text?: string): string | undefined { + return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] +} + +class RuntimeLineageError extends Error { + code: string + data?: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } +} type ResolvedWorktreeCache = { expiresAt: number @@ -3852,6 +3920,8 @@ export class OrcaRuntimeService { repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, + parentWorktreeId: worktree.parentWorktreeId, + childWorktreeIds: worktree.childWorktreeIds, displayName: worktree.displayName, linkedIssue: worktree.linkedIssue, linkedPR, @@ -5007,6 +5077,7 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch + lineage?: WorktreeLineageInput }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -5022,6 +5093,9 @@ export class OrcaRuntimeService { // Runtime RPC must not fall through to local git against server paths. throw new Error('SSH-backed worktree creation is not supported through runtime RPC yet.') } + const lineageInput = + args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined + const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) const settings = this.store.getSettings() const requestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined @@ -5137,6 +5211,10 @@ export class OrcaRuntimeService { ? { displayName: requestedName } : {} const meta = this.store.setWorktreeMeta(worktreeId, { + // Why: worktree IDs are path-derived. If a path is deleted outside Orca + // and later recreated, creation must mint a fresh instance identity so + // stale lineage records tied to the old occupant fail validation. + instanceId: randomUUID(), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't @@ -5162,6 +5240,44 @@ export class OrcaRuntimeService { ...(args.comment !== undefined ? { comment: args.comment } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) + let lineage: WorktreeLineage | null = null + const lineageWarnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] + if (lineageResolution.kind === 'lineage') { + const childInstanceId = meta.instanceId + const parentInstanceId = lineageResolution.parent.instanceId + if (childInstanceId && parentInstanceId && this.store.setWorktreeLineage) { + lineage = this.store.setWorktreeLineage(worktreeId, { + worktreeId, + worktreeInstanceId: childInstanceId, + parentWorktreeId: lineageResolution.parent.id, + parentWorktreeInstanceId: parentInstanceId, + origin: lineageResolution.origin, + capture: lineageResolution.capture, + ...(lineageResolution.orchestrationRunId + ? { orchestrationRunId: lineageResolution.orchestrationRunId } + : {}), + ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), + ...(lineageResolution.coordinatorHandle + ? { coordinatorHandle: lineageResolution.coordinatorHandle } + : {}), + ...(lineageResolution.createdByTerminalHandle + ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } + : {}), + createdAt: now + }) + } else { + lineageWarnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', + details: { + childHasInstanceId: Boolean(childInstanceId), + parentHasInstanceId: Boolean(parentInstanceId), + storeSupportsLineage: Boolean(this.store.setWorktreeLineage) + } + }) + } + } if ( settings.experimentalWorktreeSymlinks && @@ -5296,7 +5412,14 @@ export class OrcaRuntimeService { } } return { - worktree, + worktree: { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: [], + lineage, + git: created + }, + ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), ...(warning ? { warning } : {}) } @@ -5641,17 +5764,53 @@ export class OrcaRuntimeService { return { base, behind: drift.behind, recentSubjects } } - async updateManagedWorktreeMeta(worktreeSelector: string, updates: Partial) { + async updateManagedWorktreeMeta( + worktreeSelector: string, + updates: Partial & { + lineage?: { + parentWorktree?: string + noParent?: boolean + } + } + ) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const meta = this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(updates)) + const { lineage, ...metaUpdates } = updates + if (lineage?.noParent === true) { + this.store.removeWorktreeLineage?.(worktree.id) + } else if (lineage?.parentWorktree) { + const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) + this.validateLineageParent(worktree, parent) + if (!worktree.instanceId || !parent.instanceId) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace instance identity was unavailable.' + ) + } + if (!this.store.setWorktreeLineage) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace lineage storage was unavailable.' + ) + } + this.store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: Date.now() + }) + } + this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(metaUpdates)) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(worktree.repoId) - return mergeWorktree(worktree.repoId, worktree.git, meta) + return await this.showManagedWorktree(`id:${worktree.id}`) } persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { @@ -6594,6 +6753,305 @@ export class OrcaRuntimeService { throw new Error('selector_not_found') } + private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { + const childWorktreeId = child.id + const parentWorktreeId = parent.id + if (childWorktreeId === parentWorktreeId) { + throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.') + } + const instanceByWorktreeId = new Map( + this.resolvedWorktreeCache?.worktrees.map((worktree) => [ + worktree.id, + worktree.instanceId + ]) ?? [ + [child.id, child.instanceId], + [parent.id, parent.instanceId] + ] + ) + let cursor: string | undefined = parentWorktreeId + const visited = new Set([childWorktreeId]) + while (cursor) { + if (visited.has(cursor)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CYCLE', + 'Parent workspace would create a lineage cycle.' + ) + } + visited.add(cursor) + const lineage = this.store?.getWorktreeLineage?.(cursor) + if (!lineage) { + break + } + const cursorInstanceId = instanceByWorktreeId.get(cursor) + const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) + if ( + cursorInstanceId !== lineage.worktreeInstanceId || + parentInstanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + cursor = lineage.parentWorktreeId + } + } + + private async resolveLineageForWorktreeCreate( + input?: WorktreeLineageInput + ): Promise { + if (!input) { + return { kind: 'none', warnings: [] } + } + + if (input.noParent === true && input.parentWorktree) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Choose either --parent-worktree or --no-parent, not both.' + ) + } + + if (input.noParent === true) { + return { kind: 'none', warnings: [] } + } + + if (input.parentWorktree) { + try { + const parent = await this.resolveWorktreeSelector(input.parentWorktree) + return { + kind: 'lineage', + parent, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + } catch { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_NOT_FOUND', + 'Parent workspace was not found.', + { + nextSteps: [ + 'Run `orca worktree list` and pass a valid --parent-worktree selector.', + 'Retry with --no-parent to create without lineage.' + ] + } + ) + } + } + + const warnings: WorktreeLineageWarning[] = [] + const candidates: WorktreeLineageCandidate[] = [] + let cwdCandidate: WorktreeLineageCandidate | null = null + let terminalContextResolved = false + + if (input.orchestrationContext?.parentWorktreeId) { + try { + candidates.push({ + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector( + `id:${input.orchestrationContext.parentWorktreeId}` + ) + }) + } catch { + // Keep creation recoverable; the warning below covers missing inferred context. + } + } + + const commentTaskId = extractOrchestrationTaskId(input.comment) + if (commentTaskId) { + const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) + if (candidate) { + candidates.push(candidate) + } + } + + if (input.callerTerminalHandle) { + try { + const terminal = await this.showTerminal(input.callerTerminalHandle) + const terminalParent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( + input.callerTerminalHandle + ) + const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() + if (activeDispatch) { + candidates.push({ + source: 'orchestration-context', + parent: terminalParent, + taskId: activeDispatch.task_id, + ...(activeRun + ? { + orchestrationRunId: activeRun.id, + coordinatorHandle: activeRun.coordinator_handle + } + : {}) + }) + } else { + candidates.push({ + source: 'terminal-context', + parent: terminalParent + }) + } + terminalContextResolved = true + } catch { + // Why: terminal handles can go stale during reloads or SSH reconnects. + // A valid orchestration parent is still authoritative, so keep resolving + // other inferred candidates instead of dropping lineage completely. + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the caller terminal as a parent workspace.', + details: { callerTerminalHandle: input.callerTerminalHandle } + }) + } + } + + if (input.cwdParentWorktree) { + try { + cwdCandidate = { + source: 'cwd-context', + parent: await this.resolveWorktreeSelector(input.cwdParentWorktree) + } + } catch { + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the current directory as a parent workspace.', + details: { cwdParentWorktree: input.cwdParentWorktree } + }) + } + } + + if (candidates.length === 0 && cwdCandidate) { + candidates.push(cwdCandidate) + } + + if (candidates.length === 0) { + return { kind: 'none', warnings } + } + + const [first] = candidates + const conflict = candidates.find((candidate) => candidate.parent.id !== first.parent.id) + if (conflict) { + return { + kind: 'none', + warnings: [ + { + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: 'Worktree created, but Orca could not prove which parent workspace caused it.', + details: { + terminalParentWorktreeId: candidates.find((c) => c.source === 'terminal-context') + ?.parent.id, + orchestrationParentWorktreeId: candidates.find( + (c) => c.source === 'orchestration-context' + )?.parent.id + } + } + ] + } + } + + const preferred = + candidates.find((candidate) => candidate.source === 'orchestration-context') ?? first + return { + kind: 'lineage', + parent: preferred.parent, + origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', + capture: { source: preferred.source, confidence: 'inferred' }, + ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) + ? { + orchestrationRunId: + preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId + } + : {}), + ...((preferred.taskId ?? input.orchestrationContext?.taskId) + ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } + : {}), + ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) + ? { + coordinatorHandle: + preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle + } + : {}), + ...(terminalContextResolved && input.callerTerminalHandle + ? { createdByTerminalHandle: input.callerTerminalHandle } + : {}) + } + } + + private async resolveLineageCandidateForTaskId( + taskId: string + ): Promise { + const db = this.getOrchestrationDbIfAvailable() + const dispatch = db?.getDispatchContext(taskId) + // Why: agent-created task records may never be dispatched, but the + // creating terminal still identifies the parent workspace for descendants. + const parentHandle = + dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle + if (!parentHandle) { + return null + } + try { + const terminal = await this.showTerminal(parentHandle) + return { + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`), + taskId + } + } catch { + return null + } + } + + private getOrchestrationDbIfAvailable(): OrchestrationDb | null { + try { + return this._orchestrationDb ?? this.getOrchestrationDb() + } catch { + return this._orchestrationDb + } + } + + async hydrateInferredWorktreeLineage(): Promise { + const store = this.store + if ( + !store || + typeof store.getWorktreeLineage !== 'function' || + typeof store.setWorktreeLineage !== 'function' + ) { + return + } + + const worktrees = await this.listResolvedWorktrees() + for (const worktree of worktrees) { + if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { + continue + } + const taskId = extractOrchestrationTaskId(worktree.comment) + if (!taskId) { + continue + } + const candidate = await this.resolveLineageCandidateForTaskId(taskId) + if (!candidate?.parent.instanceId || candidate.parent.id === worktree.id) { + continue + } + try { + this.validateLineageParent(worktree, candidate.parent) + } catch { + continue + } + store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: candidate.parent.id, + parentWorktreeInstanceId: candidate.parent.instanceId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId, + createdAt: Date.now() + }) + } + } + + async listWorktreeLineage(): Promise> { + await this.hydrateInferredWorktreeLineage() + return this.store?.getAllWorktreeLineage?.() ?? {} + } + private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') @@ -6661,21 +7119,35 @@ export class OrcaRuntimeService { return [] } const now = Date.now() - const metaById = this.store.getAllWorktreeMeta() const perRepoWorktrees = await Promise.all( this.store.getRepos().map(async (repo) => { // Why: mobile startup RPCs share this path. A slow repo scan should // degrade one repo's metadata, not block all terminal/session loading. - const gitWorktrees = await withTimeout( - listRepoWorktrees(repo), + const scan = await withTimeout( + this.listRepoWorktreesForResolution(repo), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, - [] + { ok: false, worktrees: [] } ) + const gitWorktrees = scan.worktrees + if (scan.ok) { + this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) + } + const metaById = this.store?.getAllWorktreeMeta() ?? {} return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName) + // Why: lineage validation needs a durable instance ID even when the + // runtime sees a workspace before the renderer's discovery-stamp path. + const existingMeta = metaById[worktreeId] + const meta = + existingMeta && existingMeta.instanceId + ? existingMeta + : this.store?.setWorktreeMeta(worktreeId, {}) + const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) return { ...merged, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, git: { path: gitWorktree.path, head: gitWorktree.head, @@ -6689,7 +7161,7 @@ export class OrcaRuntimeService { }) }) ) - const worktrees = perRepoWorktrees.flat() + const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat()) // Why: terminal polling can be frequent, but git worktree state is still // allowed to change outside Orca. A short TTL avoids shelling out on every // read without pretending the cache is authoritative for long. @@ -6702,6 +7174,86 @@ export class OrcaRuntimeService { return worktrees } + private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] { + const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} + const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) + const validLineageByChildId = new Map() + const childIdsByParentId = new Map() + + for (const [childId, lineage] of Object.entries(lineageById)) { + const child = worktreeById.get(childId) + const parent = worktreeById.get(lineage.parentWorktreeId) + if ( + !child || + !parent || + child.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + // Why: worktree IDs are path-derived. Instance checks keep replacement + // checkouts from appearing as children of stale same-path lineage. + continue + } + validLineageByChildId.set(childId, lineage) + const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] + children.push(childId) + childIdsByParentId.set(lineage.parentWorktreeId, children) + } + + return worktrees.map((worktree) => { + const lineage = validLineageByChildId.get(worktree.id) ?? null + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], + lineage + } + }) + } + + private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { + const store = this.store + if ( + !store || + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: runtime selector scans can be the only scan before a path is + // reused. Once a successful scan proves the child is gone, stale + // lineage must not survive into the replacement checkout. + store.removeWorktreeLineage(childId) + } + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + !liveIds.has(lineage.parentWorktreeId) + ) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: preserving child lineage powers the repair UI, but a missing + // parent path only needs one fresh identity to keep same-path + // replacement checkouts from validating old lineage. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } + } + + private async listRepoWorktreesForResolution(repo: Repo): Promise { + if (!repo.connectionId) { + return { ok: true, worktrees: await listRepoWorktrees(repo) } + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { ok: false, worktrees: [] } + } + return { ok: true, worktrees: await provider.listWorktrees(repo.path) } + } + private async getResolvedWorktreeMap(): Promise> { return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) } diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 9e9d5d941b3..2f6b7f089a0 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -132,6 +132,17 @@ describe('OrchestrationDb', () => { expect(task.deps).toBe('[]') }) + it('persists the creating terminal handle for task-created worktrees', () => { + const d = createDb() + const task = d.createTask({ + spec: 'spawn related workspace', + createdByTerminalHandle: 'term_creator' + }) + + expect(task.created_by_terminal_handle).toBe('term_creator') + expect(d.getTask(task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('creates a task with deps as pending', () => { const d = createDb() const parent = d.createTask({ spec: 'parent' }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index e8daa9f1708..8bfe15991cb 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -36,10 +36,10 @@ function generateId(prefix: string): string { // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged -// messages without touching the `read` bit (check-wait PR). Bumping together -// since both PRs ship in the same release — existing on-disk DBs must clear -// both gaps in one atomic migration. -const SCHEMA_VERSION = 3 +// messages without touching the `read` bit (check-wait PR). v3 → v4 records +// the terminal that created a task so task-record worktree creation can infer +// the parent workspace even when no dispatch context exists. +const SCHEMA_VERSION = 4 export class OrchestrationDb { private db: Database.Database @@ -83,6 +83,7 @@ export class OrchestrationDb { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, parent_id TEXT, + created_by_terminal_handle TEXT, spec TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ( @@ -227,6 +228,11 @@ export class OrchestrationDb { this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`) } } + if (current < 4) { + if (!this.hasColumn('tasks', 'created_by_terminal_handle')) { + this.db.exec(`ALTER TABLE tasks ADD COLUMN created_by_terminal_handle TEXT`) + } + } this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') @@ -367,14 +373,28 @@ export class OrchestrationDb { // ── Tasks ── - createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { + createTask(task: { + spec: string + deps?: string[] + parentId?: string + createdByTerminalHandle?: string + }): TaskRow { const id = generateId('task') const depsJson = JSON.stringify(task.deps ?? []) const hasDeps = (task.deps ?? []).length > 0 const status: TaskStatus = hasDeps ? 'pending' : 'ready' this.db - .prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)') - .run(id, task.parentId ?? null, task.spec, status, depsJson) + .prepare( + 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?)' + ) + .run( + id, + task.parentId ?? null, + task.createdByTerminalHandle ?? null, + task.spec, + status, + depsJson + ) return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 03a31391cc9..f966368b937 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -37,6 +37,7 @@ export type MessageRow = { export type TaskRow = { id: string parent_id: string | null + created_by_terminal_handle: string | null spec: string status: TaskStatus deps: string diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts new file mode 100644 index 00000000000..bf11965bf07 --- /dev/null +++ b/src/main/runtime/rpc/errors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { mapRuntimeError } from './errors' + +class LineageError extends Error { + code = 'LINEAGE_PARENT_NOT_FOUND' + data = { + nextSteps: ['Run `orca worktree list`.', 'Retry with --no-parent.'] + } +} + +describe('mapRuntimeError', () => { + it('preserves structured lineage error codes and data for CLI recovery hints', () => { + const response = mapRuntimeError( + 'req_1', + { runtimeId: 'runtime-1' }, + new LineageError('Parent workspace was not found.') + ) + + expect(response).toEqual({ + id: 'req_1', + ok: false, + error: { + code: 'LINEAGE_PARENT_NOT_FOUND', + message: 'Parent workspace was not found.', + data: { + nextSteps: ['Run `orca worktree list`.', 'Retry with --no-parent.'] + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index da19f1cba1a..98937e6a1b3 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -59,6 +59,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow ) { return errorResponse(id, meta, (error as { code: string }).code, message) } + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + (error as { code: string }).code.startsWith('LINEAGE_') + ) { + return errorResponse( + id, + meta, + (error as { code: string }).code, + message, + (error as { data?: unknown }).data + ) + } if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 93856e98adb..27cc27cfff7 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -477,6 +477,16 @@ describe('orchestration RPC methods', () => { expect(result.task.status).toBe('pending') }) + it('records the caller terminal handle when creating a task', async () => { + setup() + const result = (await call('orchestration.taskCreate', { + spec: 'spawn related workspace', + callerTerminalHandle: 'term_creator' + })) as { task: { id: string } } + + expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('rejects invalid deps JSON', async () => { setup() await expect( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 0fb5a6f8680..b2d31e98902 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -81,7 +81,8 @@ const InboxParams = z.object({ const TaskCreateParams = z.object({ spec: requiredString('Missing --spec'), deps: OptionalString, - parent: OptionalString + parent: OptionalString, + callerTerminalHandle: OptionalString }) const TaskListParams = z.object({ @@ -328,7 +329,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const task = db.createTask({ spec: params.spec, deps, - parentId: params.parent + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle }) return { task } } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index e0da750b5c4..d9a9d4ddd72 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -26,7 +26,8 @@ describe('worktree RPC methods', () => { linkedIssue: 123, linkedPR: 456, sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, - pushTarget: { remoteName: 'fork', branchName: 'feature' } + pushTarget: { remoteName: 'fork', branchName: 'feature' }, + parentWorktree: 'id:parent' }) ) @@ -36,6 +37,7 @@ describe('worktree RPC methods', () => { baseBranch: 'origin/main', linkedIssue: 123, linkedPR: 456, + linkedLinearIssue: undefined, comment: undefined, displayName: 'Feature title', sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, @@ -43,10 +45,82 @@ describe('worktree RPC methods', () => { runHooks: false, activate: false, setupDecision: 'skip', - startup: undefined + createdWithAgent: undefined, + startup: undefined, + lineage: { + parentWorktree: 'id:parent', + noParent: false, + callerTerminalHandle: undefined, + orchestrationContext: undefined + } }) }) + it('rejects worktree.create when both parent and no-parent are supplied', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + 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: 'child', + parentWorktree: 'id:parent', + noParent: true + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('Choose either --parent-worktree or --no-parent') + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) + + it('rejects worktree.set when both parent and no-parent are supplied', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateManagedWorktreeMeta: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.set', { + worktree: 'id:child', + parentWorktree: 'id:parent', + noParent: true + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('Choose either --parent-worktree or --no-parent') + expect(runtime.updateManagedWorktreeMeta).not.toHaveBeenCalled() + }) + + it('lists raw worktree lineage through the runtime server', async () => { + const lineage = { + 'repo::/child': { + worktreeId: 'repo::/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo::/missing-parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + listWorktreeLineage: vi.fn().mockResolvedValue(lineage) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('worktree.lineageList')) + + expect(runtime.listWorktreeLineage).toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { lineage } }) + }) + it('persists smart sort order on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index ee3de8f36e6..17922acb7a1 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -29,48 +29,69 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) -const WorktreeCreate = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - baseBranch: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - comment: OptionalString, - displayName: OptionalString, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: mobile clients pass a startup command (e.g. 'claude') so the first - // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional() -}) +const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + baseBranch: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + comment: OptionalString, + displayName: OptionalString, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional() + }) + .superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } + }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, @@ -97,7 +118,16 @@ const WorktreeSet = WorktreeSelector.extend({ remoteUrl: OptionalString }) .optional(), - diffComments: z.array(z.unknown()).optional() + diffComments: z.array(z.unknown()).optional(), + parentWorktree: OptionalString, + noParent: OptionalBoolean +}).superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } }) const WorktreeRemove = WorktreeSelector.extend({ @@ -129,6 +159,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [ params: WorktreeListParams, handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit) }), + defineMethod({ + name: 'worktree.lineageList', + params: null, + handler: async (_params, { runtime }) => ({ lineage: await runtime.listWorktreeLineage() }) + }), defineMethod({ name: 'worktree.show', params: WorktreeSelector, @@ -165,7 +200,14 @@ export const WORKTREE_METHODS: RpcMethod[] = [ activate: params.activate === true, setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent, - startup: params.startupCommand ? { command: params.startupCommand } : undefined + startup: params.startupCommand ? { command: params.startupCommand } : undefined, + lineage: { + parentWorktree: params.parentWorktree, + ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } }) }), defineMethod({ @@ -189,7 +231,14 @@ export const WORKTREE_METHODS: RpcMethod[] = [ sparsePresetId: params.sparsePresetId, baseRef: params.baseRef, pushTarget: params.pushTarget, - diffComments: params.diffComments + diffComments: params.diffComments, + lineage: + params.parentWorktree || params.noParent === true + ? { + parentWorktree: params.parentWorktree, + noParent: params.noParent === true + } + : undefined } as Parameters[1]) }) }), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0fdce54eb6b..576a56ab951 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -87,6 +87,7 @@ import type { UpdateStatus, Worktree, WorktreeBaseStatusEvent, + WorktreeLineage, WorktreeMeta, WorktreeRemoteBranchConflictEvent, WorktreeSetupLaunch, @@ -586,6 +587,12 @@ export type PreloadApi = { }) => Promise<{ baseBranch: string } | { error: string }> remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + listLineage: () => Promise> + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index f6348d1b7b6..4ecf67c7b38 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -465,6 +465,15 @@ const api = { updates: Record }): Promise => ipcRenderer.invoke('worktrees:updateMeta', args), + listLineage: (): Promise> => + ipcRenderer.invoke('worktrees:listLineage'), + + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }): Promise => ipcRenderer.invoke('worktrees:updateLineage', args), + persistSortOrder: (args: { orderedIds: string[] }): Promise => ipcRenderer.invoke('worktrees:persistSortOrder', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6a7a0e96053..a1352071af9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -220,6 +220,7 @@ function App(): React.JSX.Element { toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, initGitHubCache: s.initGitHubCache, refreshAllGitHub: s.refreshAllGitHub, @@ -320,6 +321,7 @@ function App(): React.JSX.Element { const sidebarWidth = useAppStore((s) => s.sidebarWidth) const sidebarOpen = useAppStore((s) => s.sidebarOpen) const groupBy = useAppStore((s) => s.groupBy) + const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) @@ -408,6 +410,7 @@ function App(): React.JSX.Element { await actions.fetchSettings() await actions.fetchRepos() await actions.fetchAllWorktrees() + await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() uiHydrated = hydratePersistedUIAfterStartupRead({ persistedUI, @@ -782,6 +785,7 @@ function App(): React.JSX.Element { sidebarWidth, rightSidebarWidth, groupBy, + showWorkspaceLineage, sortBy, showActiveOnly, hideDefaultBranchWorkspace, @@ -801,6 +805,7 @@ function App(): React.JSX.Element { sidebarWidth, rightSidebarWidth, groupBy, + showWorkspaceLineage, sortBy, showActiveOnly, hideDefaultBranchWorkspace, diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 57ff07b7934..1cd6b264ce6 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -73,6 +73,8 @@ const SidebarHeader = React.memo(function SidebarHeader() { const setSortBy = useAppStore((s) => s.setSortBy) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) + const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage) + const setShowWorkspaceLineage = useAppStore((s) => s.setShowWorkspaceLineage) const clearWorkspaceBoardHoverClose = useCallback(() => { if (workspaceBoardHoverCloseTimerRef.current === null) { @@ -222,6 +224,15 @@ const SidebarHeader = React.memo(function SidebarHeader() {
+ + setShowWorkspaceLineage(Boolean(checked))} + onSelect={(e) => e.preventDefault()} + > + Nest child workspaces + + Sort by ) => void onActivate?: () => void onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] @@ -61,7 +70,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ onActivate, onSelectionGesture, onContextMenuSelect, - hideRepoBadge + hideRepoBadge, + parentLabel, + lineageState, + lineageChildCount = 0, + lineageCollapsed = false, + lineageChildren, + onLineageToggle }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) @@ -291,6 +306,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' + const childWorkspaceLabel = `${lineageChildCount} child ${ + lineageChildCount === 1 ? 'workspace' : 'workspaces' + }` + const childWorkspaceShortLabel = `${lineageChildCount} ${ + lineageChildCount === 1 ? 'child' : 'children' + }` + const showLineageChildChip = lineageChildCount > 0 && onLineageToggle !== undefined const handleDragStart = useCallback( (event: React.DragEvent) => { @@ -523,6 +545,23 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} + + {parentLabel && ( + + + + {lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`} + + + )}
{/* Meta section: Issue / hosted review / Comment @@ -563,6 +602,41 @@ const WorktreeCard = React.memo(function WorktreeCard({ measureElement on each row, so the virtualizer re-measures naturally when agents appear/disappear. */} {cardProps.includes('inline-agents') && } + + {showLineageChildChip && ( +
+ + + + + + {lineageCollapsed ? 'Show child workspaces' : 'Hide child workspaces'} + + +
+ )} + + {lineageChildren &&
{lineageChildren}
}
) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts new file mode 100644 index 00000000000..e909eea3dee --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + shouldIgnoreNestedWorktreeContextMenuScope, + shouldSuppressContextMenuFollowUpClick +} from './WorktreeContextMenu' + +describe('shouldIgnoreNestedWorktreeContextMenuScope', () => { + it('allows the context menu scope that owns the event target', () => { + const currentScope = {} as EventTarget + const target = { + closest: () => currentScope + } as unknown as EventTarget + + expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(false) + }) + + it('ignores context menu events owned by a nested scope', () => { + const currentScope = {} as EventTarget + const nestedScope = {} as Element + const target = { + closest: () => nestedScope + } as unknown as EventTarget + + expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(true) + }) + + it('ignores context menu events from text nodes inside a nested scope', () => { + const currentScope = {} as EventTarget + const nestedScope = {} as Element + const target = { + parentElement: { + closest: () => nestedScope + } + } as unknown as EventTarget + + expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(true) + }) + + it('allows events from unscoped targets', () => { + const currentScope = {} as EventTarget + const target = { + closest: () => null + } as unknown as EventTarget + + expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(false) + }) +}) + +describe('shouldSuppressContextMenuFollowUpClick', () => { + it('suppresses the click emitted immediately after opening a context menu', () => { + expect(shouldSuppressContextMenuFollowUpClick(1_000, 1_050)).toBe(true) + }) + + it('does not suppress later unrelated clicks', () => { + expect(shouldSuppressContextMenuFollowUpClick(1_000, 1_700)).toBe(false) + }) + + it('does not suppress clicks that predate the context menu timestamp', () => { + expect(shouldSuppressContextMenuFollowUpClick(1_000, 999)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index d8eccb5c8be..9a4f4c4f747 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +/* eslint-disable max-lines -- Why: this menu keeps row targeting, batch actions, and ctrl-click event guards together so nested worktree menus share one event policy. */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -23,15 +24,19 @@ import { Pin, PinOff, Kanban, - Trash2 + Trash2, + Unlink, + Workflow } from 'lucide-react' import { useAppStore } from '@/store' -import { useRepoById, useRepoMap } from '@/store/selectors' +import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors' import { cn } from '@/lib/utils' import type { Worktree } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getLineageRenderInfo } from './worktree-list-groups' import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status' import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu' @@ -45,6 +50,30 @@ type Props = { const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' const WORKTREE_CONTEXT_MENU_SCOPE_ATTR = 'data-worktree-context-menu-scope' +const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500 + +function shouldIgnoreNestedWorktreeContextMenuScope( + currentTarget: EventTarget, + target: EventTarget | null +): boolean { + const maybeScopedTarget = target as { + closest?: (selector: string) => Element | null + parentElement?: { closest?: (selector: string) => Element | null } + } | null + const scopeSelector = `[${WORKTREE_CONTEXT_MENU_SCOPE_ATTR}]` + const closestScope = + maybeScopedTarget?.closest?.(scopeSelector) ?? + maybeScopedTarget?.parentElement?.closest?.(scopeSelector) + // Why: lineage child previews live inside the parent card DOM but own their + // context menu target. The parent must ignore only those nested scopes. + return closestScope != null && closestScope !== currentTarget +} + +function shouldSuppressContextMenuFollowUpClick(contextMenuOpenedAt: number, now: number): boolean { + return ( + now - contextMenuOpenedAt >= 0 && now - contextMenuOpenedAt <= CONTEXT_MENU_CLICK_SUPPRESSION_MS + ) +} const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, @@ -64,10 +93,14 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const isDeleting = deleteState?.isDeleting ?? false const isFolder = repo ? isFolderRepo(repo) : false const repoMap = useRepoMap() + const worktreeMap = useWorktreeMap() + const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId) + const contextMenuOpenedAtRef = useRef(null) const activeContextWorktrees = menuOpen ? contextWorktrees : selectedWorktrees const isMultiContext = activeContextWorktrees.length > 1 const sleepableWorktrees = useMemo( @@ -110,6 +143,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ isMultiContext && batchDeleteWorktrees.length > 0 ? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}` : 'Delete Selected' + const lineage = worktreeLineageById[worktree.id] + // Why: path-derived worktree IDs can be reused. The menu must honor the same + // instance check as grouped rows before offering navigation to a parent. + const lineageInfo = useMemo( + () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap), + [worktree, worktreeLineageById, worktreeMap] + ) + const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null + const hasAnyContextLineage = activeContextWorktrees.some((item) => worktreeLineageById[item.id]) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -234,29 +276,59 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree.repoId ]) - return ( - <> -
{ - const target = event.target - if ( - target instanceof Element && - target.closest(`[${WORKTREE_CONTEXT_MENU_SCOPE_ATTR}]`) - ) { - return - } - event.preventDefault() - window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) - setContextWorktrees(onContextMenuSelect?.(event) ?? selectedWorktrees) - const bounds = event.currentTarget.getBoundingClientRect() - setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }) - setMenuOpen(true) - }} - > - {children} -
+ const handleOpenParent = useCallback(() => { + if (validParentWorktreeId) { + activateAndRevealWorktree(validParentWorktreeId) + } + }, [validParentWorktreeId]) + const handleRemoveParentLink = useCallback(() => { + void Promise.all( + activeContextWorktrees.map((item) => updateWorktreeLineage(item.id, { noParent: true })) + ) + }, [activeContextWorktrees, updateWorktreeLineage]) + + const suppressOpeningPointerEvent = useCallback((event: React.SyntheticEvent) => { + const contextMenuOpenedAt = contextMenuOpenedAtRef.current + if ( + contextMenuOpenedAt == null || + !shouldSuppressContextMenuFollowUpClick(contextMenuOpenedAt, Date.now()) + ) { + if (contextMenuOpenedAt != null) { + contextMenuOpenedAtRef.current = null + } + return + } + // Why: macOS ctrl-click can release over the just-opened menu, selecting + // the item under the cursor unless the opening pointer sequence is ignored. + event.preventDefault() + event.stopPropagation() + if (event.type === 'click') { + contextMenuOpenedAtRef.current = null + } + }, []) + + return ( +
{ + if (shouldIgnoreNestedWorktreeContextMenuScope(event.currentTarget, event.target)) { + return + } + event.preventDefault() + contextMenuOpenedAtRef.current = Date.now() + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setContextWorktrees(onContextMenuSelect?.(event) ?? selectedWorktrees) + const bounds = event.currentTarget.getBoundingClientRect() + setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }) + setMenuOpen(true) + }} + onClickCapture={(event) => { + suppressOpeningPointerEvent(event) + }} + > + {children}
) }) export default WorktreeContextMenu -export { CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR } +export { + CLOSE_ALL_CONTEXT_MENUS_EVENT, + WORKTREE_CONTEXT_MENU_SCOPE_ATTR, + shouldSuppressContextMenuFollowUpClick, + shouldIgnoreNestedWorktreeContextMenuScope +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e44df69240e..c9afa19154f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { ChevronDown, CircleX, Plus } from 'lucide-react' +import { ChevronDown, CircleX, Plus, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { getAllWorktreesFromState, @@ -16,6 +16,7 @@ import { cn } from '@/lib/utils' import type { Worktree, Repo, + WorktreeLineage, WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' @@ -35,8 +36,9 @@ import { type WorktreeGroupBy, PINNED_GROUP_KEY, buildRows, + getGroupKeyForWorktree, getRepoGroupOrdering, - getGroupKeyForWorktree + getLineageGroupKey } from './worktree-list-groups' import { getWorkspaceStatus, @@ -53,12 +55,14 @@ import { } from './visible-worktrees' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoHeaderDrag } from './repo-header-drag' +import WorktreeContextMenu from './WorktreeContextMenu' import { areWorktreeSelectionsEqual, getWorktreeSelectionIntent, pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' +import { branchDisplayName } from './WorktreeCardHelpers' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' // How long to wait after a sortEpoch bump before actually re-sorting. @@ -91,10 +95,13 @@ function getWorktreeOptionId(worktreeId: string): string { return `worktree-list-option-${encodeURIComponent(worktreeId)}` } +const LINEAGE_INDENT = 24 + type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null groupBy: WorktreeGroupBy + showWorkspaceLineage: boolean repoGroupOrdering: RepoGroupOrdering toggleGroup: (key: string) => void collapsedGroups: Set @@ -111,6 +118,8 @@ type VirtualizedWorktreeViewportProps = { worktree: Worktree ) => readonly Worktree[] repoMap: Map + worktreeMap: Map + worktreeLineageById: Record repoOrder: Map // The full canonical state.repos id ordering — the drag controller commits // permutations of this list, even when some repos aren't currently visible @@ -131,10 +140,77 @@ type VirtualizedWorktreeViewportProps = { scrollOffsetRef: React.MutableRefObject } +type WorktreeItemRow = Extract +type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } + +function isWorktreeItemRow(row: Row): row is WorktreeItemRow { + return row.type === 'item' +} + +function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { + if (worktreeId === null) { + return false + } + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.worktree.id === worktreeId) + } + return row.type === 'item' && row.worktree.id === worktreeId +} + +function buildRenderableRows(rows: Row[], showWorkspaceLineage: boolean): RenderRow[] { + if (!showWorkspaceLineage) { + return rows + } + + const renderRows: RenderRow[] = [] + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + if ( + !isWorktreeItemRow(row) || + row.lineageChildCount === 0 || + row.lineageCollapsed || + rows[index + 1]?.type !== 'item' || + (rows[index + 1] as WorktreeItemRow).depth <= row.depth + ) { + renderRows.push(row) + continue + } + + const groupRows: WorktreeItemRow[] = [row] + let cursor = index + 1 + while (cursor < rows.length) { + const child = rows[cursor] + if (!isWorktreeItemRow(child) || child.depth <= row.depth) { + break + } + groupRows.push(child) + cursor++ + } + renderRows.push({ + type: 'lineage-group', + key: getLineageGroupKey(row.worktree.id), + rows: groupRows + }) + index = cursor - 1 + } + return renderRows +} + +function getRenderRowKey(row: RenderRow): string { + if (row.type === 'header') { + return `hdr:${row.key}` + } + if (row.type === 'lineage-group') { + return `lineage-group:${row.key}` + } + return `wt:${row.worktree.id}` +} + const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ rows, activeWorktreeId, groupBy, + showWorkspaceLineage, repoGroupOrdering, toggleGroup, collapsedGroups, @@ -148,6 +224,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onSelectionGesture, onContextMenuSelect, repoMap, + worktreeMap, + worktreeLineageById, repoOrder, allRepoIds, reorderRepos, @@ -169,15 +247,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCommit: reorderRepos, getScrollContainer: () => scrollRef.current }) + const renderRows = useMemo( + () => buildRenderableRows(rows, showWorkspaceLineage), + [rows, showWorkspaceLineage] + ) const activeWorktreeRowIndex = useMemo( - () => rows.findIndex((row) => row.type === 'item' && row.worktree.id === activeWorktreeId), - [rows, activeWorktreeId] + () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), + [renderRows, activeWorktreeId] ) const virtualizer = useVirtualizer({ - count: rows.length, + count: renderRows.length, getScrollElement: () => scrollRef.current, - estimateSize: () => 120, + estimateSize: (index) => { + const row = renderRows[index] + if (row?.type === 'lineage-group') { + return 104 + Math.max(0, row.rows.length - 1) * 96 + } + return 120 + }, overscan: 10, gap: 6, // Why: tells the virtualizer to start its internal scrollOffset at the @@ -187,11 +275,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // virtualizer stay aligned across remounts. initialOffset: () => scrollOffsetRef.current, getItemKey: (index) => { - const row = rows[index] + const row = renderRows[index] if (!row) { return `__stale_${index}` } - return row.type === 'header' ? `hdr:${row.key}` : `wt:${row.worktree.id}` + return getRenderRowKey(row) } }) @@ -247,6 +335,29 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp { const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktreeId) + if (targetWorktree && showWorkspaceLineage && !targetWorktree.isPinned) { + const seen = new Set() + let current: Worktree | undefined = targetWorktree + while (current && !seen.has(current.id)) { + seen.add(current.id) + const lineage = worktreeLineageById[current.id] + const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined + if ( + !lineage || + !parent || + current.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + const lineageGroupKey = getLineageGroupKey(parent.id) + if (collapsedGroups.has(lineageGroupKey)) { + toggleGroup(lineageGroupKey) + } + current = parent + } + } + if (targetWorktree?.isPinned) { // Why: pinned worktrees live in the dedicated "Pinned" section regardless // of their PR-status / repo group. Only uncollapse the Pinned header @@ -270,8 +381,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } requestAnimationFrame(() => { - const targetIndex = rows.findIndex( - (row) => row.type === 'item' && row.worktree.id === pendingRevealWorktreeId + const targetIndex = renderRows.findIndex((row) => + renderRowContainsWorktree(row, pendingRevealWorktreeId) ) if (targetIndex !== -1) { // Why: `align: 'auto'` is a no-op when the card is already visible and @@ -290,7 +401,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, repoMap, prCache, - rows, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage, + renderRows, virtualizer, clearPendingRevealWorktreeId, toggleGroup, @@ -304,12 +418,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp useLayoutEffect(() => { virtualizer.elementsCache.forEach((element) => { const idx = parseInt(element.getAttribute('data-index') ?? '', 10) - if (Number.isNaN(idx) || idx >= rows.length) { + if (Number.isNaN(idx) || idx >= renderRows.length) { return } virtualizer.measureElement(element) }) - }, [prCacheLen, issueCacheLen, virtualizer, rows.length]) + }, [prCacheLen, issueCacheLen, virtualizer, renderRows.length]) const navigateWorktree = useCallback( (direction: 'up' | 'down') => { @@ -326,7 +440,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp new Set(), repoOrder, workspaceStatuses, - repoGroupOrdering + repoGroupOrdering, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -354,13 +471,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // it must flow through the same activation helper that records history. activateAndRevealWorktree(nextWorktreeId) - const rowIndex = rows.findIndex((r) => r.type === 'item' && r.worktree.id === nextWorktreeId) + const rowIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, nextWorktreeId)) if (rowIndex !== -1) { virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } }, [ - rows, + renderRows, activeWorktreeId, virtualizer, groupBy, @@ -369,7 +486,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoMap, prCache, repoOrder, - workspaceStatuses + workspaceStatuses, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage ] ) @@ -419,7 +539,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [navigateWorktree] ) - const firstHeaderIndex = useMemo(() => rows.findIndex((r) => r.type === 'header'), [rows]) + const firstHeaderIndex = useMemo( + () => renderRows.findIndex((row) => row.type === 'header'), + [renderRows] + ) const virtualItems = virtualizer.getVirtualItems() const activeDescendantId = @@ -527,7 +650,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp /> ) : null} {virtualItems.map((vItem) => { - const row = rows[vItem.index] + const row = renderRows[vItem.index] + if (!row) { + return null + } if (row.type === 'header') { const isRepoHeader = groupBy === 'repo' && row.repo !== undefined @@ -634,7 +760,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp /> - {row.repo ? ( + {row.repo && groupBy === 'repo' ? ( + + + {child.lineageCollapsed + ? 'Show child workspaces' + : 'Hide child workspaces'} + + + + ) : null} + + + + + ) + } + + const renderWorktreeRow = ( + itemRow: WorktreeItemRow, + nested: boolean, + lineageChildren?: React.ReactNode, + forceActiveSurface = false + ) => { + const lineageToggleGroupKey = itemRow.lineageGroupKey + return ( +
0 + ? `${Math.max(0, itemRow.depth - 1) * LINEAGE_INDENT}px` + : itemRow.depth > 0 + ? `${itemRow.depth * LINEAGE_INDENT}px` + : undefined + }} + > + onContextMenuSelect(event, itemRow.worktree)} + hideRepoBadge={groupBy === 'repo'} + parentLabel={ + itemRow.depth > 0 && itemRow.lineageState === 'valid' + ? undefined + : itemRow.parentLabel + } + lineageState={itemRow.lineageState} + lineageChildCount={itemRow.lineageChildCount} + lineageCollapsed={itemRow.lineageCollapsed} + lineageChildren={lineageChildren} + onLineageToggle={ + lineageToggleGroupKey + ? (event) => { + event.preventDefault() + event.stopPropagation() + toggleGroup(lineageToggleGroupKey) + } + : undefined + } + /> +
+ ) + } + + if (row.type === 'lineage-group') { + const [parent, ...children] = row.rows + const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) + return ( +
+
+ {parent + ? renderWorktreeRow( + parent, + false, + children.length > 0 ? children.map(renderLineageChildPreview) : undefined, + childIsActive + ) + : null} +
+
+ ) + } + const itemWorkspaceStatus = groupBy === 'none' ? getWorkspaceStatus(row.worktree, workspaceStatuses) : null return (
- onContextMenuSelect(event, row.worktree)} - hideRepoBadge={groupBy === 'repo'} - /> + {renderWorktreeRow(row, false)}
) })} @@ -721,9 +1034,11 @@ const WorktreeList = React.memo(function WorktreeList() { const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const groupBy = useAppStore((s) => s.groupBy) + const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) @@ -1045,7 +1360,10 @@ const WorktreeList = React.memo(function WorktreeList() { collapsedGroups, repoOrder, workspaceStatuses, - repoGroupOrdering + repoGroupOrdering, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage ), [ groupBy, @@ -1055,7 +1373,10 @@ const WorktreeList = React.memo(function WorktreeList() { collapsedGroups, repoOrder, workspaceStatuses, - repoGroupOrdering + repoGroupOrdering, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage ] ) // Why: rows.length alone can stay the same when items migrate between @@ -1259,6 +1580,7 @@ const WorktreeList = React.memo(function WorktreeList() { rows={rows} activeWorktreeId={selectedSidebarWorktreeId} groupBy={groupBy} + showWorkspaceLineage={showWorkspaceLineage} repoGroupOrdering={repoGroupOrdering} toggleGroup={toggleGroup} collapsedGroups={collapsedGroups} @@ -1272,6 +1594,8 @@ const WorktreeList = React.memo(function WorktreeList() { onSelectionGesture={updateSelectionForGesture} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} + worktreeMap={worktreeMap} + worktreeLineageById={worktreeLineageById} repoOrder={repoOrder} allRepoIds={allRepoIds} reorderRepos={(orderedIds) => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 1dd46e3b41b..861e874abe6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1,8 +1,15 @@ +/* eslint-disable max-lines -- Why: row-builder tests keep grouping, pinning, and lineage ordering cases together so expected row contracts stay comparable. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { buildRows, getPRGroupKey, getRepoGroupOrdering } from './worktree-list-groups' -import type { Repo, Worktree } from '../../../../shared/types' +import { + buildRows, + getLineageGroupKey, + getLineageRenderInfo, + getPRGroupKey, + getRepoGroupOrdering +} from './worktree-list-groups' +import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' const repo: Repo = { id: 'repo-1', @@ -310,6 +317,237 @@ describe('getRepoGroupOrdering', () => { }) }) +describe('buildRows workspace lineage nesting', () => { + const parent: Worktree = { + ...worktree, + id: 'wt-parent', + instanceId: 'parent-instance', + displayName: 'coordinator' + } + const child: Worktree = { + ...worktree, + id: 'wt-child', + instanceId: 'child-instance', + displayName: 'worker' + } + const grandchild: Worktree = { + ...worktree, + id: 'wt-grandchild', + instanceId: 'grandchild-instance', + displayName: 'nested-worker' + } + const lineage: WorktreeLineage = { + worktreeId: child.id, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'terminal-context', confidence: 'inferred' }, + createdAt: 1 + } + const grandchildLineage: WorktreeLineage = { + worktreeId: grandchild.id, + worktreeInstanceId: 'grandchild-instance', + parentWorktreeId: child.id, + parentWorktreeInstanceId: 'child-instance', + origin: 'cli', + capture: { source: 'terminal-context', confidence: 'inferred' }, + createdAt: 1 + } + + it('keeps lineage flat when nesting is off', () => { + const rows = buildRows( + 'none', + [child, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage }, + new Map([ + [parent.id, parent], + [child.id, child] + ]) + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items[0]).toMatchObject({ type: 'item', worktree: { id: child.id } }) + expect(items[0]).not.toHaveProperty('parentLabel') + expect(items[1]).toMatchObject({ + type: 'item', + worktree: { id: parent.id } + }) + }) + + it('places children directly under their parent when nesting is on', () => { + const rows = buildRows( + 'none', + [child, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage }, + new Map([ + [parent.id, parent], + [child.id, child] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } }) + expect(items[1]).toMatchObject({ + type: 'item', + worktree: { id: child.id }, + depth: 1, + parentLabel: 'coordinator' + }) + }) + + it('supports nested lineage chains beyond one level', () => { + const rows = buildRows( + 'none', + [grandchild, child, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage, [grandchild.id]: grandchildLineage }, + new Map([ + [parent.id, parent], + [child.id, child], + [grandchild.id, grandchild] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items.map((row) => row.worktree.id)).toEqual([parent.id, child.id, grandchild.id]) + expect(items[0]).toMatchObject({ + type: 'item', + depth: 0, + lineageChildCount: 1, + lineageCollapsed: false + }) + expect(items[1]).toMatchObject({ + type: 'item', + worktree: { id: child.id }, + depth: 1, + lineageChildCount: 1 + }) + expect(items[2]).toMatchObject({ + type: 'item', + worktree: { id: grandchild.id }, + depth: 2, + lineageChildCount: 0 + }) + }) + + it('collapses descendants under lineage parents', () => { + const rows = buildRows( + 'none', + [grandchild, child, parent], + repoMap, + null, + new Set([getLineageGroupKey(parent.id)]), + undefined, + undefined, + undefined, + { [child.id]: lineage, [grandchild.id]: grandchildLineage }, + new Map([ + [parent.id, parent], + [child.id, child], + [grandchild.id, grandchild] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + type: 'item', + worktree: { id: parent.id }, + lineageChildCount: 1, + lineageCollapsed: true + }) + }) + + it('marks stale instance links as missing without creating a parent group', () => { + const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' } + const rows = buildRows( + 'none', + [child], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: staleLineage }, + new Map([ + [parent.id, parent], + [child.id, child] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items[0]).toMatchObject({ + type: 'item', + worktree: { id: child.id }, + lineageState: 'missing' + }) + }) + + it('marks stale instance links as missing for shared context-menu validation', () => { + const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' } + const info = getLineageRenderInfo( + child, + { [child.id]: staleLineage }, + new Map([ + [parent.id, parent], + [child.id, child] + ]) + ) + + expect(info).toMatchObject({ state: 'missing' }) + }) + + it('keeps pinned children in Pinned with parent context', () => { + const pinnedChild = { ...child, isPinned: true } + const rows = buildRows( + 'none', + [parent, pinnedChild], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage }, + new Map([ + [parent.id, parent], + [child.id, pinnedChild] + ]), + true + ) + + expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) + expect(rows[1]).toMatchObject({ + type: 'item', + worktree: { id: child.id }, + parentLabel: 'coordinator' + }) + }) +}) + describe('WorktreeList header styles', () => { it('does not title-case workspace group labels', () => { const source = readFileSync( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index fd292eadf9c..0bbb191ba8c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1,6 +1,12 @@ +/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */ import { CircleCheckBig, CircleDot, CircleX, Folder, GitPullRequest, Pin } from 'lucide-react' import type React from 'react' -import type { Repo, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' +import type { + Repo, + Worktree, + WorktreeLineage, + WorkspaceStatusDefinition +} from '../../../../shared/types' import { branchName } from '@/lib/git-utils' import { getWorkspaceStatus, @@ -32,7 +38,19 @@ export type GroupHeaderRow = { repo?: Repo } -export type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined } +export type WorktreeRow = { + type: 'item' + worktree: Worktree + repo: Repo | undefined + depth: number + lineageTrail: boolean[] + isLastLineageChild: boolean + lineageChildCount: number + lineageGroupKey?: string + lineageCollapsed?: boolean + parentLabel?: string + lineageState?: 'valid' | 'missing' +} export type Row = GroupHeaderRow | WorktreeRow export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' @@ -82,6 +100,40 @@ export const PINNED_GROUP_META = { icon: Pin } as const +export const MISSING_PARENT_GROUP_META = { + label: 'Missing parent' +} as const + +export const LINEAGE_GROUP_PREFIX = 'lineage:' + +export function getLineageGroupKey(worktreeId: string): string { + return `${LINEAGE_GROUP_PREFIX}${worktreeId}` +} + +export type LineageRenderInfo = + | { state: 'none' } + | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } + | { state: 'missing'; lineage: WorktreeLineage } + +export function getLineageRenderInfo( + worktree: Worktree, + lineageById: Record, + worktreeMap: Map +): LineageRenderInfo { + const lineage = lineageById[worktree.id] + if (!lineage) { + return { state: 'none' } + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + if ( + !parent || + worktree.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + return { state: 'missing', lineage } + } + return { state: 'valid', lineage, parent } +} export function getPRGroupKey( worktree: Worktree, repoMap: Map, @@ -118,8 +170,11 @@ export function getPRGroupKey( function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, + lineageById: Record, + worktreeMap: Map, collapsedGroups: Set, result: Row[], + showLineageContext: boolean, force = false ): Set { const pinned = worktrees.filter((w) => w.isPinned) @@ -136,13 +191,151 @@ function emitPinnedGroup( icon: PINNED_GROUP_META.icon }) if (!collapsedGroups.has(PINNED_GROUP_KEY)) { - for (const w of pinned) { - result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) }) - } + appendWorktreeRows(result, pinned, repoMap, lineageById, worktreeMap, { + nestLineage: false, + showLineageContext, + collapsedGroups + }) } return new Set(pinned.map((w) => w.id)) } +function buildWorktreeRow( + worktree: Worktree, + repoMap: Map, + lineageById: Record, + worktreeMap: Map, + showLineageContext: boolean, + depth: number, + lineageTrail: boolean[], + isLastLineageChild: boolean, + lineageChildCount: number, + lineageCollapsed: boolean +): WorktreeRow { + const lineage = showLineageContext + ? getLineageRenderInfo(worktree, lineageById, worktreeMap) + : { state: 'none' as const } + return { + type: 'item', + worktree, + repo: repoMap.get(worktree.repoId), + depth, + lineageTrail, + isLastLineageChild, + lineageChildCount, + ...(lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}), + ...(lineageChildCount > 0 ? { lineageCollapsed } : {}), + ...(lineage.state === 'valid' + ? { parentLabel: lineage.parent.displayName, lineageState: 'valid' as const } + : lineage.state === 'missing' + ? { parentLabel: MISSING_PARENT_GROUP_META.label, lineageState: 'missing' as const } + : {}) + } +} + +function appendWorktreeRows( + result: Row[], + worktrees: Worktree[], + repoMap: Map, + lineageById: Record, + worktreeMap: Map, + options: { + nestLineage: boolean + showLineageContext: boolean + collapsedGroups: Set + } +): void { + const { nestLineage, showLineageContext, collapsedGroups } = options + if (!nestLineage) { + for (const worktree of worktrees) { + result.push( + buildWorktreeRow( + worktree, + repoMap, + lineageById, + worktreeMap, + showLineageContext, + 0, + [], + false, + 0, + false + ) + ) + } + return + } + + const visibleIds = new Set(worktrees.map((worktree) => worktree.id)) + const childrenByParentId = new Map() + const childIds = new Set() + for (const worktree of worktrees) { + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap) + if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) { + continue + } + childIds.add(worktree.id) + const children = childrenByParentId.get(lineage.parent.id) ?? [] + children.push(worktree) + childrenByParentId.set(lineage.parent.id, children) + } + + const emitted = new Set() + const emit = ( + worktree: Worktree, + depth: number, + lineageTrail: boolean[], + isLastChild: boolean + ): void => { + if (emitted.has(worktree.id)) { + return + } + const children = childrenByParentId.get(worktree.id) ?? [] + const lineageGroupKey = getLineageGroupKey(worktree.id) + const lineageCollapsed = collapsedGroups.has(lineageGroupKey) + emitted.add(worktree.id) + result.push( + buildWorktreeRow( + worktree, + repoMap, + lineageById, + worktreeMap, + showLineageContext, + depth, + lineageTrail, + isLastChild, + children.length, + lineageCollapsed + ) + ) + if (lineageCollapsed) { + return + } + children.forEach((child, index) => { + emit( + child, + depth + 1, + [...lineageTrail, index < children.length - 1], + index === children.length - 1 + ) + }) + } + + const roots = worktrees.filter((worktree) => !childIds.has(worktree.id)) + for (const [index, worktree] of roots.entries()) { + emit(worktree, 0, [], index === roots.length - 1) + } + if (roots.length === 0) { + for (const worktree of worktrees) { + if (!emitted.has(worktree.id)) { + // Why: malformed cyclic lineage should not hide every participant. + // Render any leftovers as roots rather than recursing forever. + emit(worktree, 0, [], true) + } + } + } +} + /** * Build the flat row list consumed by the virtualizer. * Extracted here to keep WorktreeList.tsx under the line-count lint limit. @@ -155,11 +348,25 @@ export function buildRows( collapsedGroups: Set, repoOrder?: Map, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - repoGroupOrdering: RepoGroupOrdering = 'manual' + repoGroupOrdering: RepoGroupOrdering = 'manual', + lineageById: Record = {}, + worktreeMap: Map = new Map( + worktrees.map((worktree) => [worktree.id, worktree]) + ), + nestLineage = false ): Row[] { const result: Row[] = [] - const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result, groupBy === 'none') + const pinnedIds = emitPinnedGroup( + worktrees, + repoMap, + lineageById, + worktreeMap, + collapsedGroups, + result, + nestLineage, + groupBy === 'none' + ) const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees const grouped = new Map() @@ -276,9 +483,11 @@ export function buildRows( result.push(header) if (!isCollapsed) { - for (const w of group.items) { - result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) }) - } + appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, { + nestLineage, + showLineageContext: nestLineage, + collapsedGroups + }) } } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 6f1d69bc90f..172094ce5e2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -79,6 +79,7 @@ async function prepareRemoteWorkspaceTarget(targetId: string): Promise repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId) } await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) + await useAppStore.getState().fetchWorktreeLineage() return true } @@ -352,6 +353,7 @@ export function useIpcEvents(): void { const state = useAppStore.getState() const before = new Set((state.worktreesByRepo[data.repoId] ?? []).map((w) => w.id)) await state.fetchWorktrees(data.repoId) + await useAppStore.getState().fetchWorktreeLineage() const afterState = useAppStore.getState() const after = new Set((afterState.worktreesByRepo[data.repoId] ?? []).map((w) => w.id)) const removed: string[] = [] @@ -1297,7 +1299,8 @@ export function useIpcEvents(): void { // lets it detect that providers are now available and retry. store.bumpSshConnectedGeneration() - void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(() => { + void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { + await useAppStore.getState().fetchWorktreeLineage() // Why: terminal panes that failed to spawn (no PTY provider on cold // start) sit inert. Bumping generation forces TerminalPane to remount // and retry pty:spawn. Only bump tabs with no live ptyId. diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 6923fa3339d..c5d5e8c08c0 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -14,7 +14,12 @@ import { import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder' import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle' import { shouldFollowMouseFocus } from './focus-follows-mouse' -import { equalizePaneSplitSizes, safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops' +import { + equalizePaneSplitSizes, + safeFit, + fitAllPanesInternal, + refitPanesUnder +} from './pane-tree-ops' import { toPublicPane } from './pane-public-view' import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' import { diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 28fb39334e1..5d276491a40 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -35,6 +35,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState sidebarWidth: 280, rightSidebarWidth: 350, groupBy: 'none', + showWorkspaceLineage: false, sortBy: 'name', showActiveOnly: false, hideDefaultBranchWorkspace: false, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 9d0bc5e5476..0b762ef5e40 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { AppState } from '../types' +import type { WorktreeLineage } from '../../../../shared/types' import { toast } from 'sonner' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -20,6 +21,16 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const runtimeEnvironmentCall = vi.fn() const settingsSet = vi.fn().mockResolvedValue(undefined) +const env2Lineage: WorktreeLineage = { + worktreeId: 'repo-env-2::/env-2/repo', + worktreeInstanceId: 'env-2-instance', + parentWorktreeId: 'repo-env-2::/env-2/parent', + parentWorktreeInstanceId: 'env-2-parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 +} + beforeEach(() => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() @@ -58,7 +69,9 @@ beforeEach(() => { } : method === 'browser.profile.list' ? { profiles: [] } - : {} + : method === 'worktree.lineageList' + ? { lineage: { [env2Lineage.worktreeId]: env2Lineage } } + : {} return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) }) vi.stubGlobal('window', { @@ -78,6 +91,13 @@ describe('createSettingsSlice runtime switching', () => { worktreesByRepo: { 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] }, + worktreeLineageById: { + 'repo-env-1::/env-1/repo': { + ...env2Lineage, + worktreeId: 'repo-env-1::/env-1/repo', + parentWorktreeId: 'repo-env-1::/env-1/parent' + } + }, activeWorktreeId: 'repo-env-1::/env-1/repo', openFiles: [{ id: '/env-1/repo/a.md', worktreeId: 'repo-env-1::/env-1/repo' } as never], ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] }, @@ -113,6 +133,9 @@ describe('createSettingsSlice runtime switching', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'worktree.lineageList' }) + ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', @@ -138,6 +161,9 @@ describe('createSettingsSlice runtime switching', () => { expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([ 'repo-env-2::/env-2/repo' ]) + expect(store.getState().worktreeLineageById).toEqual({ + [env2Lineage.worktreeId]: env2Lineage + }) expect(store.getState().activeWorktreeId).toBeNull() expect(store.getState().openFiles).toEqual([]) expect(store.getState().editorDrafts).toEqual({}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 4a30c09f905..629b76db5f8 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -33,6 +33,7 @@ function runtimeScopedStateReset(): Partial { sparsePresetsLoadStatusByRepo: {}, sparsePresetsErrorByRepo: {}, worktreesByRepo: {}, + worktreeLineageById: {}, activeWorktreeId: null, deleteStateByWorktreeId: {}, baseStatusByWorktreeId: {}, @@ -303,6 +304,7 @@ export const createSettingsSlice: StateCreator // while the new environment is loading. await get().fetchRepos() await get().fetchAllWorktrees() + await get().fetchWorktreeLineage() await get().fetchBrowserSessionProfiles() return true } catch (err) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a24fe6aa507..bf4fdd0b092 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -348,6 +348,8 @@ export type UISlice = { clearOrcaHookTrustForRepo: (repoId: string) => void groupBy: 'none' | 'repo' | 'pr-status' setGroupBy: (g: UISlice['groupBy']) => void + showWorkspaceLineage: boolean + setShowWorkspaceLineage: (v: boolean) => void sortBy: 'name' | 'smart' | 'recent' | 'repo' setSortBy: (s: UISlice['sortBy']) => void showActiveOnly: boolean @@ -702,6 +704,9 @@ export const createUISlice: StateCreator = (set, get) set({ groupBy: g, collapsedGroups: new Set() }) }, + showWorkspaceLineage: false, + setShowWorkspaceLineage: (v) => set({ showWorkspaceLineage: v }), + sortBy: 'recent', setSortBy: (s) => set({ sortBy: s }), @@ -886,7 +891,8 @@ export const createUISlice: StateCreator = (set, get) s.rightSidebarWidth, MAX_RIGHT_SIDEBAR_WIDTH ), - groupBy: ui.groupBy, + groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, + showWorkspaceLineage: ui.showWorkspaceLineage ?? false, sortBy, // Why: "Active only" is part of the user's sidebar working set, not a // transient render detail. Restoring it on launch keeps the filtered diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 508daa7e65d..0b4e2bafffb 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -7,6 +7,7 @@ import type { WorkspaceCreateTelemetrySource, Worktree, WorktreeBaseStatusEvent, + WorktreeLineage, WorktreeRemoteBranchConflictEvent, WorktreeMeta } from '../../../../shared/types' @@ -20,6 +21,7 @@ export type WorktreeDeleteState = { export type WorktreeSlice = { worktreesByRepo: Record + worktreeLineageById: Record activeWorktreeId: string | null deleteStateByWorktreeId: Record baseStatusByWorktreeId: Record @@ -62,6 +64,11 @@ export type WorktreeSlice = { hasHydratedWorktreePurge: boolean fetchWorktrees: (repoId: string) => Promise fetchAllWorktrees: () => Promise + fetchWorktreeLineage: () => Promise + updateWorktreeLineage: ( + worktreeId: string, + args: { parentWorktreeId?: string; noParent?: boolean } + ) => Promise createWorktree: ( repoId: string, name: string, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index d89031ac1af..e06d23c6826 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -19,8 +19,10 @@ const mockApi = { worktrees: { create: vi.fn(), list: vi.fn().mockResolvedValue([]), + listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), - updateMeta: vi.fn().mockResolvedValue(undefined) + updateMeta: vi.fn().mockResolvedValue(undefined), + updateLineage: vi.fn().mockResolvedValue(null) }, pty: { kill: vi.fn().mockResolvedValue(undefined) @@ -115,6 +117,19 @@ function makeWorktree(overrides: Partial & { id: string; repoId: strin } } +function makeLineage(overrides: Partial = {}): WorktreeLineage { + return { + worktreeId: 'repo1::/path/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo1::/path/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1, + ...overrides + } +} + describe('fetchWorktrees', () => { beforeEach(() => { vi.clearAllMocks() @@ -241,6 +256,289 @@ describe('fetchWorktrees', () => { }) expect(mockApi.worktrees.list).not.toHaveBeenCalled() }) + + it('updates remote worktree records when only lineage changes', async () => { + const store = createTestStore() + const initial = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote' + }) + const lineage = makeLineage({ worktreeId: initial.id }) + const refreshed = { ...initial, lineage } + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [initial] }, + sortEpoch: 7 + } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + const result = + method === 'worktree.lineageList' + ? { lineage: { [lineage.worktreeId]: lineage } } + : { worktrees: [refreshed], totalCount: 1, truncated: false } + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed]) + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + expect(store.getState().sortEpoch).toBe(8) + }) + + it('refreshes remote lineage when the worktree payload is otherwise unchanged', async () => { + const store = createTestStore() + const worktree = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote' + }) + const staleLineage = makeLineage({ + worktreeId: worktree.id, + parentWorktreeId: 'repo1::/remote/old-parent' + }) + const freshLineage = makeLineage({ + worktreeId: worktree.id, + parentWorktreeId: 'repo1::/remote/new-parent' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [worktree] }, + worktreeLineageById: { [worktree.id]: staleLineage }, + sortEpoch: 7 + } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + const result = + method === 'worktree.lineageList' + ? { lineage: { [freshLineage.worktreeId]: freshLineage } } + : { worktrees: [worktree], totalCount: 1, truncated: false } + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([worktree]) + expect(store.getState().worktreeLineageById).toEqual({ + [freshLineage.worktreeId]: freshLineage + }) + expect(store.getState().sortEpoch).toBe(7) + }) + + it('keeps a successful remote worktree refresh when lineage refresh fails', async () => { + const store = createTestStore() + const refreshed = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote' + }) + const staleLineage = makeLineage({ worktreeId: refreshed.id }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: {}, + worktreeLineageById: { [staleLineage.worktreeId]: staleLineage }, + sortEpoch: 7 + } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + if (method === 'worktree.lineageList') { + return Promise.reject(new Error('lineage timeout')) + } + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result: { worktrees: [refreshed], totalCount: 1, truncated: false }, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed]) + expect(store.getState().worktreeLineageById).toEqual({ + [staleLineage.worktreeId]: staleLineage + }) + expect(store.getState().sortEpoch).toBe(8) + }) +}) + +describe('worktree lineage state', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('fetches persisted lineage into the renderer store', async () => { + const store = createTestStore() + const lineage = makeLineage() + mockApi.worktrees.listLineage.mockResolvedValue({ [lineage.worktreeId]: lineage }) + + await store.getState().fetchWorktreeLineage() + + expect(mockApi.worktrees.listLineage).toHaveBeenCalled() + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + }) + + it('updates a child lineage entry and bumps sortEpoch', async () => { + const store = createTestStore() + const lineage = makeLineage() + mockApi.worktrees.updateLineage.mockResolvedValue(lineage) + store.setState({ sortEpoch: 3 } as Partial) + + await store.getState().updateWorktreeLineage(lineage.worktreeId, { + parentWorktreeId: lineage.parentWorktreeId + }) + + expect(mockApi.worktrees.updateLineage).toHaveBeenCalledWith({ + worktreeId: lineage.worktreeId, + parentWorktreeId: lineage.parentWorktreeId + }) + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + expect(store.getState().sortEpoch).toBe(4) + }) + + it('removes a child lineage entry when the backend clears the parent link', async () => { + const store = createTestStore() + const lineage = makeLineage() + mockApi.worktrees.updateLineage.mockResolvedValue(null) + store.setState({ + worktreeLineageById: { [lineage.worktreeId]: lineage }, + sortEpoch: 3 + } as Partial) + + await store.getState().updateWorktreeLineage(lineage.worktreeId, { noParent: true }) + + expect(store.getState().worktreeLineageById).toEqual({}) + expect(store.getState().sortEpoch).toBe(4) + }) + + it('refetches lineage after an update failure', async () => { + const store = createTestStore() + const lineage = makeLineage() + mockApi.worktrees.updateLineage.mockRejectedValueOnce(new Error('stale parent')) + mockApi.worktrees.listLineage.mockResolvedValue({ [lineage.worktreeId]: lineage }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await store.getState().updateWorktreeLineage(lineage.worktreeId, { + parentWorktreeId: lineage.parentWorktreeId + }) + + expect(mockApi.worktrees.listLineage).toHaveBeenCalled() + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + }) + + it('fetches raw lineage from the active remote runtime environment', async () => { + const store = createTestStore() + const lineage = makeLineage() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-lineage-list', + ok: true, + result: { lineage: { [lineage.worktreeId]: lineage } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: {} + } as Partial) + + await store.getState().fetchWorktreeLineage() + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.lineageList', + params: undefined, + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.listLineage).not.toHaveBeenCalled() + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + }) + + it('updates lineage through the active remote runtime environment', async () => { + const store = createTestStore() + const lineage = makeLineage() + const child = makeWorktree({ + id: lineage.worktreeId, + repoId: 'repo1', + path: '/remote/child' + }) + const updatedChild = { ...child, lineage } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set-lineage', + ok: true, + result: { worktree: updatedChild }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [child] }, + sortEpoch: 3 + } as Partial) + + await store.getState().updateWorktreeLineage(lineage.worktreeId, { + parentWorktreeId: lineage.parentWorktreeId + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: { + worktree: lineage.worktreeId, + parentWorktree: `id:${lineage.parentWorktreeId}` + }, + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.updateLineage).not.toHaveBeenCalled() + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + expect(store.getState().worktreesByRepo.repo1?.[0]).toEqual(updatedChild) + expect(store.getState().sortEpoch).toBe(4) + }) + + it('clears lineage through the active remote runtime environment', async () => { + const store = createTestStore() + const lineage = makeLineage() + const child = makeWorktree({ + id: lineage.worktreeId, + repoId: 'repo1', + path: '/remote/child', + lineage + } as Partial & { id: string; repoId: string }) + const updatedChild = { ...child, lineage: null } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-clear-lineage', + ok: true, + result: { worktree: updatedChild }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [child] }, + worktreeLineageById: { [lineage.worktreeId]: lineage }, + sortEpoch: 3 + } as Partial) + + await store.getState().updateWorktreeLineage(lineage.worktreeId, { noParent: true }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: { worktree: lineage.worktreeId, noParent: true }, + timeoutMs: 15_000 + }) + expect(store.getState().worktreeLineageById).toEqual({}) + expect(store.getState().worktreesByRepo.repo1?.[0]).toEqual(updatedChild) + }) }) describe('updateWorktreeGitIdentity', () => { @@ -402,6 +700,30 @@ describe('removeWorktree state cleanup', () => { }) }) + it('cleans up the removed worktree lineage entry', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + const childLineage = makeLineage({ worktreeId: wt.id }) + const siblingLineage = makeLineage({ + worktreeId: 'repo1::/path/wt2', + worktreeInstanceId: 'sibling-instance' + }) + + store.setState({ + worktreesByRepo: { repo1: [wt] }, + worktreeLineageById: { + [wt.id]: childLineage, + 'repo1::/path/wt2': siblingLineage + } + } as Partial) + + await store.getState().removeWorktree(wt.id) + + expect(store.getState().worktreeLineageById).toEqual({ + 'repo1::/path/wt2': siblingLineage + }) + }) + it('cleans up markdownViewMode for files in the removed worktree', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) @@ -1030,6 +1352,10 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { ], editorDrafts: { 'file-1': 'draft', 'file-99': 'other' }, activeWorktreeId: 'repoA::/a/wt1', + worktreeLineageById: { + 'repoA::/a/wt1': makeLineage({ worktreeId: 'repoA::/a/wt1' }), + 'repoA::/a/wt2': makeLineage({ worktreeId: 'repoA::/a/wt2' }) + }, activeFileId: 'file-1', activeTabId: 'tab-1', activeTabType: 'editor' as const @@ -1041,6 +1367,9 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { expect(s.tabsByWorktree).toEqual({ 'repoA::/a/wt2': [{ id: 'tab-3', worktreeId: 'repoA::/a/wt2' }] }) + expect(s.worktreeLineageById).toEqual({ + 'repoA::/a/wt2': makeLineage({ worktreeId: 'repoA::/a/wt2' }) + }) expect(s.terminalLayoutsByTabId).toEqual({ 'tab-3': { panes: [] } }) expect(s.ptyIdsByTabId).toEqual({ 'tab-3': ['pty-3'] }) expect(s.runtimePaneTitlesByTabId).toEqual({ 'tab-3': 'bash' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 53ae531c8ab..7912964c2d6 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { Worktree, WorkspaceVisibleTabType, WorktreeMeta } from '../../../../shared/types' +import type { + Worktree, + WorkspaceVisibleTabType, + WorktreeLineage, + WorktreeMeta +} from '../../../../shared/types' import { findWorktreeById, applyWorktreeUpdates, @@ -23,6 +28,29 @@ function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): b return a.every((v, i) => v === b[i]) } +function areLineageRecordsEqual( + a: WorktreeLineage | null | undefined, + b: WorktreeLineage | null | undefined +): boolean { + if (!a || !b) { + return !a && !b + } + return ( + a.worktreeId === b.worktreeId && + a.worktreeInstanceId === b.worktreeInstanceId && + a.parentWorktreeId === b.parentWorktreeId && + a.parentWorktreeInstanceId === b.parentWorktreeInstanceId && + a.origin === b.origin && + a.capture.source === b.capture.source && + a.capture.confidence === b.capture.confidence && + a.orchestrationRunId === b.orchestrationRunId && + a.taskId === b.taskId && + a.coordinatorHandle === b.coordinatorHandle && + a.createdByTerminalHandle === b.createdByTerminalHandle && + a.createdAt === b.createdAt + ) +} + function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean { if (!current || current.length !== next.length) { return false @@ -32,6 +60,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b const candidate = next[index] return ( worktree.id === candidate.id && + worktree.instanceId === candidate.instanceId && worktree.repoId === candidate.repoId && worktree.path === candidate.path && worktree.head === candidate.head && @@ -55,7 +84,17 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b worktree.pushTarget?.branchName === candidate.pushTarget?.branchName && worktree.pushTarget?.remoteUrl === candidate.pushTarget?.remoteUrl && worktree.sparseBaseRef === candidate.sparseBaseRef && - arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories) + arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories) && + (worktree as WorktreeWithLineage).parentWorktreeId === + (candidate as WorktreeWithLineage).parentWorktreeId && + arraysShallowEqual( + (worktree as WorktreeWithLineage).childWorktreeIds, + (candidate as WorktreeWithLineage).childWorktreeIds + ) && + areLineageRecordsEqual( + (worktree as WorktreeWithLineage).lineage, + (candidate as WorktreeWithLineage).lineage + ) ) }) } @@ -64,6 +103,29 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor' } +type WorktreeWithLineage = Worktree & { + parentWorktreeId?: string | null + childWorktreeIds?: string[] + lineage?: WorktreeLineage | null +} + +function replaceWorktreeInRepoLists( + worktreesByRepo: Record, + updatedWorktree: Worktree +): Record { + const repoId = getRepoIdFromWorktreeId(updatedWorktree.id) + const current = worktreesByRepo[repoId] + if (!current) { + return worktreesByRepo + } + return { + ...worktreesByRepo, + [repoId]: current.map((worktree) => + worktree.id === updatedWorktree.id ? updatedWorktree : worktree + ) + } +} + async function listWorktreesForRepo( settings: AppState['settings'], repoId: string @@ -83,6 +145,39 @@ async function listWorktreesForRepo( return result.worktrees } +async function listWorktreeLineageForRuntime( + settings: AppState['settings'] +): Promise> { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + return window.api.worktrees.listLineage() + } + return ( + await callRuntimeRpc<{ lineage: Record }>( + target, + 'worktree.lineageList', + undefined, + { timeoutMs: 15_000 } + ) + ).lineage +} + +async function refreshRemoteWorktreeLineageBestEffort( + settings: AppState['settings'], + set: (partial: Partial) => void +): Promise { + if (getActiveRuntimeTarget(settings).kind === 'local') { + return + } + try { + set({ worktreeLineageById: await listWorktreeLineageForRuntime(settings) }) + } catch (err) { + // Why: lineage is supplemental to the worktree list. A remote timeout here + // must not discard a successful worktree refresh. + console.error('Failed to fetch worktree lineage:', err) + } +} + async function persistWorktreeMeta( settings: AppState['settings'], worktreeId: string, @@ -103,6 +198,7 @@ async function persistWorktreeMeta( export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, + worktreeLineageById: {}, activeWorktreeId: null, deleteStateByWorktreeId: {}, baseStatusByWorktreeId: {}, @@ -114,9 +210,11 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId) => { try { - const worktrees = await listWorktreesForRepo(get().settings, repoId) + const settings = get().settings + const worktrees = await listWorktreesForRepo(settings, repoId) const current = get().worktreesByRepo[repoId] if (areWorktreesEqual(current, worktrees)) { + await refreshRemoteWorktreeLineageBestEffort(settings, set) return } @@ -138,6 +236,7 @@ export const createWorktreeSlice: StateCreator worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }, sortEpoch: s.sortEpoch + 1 })) + await refreshRemoteWorktreeLineageBestEffort(settings, set) } catch (err) { console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) } @@ -209,6 +308,56 @@ export const createWorktreeSlice: StateCreator set({ hasHydratedWorktreePurge: true }) }, + fetchWorktreeLineage: async () => { + try { + set({ worktreeLineageById: await listWorktreeLineageForRuntime(get().settings) }) + } catch (err) { + console.error('Failed to fetch worktree lineage:', err) + } + }, + + updateWorktreeLineage: async (worktreeId, args) => { + try { + const target = getActiveRuntimeTarget(get().settings) + let updatedRemoteWorktree: WorktreeWithLineage | undefined + const lineage = + target.kind === 'local' + ? await window.api.worktrees.updateLineage({ worktreeId, ...args }) + : await callRuntimeRpc<{ worktree: WorktreeWithLineage }>( + target, + 'worktree.set', + { + worktree: worktreeId, + ...(args.parentWorktreeId ? { parentWorktree: `id:${args.parentWorktreeId}` } : {}), + ...(args.noParent === true ? { noParent: true } : {}) + }, + { timeoutMs: 15_000 } + ).then((result) => { + updatedRemoteWorktree = result.worktree + return result.worktree.lineage ?? null + }) + set((s) => { + const next = { ...s.worktreeLineageById } + if (lineage) { + next[worktreeId] = lineage + } else { + delete next[worktreeId] + } + return { + worktreeLineageById: next, + worktreesByRepo: + target.kind === 'local' || !updatedRemoteWorktree + ? s.worktreesByRepo + : replaceWorktreeInRepoLists(s.worktreesByRepo, updatedRemoteWorktree), + sortEpoch: s.sortEpoch + 1 + } + }) + } catch (err) { + console.error('Failed to update worktree lineage:', err) + await get().fetchWorktreeLineage() + } + }, + updateWorktreeGitIdentity: (worktreeId, identity) => { set((s) => { const repoId = getRepoIdFromWorktreeId(worktreeId) @@ -424,6 +573,8 @@ export const createWorktreeSlice: StateCreator } const nextDeleteState = { ...s.deleteStateByWorktreeId } delete nextDeleteState[worktreeId] + const nextLineage = { ...s.worktreeLineageById } + delete nextLineage[worktreeId] // Clean up editor files belonging to this worktree const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId) const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } @@ -521,6 +672,7 @@ export const createWorktreeSlice: StateCreator : s.lastVisitedAtByWorktreeId return { worktreesByRepo: next, + worktreeLineageById: nextLineage, tabsByWorktree: nextTabs, ptyIdsByTabId: nextPtyIdsByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, @@ -1108,6 +1260,7 @@ export const createWorktreeSlice: StateCreator return { // Worktree-scoped terminal/tab state + worktreeLineageById: omitByWorktree(s.worktreeLineageById), tabsByWorktree: omitByWorktree(s.tabsByWorktree), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 172742c0b21..5e98aa3e6ae 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -11,7 +11,8 @@ import type { Repo, SearchResult, StatsSummary, - Worktree + Worktree, + WorktreeLineage } from '../../../shared/types' import { getDefaultOnboardingState, @@ -323,6 +324,23 @@ function createWorktreesApi(): NonNullable['worktrees']> { ...updates }) ).worktree, + listLineage: async () => + ( + await callRuntimeResult<{ lineage: Record }>( + 'worktree.lineageList' + ) + ).lineage, + updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { + cachedWorktrees = null + const result = await callRuntimeResult<{ + worktree: Worktree & { lineage?: WorktreeLineage | null } + }>('worktree.set', { + worktree: worktreeId, + parentWorktree: parentWorktreeId, + noParent + }) + return result.worktree.lineage ?? null + }, persistSortOrder: async ({ orderedIds }) => { await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d451336ecef..f007b726f31 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -315,6 +315,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { repos: [], sparsePresetsByRepo: {}, worktreeMeta: {}, + worktreeLineageById: {}, settings: getDefaultSettings(homedir), ui: getDefaultUIState(), githubCache: { pr: {}, issue: {} }, @@ -336,6 +337,7 @@ export function getDefaultUIState(): PersistedUIState { sidebarWidth: 280, rightSidebarWidth: 350, groupBy: 'repo', + showWorkspaceLineage: false, sortBy: 'recent', showActiveOnly: false, hideDefaultBranchWorkspace: false, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6d89e8d79fb..9afa5e14211 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -1,13 +1,15 @@ /* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */ -import type { TerminalPaneLayoutNode } from './types' import type { BrowserCookieImportResult, BrowserSessionProfile, BrowserSessionProfileSource, GitWorktreeInfo, Repo, - Worktree + Worktree, + WorktreeLineage, + WorktreeLineageWarning } from './types' +import type { TerminalPaneLayoutNode } from './types' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult @@ -304,6 +306,8 @@ export type RuntimeWorktreePsSummary = { repo: string path: string branch: string + parentWorktreeId: string | null + childWorktreeIds: string[] displayName: string linkedIssue: number | null linkedPR: { number: number; state: string } | null @@ -319,9 +323,19 @@ export type RuntimeWorktreePsSummary = { export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' export type RuntimeWorktreeRecord = Worktree & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null git: GitWorktreeInfo } +export type RuntimeWorktreeCreateResult = { + worktree: RuntimeWorktreeRecord + lineage: WorktreeLineage | null + warnings: WorktreeLineageWarning[] + warning?: string +} + export type RuntimeWorktreePsResult = { worktrees: RuntimeWorktreePsSummary[] totalCount: number diff --git a/src/shared/types.ts b/src/shared/types.ts index b85faf388a1..00e72770bcb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -121,6 +121,7 @@ export type WorkspaceStatusDefinition = { export type Worktree = { id: string // `${repoId}::${path}` + instanceId?: string repoId: string displayName: string comment: string @@ -172,6 +173,8 @@ export type GitPushTarget = { // ─── Worktree metadata (persisted user-authored fields only) ───────── export type WorktreeMeta = { + /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ + instanceId?: string displayName: string comment: string linkedIssue: number | null @@ -202,6 +205,45 @@ export type WorktreeMeta = { diffComments?: DiffComment[] } +export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual' +export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred' +export type WorktreeLineageCaptureSource = + | 'explicit-cli-flag' + | 'cwd-context' + | 'terminal-context' + | 'orchestration-context' + | 'manual-action' + +export type WorktreeLineageCapture = { + source: WorktreeLineageCaptureSource + confidence: WorktreeLineageCaptureConfidence +} + +export type WorktreeLineage = { + worktreeId: string + worktreeInstanceId: string + parentWorktreeId: string + parentWorktreeInstanceId: string + origin: WorktreeLineageOrigin + capture: WorktreeLineageCapture + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + createdAt: number +} + +export type WorktreeLineageWarningCode = + | 'LINEAGE_PARENT_CONTEXT_MISSING' + | 'LINEAGE_PARENT_CONTEXT_CONFLICT' + | 'LINEAGE_PARENT_INSTANCE_STALE' + +export type WorktreeLineageWarning = { + code: WorktreeLineageWarningCode + message: string + details?: Record +} + // ─── Diff line comments ────────────────────────────────────────────── // Why: users leave review notes on specific lines of the modified side of // a diff so they can be handed back to an AI agent (pasted into a terminal @@ -988,7 +1030,14 @@ export type CreateWorktreeArgs = { } export type CreateWorktreeResult = { - worktree: Worktree + worktree: Worktree & { + parentWorktreeId?: string | null + childWorktreeIds?: string[] + lineage?: WorktreeLineage | null + git?: GitWorktreeInfo + } + lineage?: WorktreeLineage | null + warnings?: WorktreeLineageWarning[] setup?: WorktreeSetupLaunch warning?: string initialBaseStatus?: WorktreeBaseStatusEvent @@ -1621,6 +1670,7 @@ export type PersistedUIState = { sidebarWidth: number rightSidebarWidth: number groupBy: 'none' | 'repo' | 'pr-status' + showWorkspaceLineage?: boolean sortBy: 'name' | 'smart' | 'recent' | 'repo' showActiveOnly: boolean /** Hide the repo's original checked-out branch from workspace navigation @@ -1819,6 +1869,7 @@ export type PersistedState = { * presets are managed from the new-workspace composer and repo settings. */ sparsePresetsByRepo: Record worktreeMeta: Record + worktreeLineageById: Record settings: GlobalSettings ui: PersistedUIState githubCache: { diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts new file mode 100644 index 00000000000..201965723b8 --- /dev/null +++ b/tests/e2e/worktree-lineage.spec.ts @@ -0,0 +1,127 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +type LineageScenario = { + parentId: string + childId: string +} + +function worktreeOption(page: Page, worktreeId: string) { + return page.locator(`[id="worktree-list-option-${encodeURIComponent(worktreeId)}"]`) +} + +async function seedLineageScenario(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowWorkspaceLineage(true) + + const worktrees = Object.values(state.worktreesByRepo) + .flat() + .filter((worktree) => !worktree.isArchived) + if (worktrees.length < 2) { + throw new Error('Worktree lineage E2E needs at least two worktrees') + } + + const [parent, child] = worktrees + if (!parent.instanceId || !child.instanceId) { + throw new Error('Worktree lineage E2E needs instance-stamped worktrees') + } + store.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ + repoId, + repoWorktrees.map((worktree) => { + if (worktree.id === parent.id) { + return { ...worktree, displayName: 'E2E lineage parent', sortOrder: 0 } + } + if (worktree.id === child.id) { + return { ...worktree, displayName: 'E2E lineage child', sortOrder: 1 } + } + return worktree + }) + ]) + ), + worktreeLineageById: { + ...current.worktreeLineageById, + [child.id]: { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: Date.now() + } + } + })) + + store.getState().setActiveWorktree(parent.id) + return { parentId: parent.id, childId: child.id } + }) +} + +test.describe('Worktree Lineage', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('renders existing child lineage in the sidebar', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + const parentRow = worktreeOption(orcaPage, parentId) + const childRow = worktreeOption(orcaPage, childId) + + await expect(parentRow).toContainText('E2E lineage parent') + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + + await expect(childRow).toContainText('E2E lineage child') + const childToggle = parentRow.getByRole('button', { name: 'Hide 1 child workspace' }) + await expect(childToggle).toBeVisible({ timeout: 10_000 }) + await expect(childRow).toBeVisible() + + const positions = await orcaPage.evaluate( + ({ parentId, childId }) => { + const parent = document.getElementById( + `worktree-list-option-${encodeURIComponent(parentId)}` + ) + const child = document.getElementById(`worktree-list-option-${encodeURIComponent(childId)}`) + if (!parent || !child) { + return null + } + return { + parentTop: parent.getBoundingClientRect().top, + childTop: child.getBoundingClientRect().top + } + }, + { parentId, childId } + ) + expect(positions).not.toBeNull() + expect(positions!.childTop).toBeGreaterThan(positions!.parentTop) + + await childToggle.click() + await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible() + await expect(childRow).toBeHidden() + + await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click() + await childRow.click({ button: 'right' }) + await orcaPage.getByRole('menuitem', { name: 'Remove from Parent' }).click() + await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await expect(childRow).toBeVisible() + + await parentRow.click({ button: 'right' }) + await expect( + orcaPage.getByRole('menuitem', { name: 'Group under Active Workspace' }) + ).toHaveCount(0) + }) +}) From fc0a955d0b16df09a0f64db28ca6256c4bdfe0e5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 17:22:13 -0700 Subject: [PATCH 023/177] Improve automations scheduling UX (#1997) * Improve automations scheduling UX * fix: address review findings --- .../automations/AutomationDetail.tsx | 31 +- .../automations/AutomationEditorDialog.tsx | 363 ++++++++++-------- .../automations/AutomationSchedulePicker.tsx | 226 +++++++++++ .../automations/AutomationsPage.tsx | 217 +++++++++-- .../automations/CreateFromPicker.tsx | 4 +- .../automations/WorkspaceCombobox.tsx | 4 +- .../automations/automation-templates.ts | 68 ++++ src/shared/automation-schedules.test.ts | 71 +++- src/shared/automation-schedules.ts | 315 ++++++++++++++- src/shared/automations-types.ts | 2 +- 10 files changed, 1049 insertions(+), 252 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationSchedulePicker.tsx create mode 100644 src/renderer/src/components/automations/automation-templates.ts diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index fe9228c4434..56d5eea7904 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import type { Worktree } from '../../../../shared/types' -import { parseAutomationRrule } from '../../../../shared/automation-schedules' +import { formatAutomationSchedule } from '../../../../shared/automation-schedules' import { formatAutomationDateTime, formatAutomationDateTimeWithRelative, @@ -44,15 +44,6 @@ function DetailMetric({ label, value }: { label: string; value: string }): React ) } -function formatTime(hour: number, minute: number): string { - const date = new Date() - date.setHours(hour, minute, 0, 0) - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit' - }).format(date) -} - function formatGrace(minutes: number): string { if (minutes <= 0) { return 'No grace' @@ -64,24 +55,6 @@ function formatGrace(minutes: number): string { return `${hours} ${hours === 1 ? 'hour' : 'hours'}` } -function formatSchedule(rrule: string): string { - const schedule = parseAutomationRrule(rrule) - if (schedule.preset === 'hourly') { - return `Hourly at :${String(schedule.minute).padStart(2, '0')}` - } - const time = formatTime(schedule.hour, schedule.minute) - if (schedule.preset === 'daily') { - return `Daily at ${time}` - } - if (schedule.preset === 'weekdays') { - return `Weekdays at ${time}` - } - const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format( - new Date(2026, 0, 4 + schedule.dayOfWeek) - ) - return `${day}s at ${time}` -} - function ToolbarIconButton({ label, children, @@ -223,7 +196,7 @@ export function AutomationDetail({ - + void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onApplyTemplate: (template: AutomationTemplate) => void onSave: () => void } +function AutomationTemplateCard({ + template, + onSelect +}: { + template: AutomationTemplate + onSelect: () => void +}): React.JSX.Element { + return ( + + ) +} + export function AutomationEditorDialog({ open, isEditing, @@ -67,88 +98,142 @@ export function AutomationEditorDialog({ onProjectChange, onOpenChange, onDraftChange, + onApplyTemplate, onSave }: AutomationEditorDialogProps): React.JSX.Element { + const [templateOpen, setTemplateOpen] = React.useState(false) + return ( { event.preventDefault() }} > - - - {isEditing ? 'Edit Automation' : 'Create Automation'} - + +
+
+ + {isEditing ? 'Edit automation' : 'Create automation'} + + + onDraftChange((current) => ({ ...current, name: event.target.value })) + } + /> +
+ {!isEditing ? ( + + + + + +
+ {AUTOMATION_TEMPLATES.map((template) => ( + { + onApplyTemplate(template) + setTemplateOpen(false) + }} + /> + ))} +
+
+
+ ) : null} +
-
- - + {draft.scheduleWarning ? ( +
+ {draft.scheduleWarning} +
+ ) : null} + + ') + expect(markup).not.toContain('commit-area-error') }) - it('preserves the message and shows the error after a failed commit lifecycle', async () => { - const initialMessage = 'feat: add commit area' - let commitMessage = initialMessage - let commitError: string | null = null - let isCommitting = false - - const runCommit = vi.fn(async () => { - isCommitting = true - commitError = null - await Promise.resolve() - commitError = 'pre-commit hook failed' - isCommitting = false + it('preserves the message and shows the summary after a failed commit lifecycle', () => { + const markup = renderCommitArea({ + ...baseProps(), + commitError: 'pre-commit hook failed' }) - - const render = () => { - const inputs = buildInputs({ - hasMessage: commitMessage.trim().length > 0, - isCommitting - }) - return CommitArea({ - ...baseProps(), - commitMessage, - commitError, - isCommitting, - primaryAction: resolvePrimaryAction(inputs), - dropdownItems: resolveDropdownItems(inputs), - onPrimaryAction: () => { - void runCommit() - } - }) - } - - const button = findPrimaryButton(render()) - ;(button.props.onClick as () => void)() - await flushPromises() - - const updated = render() - expect(findTextarea(updated).props.value).toBe(initialMessage) - expect(hasText(updated, 'pre-commit hook failed')).toBe(true) - expect(runCommit).toHaveBeenCalledTimes(1) + expect(textarea(markup)).toContain('feat: add commit area') + expect(markup).toContain('Pre-commit hook failed.') }) it('locks the primary button while the commit is in flight', () => { const props = baseProps({ isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - const button = findPrimaryButton(element) - expect(button.props.disabled).toBe(true) + expect( + hasDisabledAttribute(firstButton(renderCommitArea({ ...props, isCommitting: true }))) + ).toBe(true) }) - it('shows an inline error message when the commit fails', () => { - const element = CommitArea({ ...baseProps(), commitError: 'pre-commit hook failed' }) - expect(hasText(element, 'pre-commit hook failed')).toBe(true) + it('shows a compact summary and not raw multiline text when the commit fails', () => { + const raw = 'husky - pre-commit hook\neslint found 2 errors\nfull lint output line' + const markup = renderCommitArea({ ...baseProps(), commitError: raw }) + + expect(markup).toContain('id="commit-area-error"') + expect(markup).toContain('role="alert"') + expect(markup).toContain('aria-live="polite"') + expect(markup).toContain('Lint failed during commit.') + expect(markup).not.toContain('full lint output line') + expect(markup).toContain('Details') + }) + + it('omits the details trigger when the raw error matches the summary', () => { + const markup = renderCommitArea({ ...baseProps(), commitError: 'nothing to commit' }) + expect(markup).toContain('nothing to commit') + expect(markup).not.toContain('Details') }) it('shows an inline error message when a remote action fails', () => { - const element = CommitArea({ + const markup = renderCommitArea({ ...baseProps(), remoteActionError: 'Fetch failed. network timeout' }) - expect(hasText(element, 'Fetch failed. network timeout')).toBe(true) + expect(markup).toContain('Fetch failed. network timeout') + expect(markup).toContain('commit-area-remote-error') + }) + + it('keeps generation errors separate from commit and remote errors', () => { + const markup = renderCommitArea({ + ...baseProps(), + generateError: 'No staged changes to summarize.' + }) + expect(markup).toContain('No staged changes to summarize.') + expect(markup).toContain('aria-describedby="commit-area-generate-error"') + }) + + it('keeps all visible errors linked to the textarea', () => { + const markup = renderCommitArea({ + ...baseProps(), + commitError: 'pre-commit hook failed', + remoteActionError: 'Fetch failed.', + generateError: 'No staged changes.' + }) + expect(markup).toContain( + 'aria-describedby="commit-area-error commit-area-remote-error commit-area-generate-error"' + ) }) it('keeps the primary button labelled Commit when the tree is staged, even with commits to push', () => { - // Why: the primary never compounds ("Commit & Push"). Users commit first, - // then the primary rotates to Push. Compound flows remain in the dropdown, - // so we check only the primary button, not the whole tree. - const props = baseProps({ - stagedCount: 1, - hasMessage: true, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Commit')).toBe(true) - expect(hasText(primary, 'Commit & Push')).toBe(false) - expect(hasText(primary, 'Commit & Sync')).toBe(false) - expect(hasText(primary, 'Commit & Publish')).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 1, + hasMessage: true, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } + }) + ) + expect(firstButton(markup)).toContain('Commit') + expect(firstButton(markup)).not.toContain('Commit & Push') }) - // Why: fetching from the dropdown sets isRemoteOperationActive, but the - // primary button is plain "Commit" — painting a spinner on it told the - // user their commit was running. The spinner must track the primary - // action itself, not every background remote op. it('does not show a spinner on a plain Commit primary when a dropdown remote op is running', () => { - const props = baseProps({ - // stagedCount + no message resolves to plain 'commit' kind (disabled - // because the message is empty). This is the scenario the user hit: - // a Commit button that falsely claimed their commit was in flight - // while a dropdown-triggered Fetch was the actual work. - stagedCount: 1, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - isCommitting: false, - isRemoteOperationActive: true - }) - const element = CommitArea(props) - expect(primaryHasSpinner(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 1, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + isCommitting: false, + isRemoteOperationActive: true + }) + ) + expect(firstButton(markup)).not.toContain('animate-spin') }) it('shows a spinner on a Commit primary while the commit itself is in flight', () => { @@ -336,85 +204,73 @@ describe('CommitArea', () => { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - expect(primaryHasSpinner(element)).toBe(true) + expect(firstButton(renderCommitArea({ ...props, isCommitting: true }))).toContain( + 'animate-spin' + ) }) - it('shows a spinner on a remote primary (Push) while the matching remote op is active', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push' - }) - const element = CommitArea(props) - expect(primaryHasSpinner(element)).toBe(true) + it('shows a spinner on a remote primary while the matching remote op is active', () => { + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'push' + }) + ) + expect(firstButton(markup)).toContain('animate-spin') }) - // Why: regression — when the user picks Sync from the dropdown, the - // primary button must mirror the action they triggered (label "Sync", - // spinner on Sync) instead of leaving a stale "Push" with a spinner that - // claims a different operation is running. it('mirrors a dropdown-triggered Sync on the primary button while it runs', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - // Pre-click state: ahead=3, behind=0 → primary's natural label is Push. - upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'sync' - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Sync')).toBe(true) - expect(hasText(primary, 'Push')).toBe(false) - expect(primaryHasSpinner(element)).toBe(true) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'sync' + }) + ) + expect(firstButton(markup)).toContain('Sync') + expect(firstButton(markup)).not.toContain('Push') + expect(firstButton(markup)).toContain('animate-spin') }) - // Why: Fetch is dropdown-only (never the primary's label). Spinning the - // primary on a fetch would mis-narrate "Push is running" while the actual - // work is fetching. Primary keeps its natural label, disabled, no spinner. it('does not spin or relabel the primary when a dropdown Fetch is in flight', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'fetch' - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Push')).toBe(true) - expect(primary.props.disabled).toBe(true) - expect(primaryHasSpinner(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'fetch' + }) + ) + expect(firstButton(markup)).toContain('Push') + expect(hasDisabledAttribute(firstButton(markup))).toBe(true) + expect(firstButton(markup)).not.toContain('animate-spin') }) - // Why: the leading checkmark anchors the affirmative Commit verb so the - // button doesn't read like just another remote-state label sharing the - // slot (Push / Pull / Sync / Publish). Decorative — verified by - // presence/absence rather than label text. it('renders a leading checkmark on a Commit primary', () => { - const element = CommitArea(baseProps()) - expect(primaryHasCheck(element)).toBe(true) + expect(firstButton(renderCommitArea(baseProps()))).toContain('lucide-check') }) it('omits the checkmark when the primary is a remote action', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } - }) - const element = CommitArea(props) - expect(primaryHasCheck(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } + }) + ) + expect(firstButton(markup)).not.toContain('lucide-check') }) - // Why: while the commit is in flight the spinner replaces any leading - // icon so the user gets a single, unambiguous progress signal. it('replaces the checkmark with a spinner while the commit is in flight', () => { const props = baseProps({ isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - expect(primaryHasSpinner(element)).toBe(true) - expect(primaryHasCheck(element)).toBe(false) + const button = firstButton(renderCommitArea({ ...props, isCommitting: true })) + expect(button).toContain('animate-spin') + expect(button).not.toContain('lucide-check') }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 66f5d65c8c2..4d86a39adfc 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -91,6 +91,7 @@ import { } from '@/components/ui/context-menu' import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -144,6 +145,7 @@ import { isCustomAgentId, resolveCommitMessageAgentChoice } from '../../../../shared/commit-message-agent-spec' +import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary' type SourceControlScope = 'all' | 'uncommitted' type SourceControlViewMode = 'list' | 'tree' @@ -2675,6 +2677,7 @@ function SourceControlInner(): React.JSX.Element { clears. */} {(scope === 'all' || scope === 'uncommitted') && ( (commitError ? summarizeCommitFailure(commitError) : null), + [commitError] + ) + const hasCommitFailureDetails = useMemo( + () => + commitError && commitFailureSummary + ? hasExpandedCommitFailureDetails(commitError, commitFailureSummary) + : false, + [commitError, commitFailureSummary] + ) + const commitFailureIdentity = `${worktreeId ?? 'no-worktree'}:${commitError ?? ''}` + const [commitFailureDialogState, setCommitFailureDialogState] = useState<{ + identity: string + open: boolean + }>({ identity: commitFailureIdentity, open: false }) + const isCommitFailureDialogOpen = + commitFailureDialogState.open && commitFailureDialogState.identity === commitFailureIdentity + const setCommitFailureDialogOpen = useCallback( + (open: boolean) => { + setCommitFailureDialogState({ identity: commitFailureIdentity, open }) + }, + [commitFailureIdentity] + ) + + useEffect(() => { + setCommitFailureDialogState((current) => + current.identity === commitFailureIdentity + ? current + : { identity: commitFailureIdentity, open: false } + ) + }, [commitFailureIdentity]) // Why: most primary-kind labels are anchored by a directional icon so // the affirmative Commit (✓) reads distinctly from the remote-state @@ -3376,14 +3413,50 @@ export function CommitArea({ // Why: role="alert" + aria-live="polite" lets screen readers announce // commit failures; the id ties the message to the textarea via // aria-describedby so assistive tech associates the two. - +
+ )} + {commitError && commitFailureSummary && ( + + + + Commit Failed + {commitFailureSummary} + +
+              {commitError}
+            
+ + + + + +
+
)} {remoteActionError && (

{ + it('collapses lint-staged, husky, and oxlint failures to a lint summary', () => { + const raw = [ + 'npm warn Unknown env config "python". This will stop working.', + 'husky - pre-commit hook exited with code 1', + 'lint-staged failed', + 'oxlint found 3 errors' + ].join('\n') + + expect(summarizeCommitFailure(raw)).toBe('Lint failed during commit.') + }) + + it('collapses pre-commit hook failures without lint output to a hook summary', () => { + expect(summarizeCommitFailure('pre-commit hook failed: secret scan blocked commit')).toBe( + 'Pre-commit hook failed.' + ) + }) + + it('falls back to the first meaningful line for generic failures', () => { + expect(summarizeCommitFailure('\n fatal: unable to auto-detect email address\nmore')).toBe( + 'fatal: unable to auto-detect email address' + ) + }) + + it('strips ANSI/control sequences and handles empty input', () => { + expect(summarizeCommitFailure('\u001b[31meslint found 2 errors\u001b[0m')).toBe( + 'Lint failed during commit.' + ) + expect(summarizeCommitFailure(' \n\t ')).toBe('Commit failed.') + }) + + it('reports whether expanded details add information beyond the summary', () => { + expect(hasExpandedCommitFailureDetails('nothing to commit', 'nothing to commit')).toBe(false) + expect( + hasExpandedCommitFailureDetails( + 'husky - pre-commit hook\neslint found 2 errors\nfull output', + 'Lint failed during commit.' + ) + ).toBe(true) + expect(hasExpandedCommitFailureDetails('', 'Commit failed.')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/commit-failure-summary.ts b/src/renderer/src/components/right-sidebar/commit-failure-summary.ts new file mode 100644 index 00000000000..8597c643ce5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/commit-failure-summary.ts @@ -0,0 +1,63 @@ +const FALLBACK_COMMIT_FAILURE_SUMMARY = 'Commit failed.' +const LINT_COMMIT_FAILURE_SUMMARY = 'Lint failed during commit.' +const PRE_COMMIT_FAILURE_SUMMARY = 'Pre-commit hook failed.' + +const ANSI_PATTERN = + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g +const CONTROL_PATTERN = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g +const LOW_SIGNAL_LINE_PATTERN = + /^(?:npm\s+(?:warn|warning)\b.*(?:env|config)|npm\s+notice\b|husky\s+-\s+deprecated\b)/i +const HOOK_PATTERN = /\b(?:pre-commit|precommit|husky|lint-staged)\b/i +const LINT_PATTERN = + /\b(?:eslint|oxlint|lint-staged|lint)\b|(?:found|found:)\s+\d+\s+errors?\b|\b\d+\s+errors?\b/i + +function normalizeCommitFailure(raw: string): string { + return raw.replace(ANSI_PATTERN, '').replace(/\r\n?/g, '\n').replace(CONTROL_PATTERN, '').trim() +} + +function getMeaningfulLines(raw: string): string[] { + const lines = normalizeCommitFailure(raw) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const hasSignalLine = lines.some((line) => HOOK_PATTERN.test(line) || LINT_PATTERN.test(line)) + + if (!hasSignalLine) { + return lines + } + + const filtered = lines.filter((line) => !LOW_SIGNAL_LINE_PATTERN.test(line)) + return filtered.length > 0 ? filtered : lines +} + +export function summarizeCommitFailure(raw: string): string { + const lines = getMeaningfulLines(raw) + + if (lines.length === 0) { + return FALLBACK_COMMIT_FAILURE_SUMMARY + } + + if (lines.some((line) => LINT_PATTERN.test(line))) { + return LINT_COMMIT_FAILURE_SUMMARY + } + + if (lines.some((line) => HOOK_PATTERN.test(line))) { + return PRE_COMMIT_FAILURE_SUMMARY + } + + return lines[0] ?? FALLBACK_COMMIT_FAILURE_SUMMARY +} + +export function hasExpandedCommitFailureDetails(raw: string, summary: string): boolean { + const normalizedRaw = normalizeCommitFailure(raw) + const normalizedSummary = normalizeCommitFailure(summary) + + if (!normalizedRaw) { + return false + } + + return normalizedRaw.replace(/\s+/g, ' ') !== normalizedSummary.replace(/\s+/g, ' ') +} From c7ac8a1f2f4f49c6c34dbe15aae20b285aa9361d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 16 May 2026 22:49:12 -0700 Subject: [PATCH 153/177] fix: handle related crash cleanup (#2139) --- .../crash-report-store.test.ts | 29 ++++++ .../crash-reporting/crash-report-store.ts | 26 ++++++ ...lesystem-watcher-local-unsubscribe.test.ts | 90 +++++++++++++++++++ src/main/ipc/filesystem-watcher.ts | 31 ++++--- 4 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index 44e6c83fdc6..2dc9a87aa55 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -87,6 +87,35 @@ describe('CrashReportStore', () => { await expect(store.markSent(report.id)).resolves.toMatchObject({ status: 'dismissed' }) }) + it('dismisses sibling pending records from the same Electron crash event', async () => { + const { store } = await createStore() + await store.record(input()) + await store.record(input()) + const renderer = await store.record(input()) + + await expect(store.dismiss(renderer.id)).resolves.toMatchObject({ status: 'dismissed' }) + await expect(store.getLatestPending()).resolves.toBeNull() + }) + + it('dismisses sibling pending records after one crash report is sent', async () => { + const { store } = await createStore() + await store.record(input()) + await store.record(input()) + const renderer = await store.record(input()) + + await expect(store.markSent(renderer.id)).resolves.toMatchObject({ status: 'sent' }) + + const reports = await store.listRecent() + expect(reports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: renderer.id, status: 'sent' }), + expect.objectContaining({ status: 'dismissed' }), + expect.objectContaining({ status: 'dismissed' }) + ]) + ) + await expect(store.getLatestPending()).resolves.toBeNull() + }) + it('persists a submitted dismissed report as sent', async () => { const { store, filePath } = await createStore() const report = await store.record(input()) diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index 695ecca571a..e1791398e7b 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -13,11 +13,30 @@ import { } from '../../shared/crash-reporting' const MAX_REPORTS = 5 +const RELATED_CRASH_WINDOW_MS = 5_000 type CrashReportFile = { reports: CrashReportRecord[] } +function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRecord): boolean { + if (anchor.id === candidate.id || candidate.status !== 'pending') { + return false + } + const anchorTime = Date.parse(anchor.createdAt) + const candidateTime = Date.parse(candidate.createdAt) + if (!Number.isFinite(anchorTime) || !Number.isFinite(candidateTime)) { + return false + } + return ( + Math.abs(anchorTime - candidateTime) <= RELATED_CRASH_WINDOW_MS && + anchor.reason === candidate.reason && + anchor.exitCode === candidate.exitCode && + anchor.appVersion === candidate.appVersion && + anchor.platform === candidate.platform + ) +} + export class CrashReportStore { private writeChain = Promise.resolve() @@ -90,8 +109,15 @@ export class CrashReportStore { ): Promise { return this.withWrite(async (reports) => { let result: CrashReportRecord | null = null + const anchor = reports.find((report) => report.id === id) const nextReports = reports.map((report) => { if (report.id !== id) { + // Why: one Electron crash can emit GPU/Network/renderer exits in a + // burst. Once one report is handled, sibling pending records should + // not re-open the crash prompt as separate crashes. + if (anchor && anchor.status === from && isRelatedCrashEvent(anchor, report)) { + return { ...report, status: 'dismissed' as const } + } return report } if (report.status !== from) { diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts new file mode 100644 index 00000000000..4384b6a8718 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock } = vi.hoisted(() => ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' +import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' + +type HandlerMap = Record Promise | unknown> + +describe('local filesystem watcher shutdown', () => { + const handlers: HandlerMap = {} + + beforeEach(() => { + vi.useRealTimers() + handleMock.mockReset() + vi.mocked(stat).mockReset() + vi.mocked(subscribeParcelWatcher).mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + }) + + it('awaits unsubscribe already started by sender cleanup during shutdown', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + destroyedCallbacks[0]() + + let shutdownResolved = false + const shutdownPromise = closeAllWatchers().then(() => { + shutdownResolved = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(shutdownResolved).toBe(false) + + resolveUnsubscribe() + await shutdownPromise + expect(shutdownResolved).toBe(true) + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 030c284728a..79d3d38b187 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -62,6 +62,9 @@ const senderCleanupRegistered = new Set() // Key: rootKey, Value: pending teardown timer. const WATCHER_TEARDOWN_GRACE_MS = 30_000 const pendingTeardowns = new Map>() +// Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy +// cleanup can start it before app shutdown, so will-quit must still await it. +const pendingLocalUnsubscribes = new Set>() // ── Path normalization ─────────────────────────────────────────────── @@ -324,15 +327,26 @@ function cleanupLocalWatchersForSender(senderId: number): void { if (watchedRoot.batch.timer) { clearTimeout(watchedRoot.batch.timer) } - void watchedRoot.subscription.unsubscribe().catch((err: unknown) => { - console.error(`[filesystem-watcher] unsubscribe error for ${key}:`, err) - }) + trackLocalUnsubscribe(key, watchedRoot) watchedRoots.delete(key) } } } } +function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise { + const unsubscribePromise = Promise.resolve() + .then(() => root.subscription.unsubscribe()) + .catch((err: unknown) => { + console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) + }) + .finally(() => { + pendingLocalUnsubscribes.delete(unsubscribePromise) + }) + pendingLocalUnsubscribes.add(unsubscribePromise) + return unsubscribePromise +} + function registerSenderCleanup(sender: WebContents): void { if (senderCleanupRegistered.has(sender.id)) { return @@ -424,9 +438,7 @@ function unsubscribe(worktreePath: string, senderId: number): void { if (!currentRoot || currentRoot.listeners.size > 0) { return } - void currentRoot.subscription.unsubscribe().catch((err: unknown) => { - console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) - }) + void trackLocalUnsubscribe(rootKey, currentRoot) watchedRoots.delete(rootKey) }, WATCHER_TEARDOWN_GRACE_MS) @@ -729,13 +741,10 @@ export async function closeAllWatchers(): Promise { if (root.batch.timer) { clearTimeout(root.batch.timer) } - try { - await root.subscription.unsubscribe() - } catch (err) { - console.error(`[filesystem-watcher] shutdown unsubscribe error for ${rootKey}:`, err) - } + await trackLocalUnsubscribe(rootKey, root) } watchedRoots.clear() + await Promise.allSettled(Array.from(pendingLocalUnsubscribes)) // Why: remote watchers are tracked separately from local @parcel/watcher // subscriptions. Without cleaning them up here, their unwatch callbacks From 40e903d8339c3fb741cdbbcd66c04301c29febd1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 16 May 2026 22:56:42 -0700 Subject: [PATCH 154/177] fix: address review findings (#2138) --- src/main/ipc/filesystem.ts | 98 +++++- src/main/ipc/hosted-review.ts | 24 +- src/main/runtime/orca-runtime-git.ts | 82 ++++- src/main/runtime/orca-runtime.test.ts | 99 +++++- src/main/runtime/orca-runtime.ts | 35 +- src/main/runtime/rpc/methods/git-params.ts | 114 +++++++ src/main/runtime/rpc/methods/git.ts | 162 ++++------ .../runtime/rpc/methods/hosted-review.test.ts | 4 + src/main/runtime/rpc/methods/hosted-review.ts | 4 + .../commit-message-text-generation.test.ts | 91 ++++++ .../commit-message-text-generation.ts | 118 +++++-- .../text-generation/pull-request-context.ts | 64 ++++ src/preload/api-types.ts | 19 ++ src/preload/index.ts | 12 + .../components/right-sidebar/ChecksPanel.tsx | 5 + .../right-sidebar/CreatePullRequestDialog.tsx | 185 ++++++----- .../right-sidebar/SourceControl.tsx | 18 +- .../source-control-dropdown-items.test.ts | 15 + .../source-control-dropdown-items.ts | 22 +- .../source-control-primary-action.test.ts | 14 +- .../source-control-primary-action.ts | 16 +- .../useCreatePullRequestDialogFields.ts | 299 ++++++++++++++++++ .../src/runtime/runtime-git-client.ts | 51 +++ .../store/slices/hosted-review-cache.test.ts | 171 ++++++++++ .../src/store/slices/hosted-review.test.ts | 227 ++++++------- .../src/store/slices/hosted-review.ts | 15 +- src/renderer/src/web/web-preload-api.ts | 5 + src/shared/hosted-review.ts | 2 + src/shared/pull-request-generation.test.ts | 56 ++++ src/shared/pull-request-generation.ts | 114 +++++++ 30 files changed, 1775 insertions(+), 366 deletions(-) create mode 100644 src/main/runtime/rpc/methods/git-params.ts create mode 100644 src/main/text-generation/pull-request-context.ts create mode 100644 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts create mode 100644 src/renderer/src/store/slices/hosted-review-cache.test.ts create mode 100644 src/shared/pull-request-generation.test.ts create mode 100644 src/shared/pull-request-generation.ts diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index ed896826de6..d96f9a733b7 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -3,7 +3,7 @@ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' import { extname, join } from 'path' import type { ChildProcess } from 'child_process' -import { wslAwareSpawn } from '../git/runner' +import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import type { Store } from '../persistence' import type { @@ -48,10 +48,14 @@ import { import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, - type GenerateCommitMessageResult + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' @@ -691,6 +695,96 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + } + ): Promise => { + const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + const context = await getPullRequestDraftContext( + (argv) => provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const context = await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: worktreePath, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath) + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) + ipcMain.handle( 'git:branchCompare', async ( diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 909eec8698a..d972334bf15 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -13,6 +13,8 @@ import { getHostedReviewCreationEligibility } from '../source-control/hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' +import { resolveRegisteredWorktreePath } from './filesystem-auth' +import { listRepoWorktrees } from '../repo-worktrees' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -30,6 +32,22 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } +async function resolveHostedReviewWorktreePath( + repo: Repo, + store: Store, + worktreePath?: string +): Promise { + if (!worktreePath) { + return repo.path + } + const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + const repoWorktrees = await listRepoWorktrees(repo) + if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { + throw new Error('Access denied: worktree does not belong to repository') + } + return resolvedWorktreePath +} + export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) @@ -71,7 +89,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector body: null } } - return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path }) + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath }) } ) @@ -84,7 +103,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector error: 'Creating pull requests from SSH worktrees is not supported yet.' } } - const result = await createHostedReview(repo.path, { + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const result = await createHostedReview(worktreePath, { provider: args.provider, base: args.base, head: args.head, diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index f5fa5ada36f..46c1f30e9ca 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -37,13 +37,18 @@ import { gitFetch, gitPull, gitPush } from '../git/remote' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, - type GenerateCommitMessageResult + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { gitExecFileAsync } from '../git/runner' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } type RuntimeCommitMessageSettingsOverride = Partial< @@ -379,6 +384,81 @@ export class RuntimeGitCommands { return { ok: true } } + async generateRuntimePullRequestFields( + worktreeSelector: string, + input: { base: string; title: string; body: string; draft: boolean }, + settingsOverride?: RuntimeCommitMessageSettingsOverride + ): Promise { + const resolvedSettings = resolveCommitMessageSettings({ + ...this.host.getRuntimeSettings(), + ...settingsOverride + }) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId && !provider) { + return { + success: false, + error: `No git provider for connection "${target.connectionId}"` + } + } + const context = target.connectionId + ? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + }) + : await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }), + { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + + if (target.connectionId) { + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: target.worktree.path, + execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + this.host.getCommitMessageAgentEnvironment?.() + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: target.worktree.path, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + + async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + await provider?.cancelGenerateCommitMessage(target.worktree.path) + return { ok: true } + } + cancelGeneratePullRequestFieldsLocal(target.worktree.path) + return { ok: true } + } + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cae344efbd6..a34ef8c00f9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -38,7 +38,9 @@ const { getSshGitProviderMock, registerSshGitProviderMock, unregisterSshGitProviderMock, - invalidateAuthorizedRootsCacheMock + invalidateAuthorizedRootsCacheMock, + createHostedReviewMock, + getHostedReviewCreationEligibilityMock } = vi.hoisted(() => { // Why: SSH runtime tests register providers through the public dispatcher API, // so the mock needs the same registry semantics as the real module. @@ -66,7 +68,9 @@ const { unregisterSshGitProviderMock: vi.fn((connectionId: string) => { sshGitProviders.delete(connectionId) }), - invalidateAuthorizedRootsCacheMock: vi.fn() + invalidateAuthorizedRootsCacheMock: vi.fn(), + createHostedReviewMock: vi.fn(), + getHostedReviewCreationEligibilityMock: vi.fn() } }) @@ -110,6 +114,11 @@ vi.mock('../ipc/filesystem-auth', () => ({ Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') })) +vi.mock('../source-control/hosted-review-creation', () => ({ + createHostedReview: createHostedReviewMock, + getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock +})) + // Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a // fallback base branch. Real resolution shells out to `git` against the // test's fabricated repo path, which has no refs, so we stub it to a @@ -157,6 +166,25 @@ afterEach(() => { computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() + createHostedReviewMock.mockReset() + createHostedReviewMock.mockResolvedValue({ + ok: true, + provider: 'github', + number: 1, + url: 'https://example.com/pull/1' + }) + getHostedReviewCreationEligibilityMock.mockReset() + getHostedReviewCreationEligibilityMock.mockResolvedValue({ + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'main', + head: 'feature/foo', + title: null, + body: null + }) }) function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { @@ -810,6 +838,73 @@ describe('OrcaRuntimeService', () => { ) }) + it('rejects hosted review worktree selectors outside the selected repo', async () => { + vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => { + if (repoPath === '/tmp/repo-b') { + return [ + { + path: '/tmp/worktree-b', + head: 'def', + branch: 'feature/bar', + isBare: false, + isMainWorktree: false + } + ] + } + return MOCK_GIT_WORKTREES + }) + const repos = [ + { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: 'repo-2', + path: '/tmp/repo-b', + displayName: 'repo-b', + badgeColor: 'green', + addedAt: 2 + } + ] + const multiRepoStore = { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id) + } + const runtime = new OrcaRuntimeService(multiRepoStore as never) + + await expect( + runtime.getHostedReviewCreationEligibility({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + branch: 'feature/bar', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 1, + behind: 0 + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + await expect( + runtime.createHostedReview({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + provider: 'github', + base: 'main', + head: 'feature/bar', + title: 'Create PR', + body: '', + draft: false + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + + expect(getHostedReviewCreationEligibilityMock).not.toHaveBeenCalled() + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) + it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(getDefaultBaseRef).mockClear() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 660cd4832e1..632bfc1ba6a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1239,6 +1239,10 @@ export class OrcaRuntimeService { this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) + generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = + this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) + cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = + this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = @@ -4467,6 +4471,22 @@ export class OrcaRuntimeService { } } + private async resolveHostedReviewTarget(args: { + repoSelector: string + worktreeSelector?: string + }): Promise<{ repo: Repo; repoPath: string }> { + const repo = await this.resolveRepoSelector(args.repoSelector) + if (!args.worktreeSelector) { + return { repo, repoPath: repo.path } + } + + const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) + if (worktree.repoId !== repo.id) { + throw new Error('Access denied: worktree does not belong to repository') + } + return { repo, repoPath: worktree.path } + } + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') @@ -4583,12 +4603,15 @@ export class OrcaRuntimeService { } async getHostedReviewCreationEligibility( - args: Omit & { repoSelector: string } + args: Omit & { + repoSelector: string + worktreeSelector?: string + } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') return getHostedReviewCreationEligibilityFromRepo({ - repoPath: repo.path, + repoPath, branch: args.branch, base: args.base ?? null, hasUncommittedChanges: args.hasUncommittedChanges, @@ -4604,11 +4627,11 @@ export class OrcaRuntimeService { } async createHostedReview( - args: CreateHostedReviewInput & { repoSelector: string } + args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') - const result = await createHostedReviewFromRepo(repo.path, { + const result = await createHostedReviewFromRepo(repoPath, { provider: args.provider, base: args.base, head: args.head, diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts new file mode 100644 index 00000000000..69b2c21f53c --- /dev/null +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const GitStatusParams = WorktreeSelector.extend({ + includeIgnored: z.boolean().optional() +}) + +export const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +export const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +export const GitBranchCompare = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +export const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +export const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + +export const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +export const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + +export const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +const CommitMessageAiSettings = z.object({ + enabled: z.boolean(), + agentId: z.string().nullable(), + selectedModelByAgent: z.record(z.string(), z.string()), + selectedThinkingByModel: z.record(z.string(), z.string()), + customPrompt: z.string(), + customAgentCommand: z.string() +}) + +export const GitGenerateCommitMessage = WorktreeSelector.extend({ + commitMessageAi: CommitMessageAiSettings.optional(), + agentCmdOverrides: z.record(z.string(), z.string()).optional(), + enableGitHubAttribution: z.boolean().optional() +}) + +export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ + base: z.string().min(1, 'Missing base branch'), + title: z.string(), + body: z.string(), + draft: z.boolean() +}) + +export const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string().min(1, 'Missing file path')) +}) + +export const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + pushTarget: z.unknown().optional() +}) + +export const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c6db872eb4b..2eda43d678a 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,112 +1,22 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import type { GlobalSettings } from '../../../../shared/types' - -const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -const GitStatusParams = WorktreeSelector.extend({ - includeIgnored: z.boolean().optional() -}) - -const GitFilePath = WorktreeSelector.extend({ - filePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing file path')) -}) - -const GitDiff = GitFilePath.extend({ - staged: z.boolean(), - compareAgainstHead: z.boolean().optional() -}) - -const GitBranchCompare = WorktreeSelector.extend({ - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -const FullGitObjectId = z - .string() - .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') - -const GitCommitCompare = WorktreeSelector.extend({ - commitId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) - -const GitHistory = WorktreeSelector.extend({ - limit: z.number().int().min(1).max(200).optional(), - baseRef: z.string().nullable().optional() -}) - -const GitBranchDiff = GitFilePath.extend({ - compare: z.object({ - baseRef: z.string().optional(), - baseOid: FullGitObjectId.optional(), - headOid: FullGitObjectId, - mergeBase: FullGitObjectId - }), - oldPath: z.string().optional() -}) - -const GitCommitDiff = GitFilePath.extend({ - commitOid: FullGitObjectId, - parentOid: FullGitObjectId.nullable().optional(), - oldPath: z.string().optional() -}) - -const GitCommit = WorktreeSelector.extend({ - message: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing commit message')) -}) - -const CommitMessageAiSettings = z.object({ - enabled: z.boolean(), - agentId: z.string().nullable(), - selectedModelByAgent: z.record(z.string(), z.string()), - selectedThinkingByModel: z.record(z.string(), z.string()), - customPrompt: z.string(), - customAgentCommand: z.string() -}) - -const GitGenerateCommitMessage = WorktreeSelector.extend({ - commitMessageAi: CommitMessageAiSettings.optional(), - agentCmdOverrides: z.record(z.string(), z.string()).optional(), - enableGitHubAttribution: z.boolean().optional() -}) - -const GitBulkPaths = WorktreeSelector.extend({ - filePaths: z.array(z.string().min(1, 'Missing file path')) -}) - -const GitPush = WorktreeSelector.extend({ - publish: z.boolean().optional(), - pushTarget: z.unknown().optional() -}) - -const GitRemoteFileUrl = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')), - line: z.number().int().min(1) -}) +import { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitFilePath, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRemoteFileUrl, + GitStatusParams, + WorktreeSelector +} from './git-params' export const GIT_METHODS: RpcMethod[] = [ defineMethod({ @@ -235,6 +145,44 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.cancelRuntimeGenerateCommitMessage(params.worktree) }), + defineMethod({ + name: 'git.generatePullRequestFields', + params: GitGeneratePullRequestFields, + handler: async (params, { runtime }) => { + const input = { + base: params.base, + title: params.title, + body: params.body, + draft: params.draft + } + if ( + params.commitMessageAi === undefined && + params.agentCmdOverrides === undefined && + params.enableGitHubAttribution === undefined + ) { + return runtime.generateRuntimePullRequestFields(params.worktree, input) + } + return runtime.generateRuntimePullRequestFields(params.worktree, input, { + ...(params.commitMessageAi !== undefined + ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } + : {}), + ...(params.agentCmdOverrides !== undefined + ? { + agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] + } + : {}), + ...(params.enableGitHubAttribution !== undefined + ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}) + }) + } + }), + defineMethod({ + name: 'git.cancelGeneratePullRequestFields', + params: WorktreeSelector, + handler: async (params, { runtime }) => + runtime.cancelRuntimeGeneratePullRequestFields(params.worktree) + }), defineMethod({ name: 'git.stage', params: GitFilePath, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 28730ce1e2f..63f3d2a8d9f 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -67,6 +67,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.getCreationEligibility', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -79,6 +80,7 @@ describe('hosted review RPC methods', () => { expect(runtime.getHostedReviewCreationEligibility).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -111,6 +113,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.create', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', @@ -122,6 +125,7 @@ describe('hosted review RPC methods', () => { expect(runtime.createHostedReview).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index e4d239a3860..cd2ccf4550a 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -14,6 +14,7 @@ const HostedReviewForBranch = z.object({ const HostedReviewCreationEligibility = z.object({ repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), branch: requiredString('Missing branch'), base: z.string().nullable().optional(), hasUncommittedChanges: z.boolean().optional(), @@ -29,6 +30,7 @@ const HostedReviewCreationEligibility = z.object({ const HostedReviewCreate = z.object({ repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), base: requiredString('Missing base branch'), head: z.string().optional(), @@ -58,6 +60,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getHostedReviewCreationEligibility({ repoSelector: params.repo, + worktreeSelector: params.worktree, branch: params.branch, base: params.base ?? null, hasUncommittedChanges: params.hasUncommittedChanges, @@ -77,6 +80,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.createHostedReview({ repoSelector: params.repo, + worktreeSelector: params.worktree, provider: params.provider, base: params.base, head: params.head, diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 709e254b51b..c18f91bad87 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -6,7 +6,10 @@ import type * as ChildProcess from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { + cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, trimGeneratedCommitMessage } from './commit-message-text-generation' @@ -390,6 +393,94 @@ describe('generateCommitMessageFromContext', () => { ) }) + it('keeps local commit-message and pull-request cancellation lanes separate', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + + const commit = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const pullRequest = generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + cancelGenerateCommitMessageLocal('/repo') + + expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL') + expect(children[1]?.kill).not.toHaveBeenCalled() + + children[0]?.listeners.get('close')?.(null) + const pullRequestStdout = children[1]?.listeners.get('stdout:data') + pullRequestStdout?.( + Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') + ) + children[1]?.listeners.get('close')?.(0) + + await expect(commit).resolves.toEqual({ + success: false, + error: 'Generation canceled.', + canceled: true + }) + await expect(pullRequest).resolves.toMatchObject({ + success: true, + fields: { + base: 'main', + title: 'Update README', + body: 'Details', + draft: false + } + }) + + cancelGeneratePullRequestFieldsLocal('/repo') + expect(children[1]?.kill).not.toHaveBeenCalled() + }) + it('routes Windows batch-script agent commands through cmd.exe', async () => { const originalComSpec = process.env.ComSpec process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index cb6aa1c31eb..09949e28181 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -9,6 +9,12 @@ import { type CommitMessageDraftContext, type GeneratedCommitMessage } from '../../shared/commit-message-generation' +import { + buildPullRequestFieldsPrompt, + parseGeneratedPullRequestFields, + type GeneratedPullRequestFields, + type PullRequestDraftContext +} from '../../shared/pull-request-generation' import { cleanGeneratedCommitMessage, extractAgentErrorMessage @@ -47,6 +53,10 @@ export type GenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type GeneratePullRequestFieldsResult = + | { success: true; fields: GeneratedPullRequestFields; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + export type RemoteCommitMessageExecResult = { stdout: string stderr: string @@ -73,8 +83,8 @@ type ResolveCommitMessageSettingsResult = | { ok: true; params: GenerateCommitMessageParams } | { ok: false; error: string } -type InternalCommitMessageGenerationResult = - | { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string } +type InternalTextGenerationResult = + | { success: true; rawOutput: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } export function trimGeneratedCommitMessage(message: string): string { @@ -190,23 +200,27 @@ function killProcessTree(child: ChildProcess): void { } } -// Keying by `local:${cwd}` keeps local cancellation independent from any SSH -// worktree with the same remote path. +type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' + +// Keying by operation plus `local:${cwd}` keeps local cancellation independent +// from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -function localLaneKey(cwd: string): string { - return `local:${cwd}` +function localLaneKey(operation: LocalGenerationOperation, cwd: string): string { + return `${operation}:local:${cwd}` } export function cancelGenerateCommitMessageLocal(cwd: string): void { - cancelTokensByLane.get(localLaneKey(cwd))?.() + cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() } async function runLocalPlan( plan: CommitMessagePlan, cwd: string, - env: NodeJS.ProcessEnv | undefined -): Promise { + env: NodeJS.ProcessEnv | undefined, + emptyResultName = 'message', + operation: LocalGenerationOperation = 'commit-message' +): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { let child: ChildProcess @@ -246,20 +260,24 @@ async function runLocalPlan( let outputLimitExceeded = false let settled = false let canceledByUser = false - const laneKey = localLaneKey(cwd) - const finalize = (result: InternalCommitMessageGenerationResult): void => { + const laneKey = localLaneKey(operation, cwd) + let cancelToken: (() => void) | null = null + const finalize = (result: InternalTextGenerationResult): void => { if (settled) { return } settled = true - cancelTokensByLane.delete(laneKey) + if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { + cancelTokensByLane.delete(laneKey) + } resolve(result) } - cancelTokensByLane.set(laneKey, () => { + cancelToken = () => { canceledByUser = true killProcessTree(child) - }) + } + cancelTokensByLane.set(laneKey, cancelToken) const timer = setTimeout(() => { killProcessTree(child) @@ -313,7 +331,7 @@ async function runLocalPlan( finalize({ success: false, error: userFacingAgentFailure(label) }) return } - finalizeFromAgentOutput({ code, stdout, stderr, label, finalize }) + finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize }) }) child.stdin?.end(stdinPayload ?? undefined) @@ -325,9 +343,10 @@ function finalizeFromAgentOutput(args: { stdout: string stderr: string label: string - finalize: (result: InternalCommitMessageGenerationResult) => void + emptyResultName: string + finalize: (result: InternalTextGenerationResult) => void }): void { - const { code, stdout, stderr, label, finalize } = args + const { code, stdout, stderr, label, emptyResultName, finalize } = args if (code !== 0) { const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) console.error('[commit-message] Generator failed:', { @@ -342,21 +361,21 @@ function finalizeFromAgentOutput(args: { } const cleaned = cleanGeneratedCommitMessage(stdout) if (!cleaned) { - finalize({ success: false, error: `${label} returned an empty message.` }) + finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` }) return } - const commitMessage = splitGeneratedCommitMessage(cleaned) finalize({ success: true, - commitMessage, + rawOutput: cleaned, agentLabel: label }) } async function runRemotePlan( plan: CommitMessagePlan, - target: Extract -): Promise { + target: Extract, + emptyResultName = 'message' +): Promise { const { binary, label } = plan let result: RemoteCommitMessageExecResult try { @@ -403,20 +422,27 @@ async function runRemotePlan( stdout: result.stdout, stderr: result.stderr, label, + emptyResultName, finalize: resolve }) }) } function formatCommitMessageGenerationResult( - result: InternalCommitMessageGenerationResult + result: InternalTextGenerationResult ): GenerateCommitMessageResult { if (!result.success) { return result } + let commitMessage: GeneratedCommitMessage + try { + commitMessage = splitGeneratedCommitMessage(result.rawOutput) + } catch { + return { success: false, error: 'Generated commit message could not be parsed.' } + } return { success: true, - message: trimGeneratedCommitMessage(result.commitMessage.message), + message: trimGeneratedCommitMessage(commitMessage.message), agentLabel: result.agentLabel } } @@ -434,7 +460,47 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target) - : await runLocalPlan(planned.plan, target.cwd, target.env) + ? await runRemotePlan(planned.plan, target, 'details') + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details') return formatCommitMessageGenerationResult(internalResult) } + +export function cancelGeneratePullRequestFieldsLocal(cwd: string): void { + cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.() +} + +function formatPullRequestFieldsGenerationResult( + result: InternalTextGenerationResult, + context: PullRequestDraftContext +): GeneratePullRequestFieldsResult { + if (!result.success) { + return result + } + try { + return { + success: true, + fields: parseGeneratedPullRequestFields(result.rawOutput, context), + agentLabel: result.agentLabel + } + } catch { + return { success: false, error: 'Generated pull request details could not be parsed.' } + } +} + +export async function generatePullRequestFieldsFromContext( + context: PullRequestDraftContext, + params: GenerateCommitMessageParams, + target: CommitMessageGenerationTarget +): Promise { + const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') + const planned = planCommitMessageGeneration(params, prompt) + if (!planned.ok) { + return { success: false, error: planned.error } + } + + const internalResult = + target.kind === 'remote' + ? await runRemotePlan(planned.plan, target) + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') + return formatPullRequestFieldsGenerationResult(internalResult, context) +} diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts new file mode 100644 index 00000000000..12aa93846bb --- /dev/null +++ b/src/main/text-generation/pull-request-context.ts @@ -0,0 +1,64 @@ +import type { PullRequestDraftContext } from '../../shared/pull-request-generation' + +const MAX_PULL_REQUEST_CONTEXT_BYTES = 10 * 1024 * 1024 + +type GitExec = ( + args: string[], + options?: { maxBuffer?: number } +) => Promise<{ stdout: string; stderr?: string }> + +export type PullRequestContextInput = { + base: string + currentTitle: string + currentBody: string + currentDraft: boolean +} + +async function safeExec(execGit: GitExec, args: string[]): Promise { + try { + const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES }) + return stdout.trim() + } catch { + return '' + } +} + +export async function getPullRequestDraftContext( + execGit: GitExec, + input: PullRequestContextInput +): Promise { + const base = input.base.trim() + if (!base || base.startsWith('-')) { + return null + } + + const [branch, mergeBase] = await Promise.all([ + safeExec(execGit, ['branch', '--show-current']), + safeExec(execGit, ['merge-base', base, 'HEAD']) + ]) + if (!mergeBase) { + return null + } + + const range = `${mergeBase}..HEAD` + const [commitSummary, changeSummary, patch] = await Promise.all([ + safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]), + safeExec(execGit, ['diff', '--name-status', range]), + safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range]) + ]) + + if (!commitSummary && !changeSummary && !patch) { + return null + } + + return { + branch: branch || null, + base, + currentTitle: input.currentTitle, + currentBody: input.currentBody, + currentDraft: input.currentDraft, + commitSummary, + changeSummary, + patch + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 12d5a4fc0ce..ff54e93b098 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1431,6 +1431,25 @@ export type PreloadApi = { worktreePath: string connectionId?: string }) => Promise + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }) => Promise< + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + } + | { success: false; error: string; canceled?: boolean } + > + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }) => Promise stage: (args: { worktreePath: string filePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 63936248cae..cf75c0818bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1972,6 +1972,18 @@ const api = { worktreePath: string connectionId?: string }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), stage: (args: { worktreePath: string filePath: string diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 2f8b2b8cd8d..3071e5d27d6 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -138,6 +138,7 @@ export default function ChecksPanel(): React.JSX.Element { // differs from the PR's head ref) resolve via the number-based fallback. const linkedPR = activeWorktree?.linkedPR ?? null const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null + const activeWorktreePath = activeWorktree?.path ?? null const stateRequestKey = repo && branch ? checksPanelAsyncResultKey(repo.id, branch, prNumber) : '' asyncResultKeyRef.current = stateRequestKey @@ -160,6 +161,7 @@ export default function ChecksPanel(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: repo.path, + ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}), branch, base: repo.worktreeBaseRef ?? null, hasUncommittedChanges, @@ -182,6 +184,7 @@ export default function ChecksPanel(): React.JSX.Element { stale = true } }, [ + activeWorktreePath, branch, getHostedReviewCreationEligibility, hasUncommittedChanges, @@ -792,6 +795,8 @@ export default function ChecksPanel(): React.JSX.Element { open={createPrDialogOpen} repoId={repo.id} repoPath={repo.path} + worktreeId={activeWorktreeId} + worktreePath={activeWorktreePath ?? repo.path} branch={branch} eligibility={hostedReviewCreation} pushBeforeCreate={createPrPushFirst} diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index 318328d0039..fe7692ba29f 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { @@ -12,25 +12,22 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { - getRuntimeRepoBaseRefDefault, - searchRuntimeRepoBaseRefs -} from '@/runtime/runtime-repo-client' import type { CreateHostedReviewResult, HostedReviewCreationEligibility } from '../../../../shared/hosted-review' -import { - normalizeHostedReviewBaseRef, - normalizeHostedReviewHeadRef -} from '../../../../shared/hosted-review-refs' +import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' +import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' type CreatePullRequestDialogProps = { open: boolean repoId: string repoPath: string + worktreeId: string | null + worktreePath: string branch: string eligibility: HostedReviewCreationEligibility | null pushBeforeCreate: boolean @@ -39,10 +36,6 @@ type CreatePullRequestDialogProps = { onCreated: (result: { number: number; url: string }) => Promise } -function stripBaseRef(ref: string): string { - return normalizeHostedReviewBaseRef(ref) -} - function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string { if (result.ok) { return '' @@ -57,6 +50,8 @@ export function CreatePullRequestDialog({ open, repoId, repoPath, + worktreeId, + worktreePath, branch, eligibility, pushBeforeCreate, @@ -67,93 +62,52 @@ export function CreatePullRequestDialog({ const settings = useAppStore((s) => s.settings) const createHostedReview = useAppStore((s) => s.createHostedReview) const submitInFlightRef = useRef(false) - const initializedFromEligibilityRef = useRef(null) - const [base, setBase] = useState('') - const [title, setTitle] = useState('') - const [body, setBody] = useState('') - const [draft, setDraft] = useState(false) - const [baseQuery, setBaseQuery] = useState('') - const [baseResults, setBaseResults] = useState([]) - const [baseSearchError, setBaseSearchError] = useState(null) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const { + aiGenerationEnabled, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } = useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting + }) useEffect(() => { - if (!open) { - submitInFlightRef.current = false - initializedFromEligibilityRef.current = null - setSubmitting(false) - setError(null) + if (open) { return } - if (!eligibility) { - return - } - const initializationKey = `${repoId}:${branch}` - if (initializedFromEligibilityRef.current === initializationKey) { - return - } - // Why: eligibility refreshes while the dialog is open; only seed fields - // once per branch so late refreshes (including populated→null transitions - // when a background eligibility fetch errors out) do not overwrite user edits. - initializedFromEligibilityRef.current = initializationKey - const initialBase = eligibility.defaultBaseRef ?? '' - setBase(stripBaseRef(initialBase)) - setTitle(eligibility.title ?? '') - setBody(eligibility.body ?? '') - setDraft(false) - setBaseQuery('') - setBaseResults([]) - setBaseSearchError(null) - }, [branch, eligibility, open, repoId]) - - useEffect(() => { - if (!open || base) { - return - } - let stale = false - void getRuntimeRepoBaseRefDefault(settings, repoId) - .then((result) => { - if (!stale && result.defaultBaseRef) { - setBase(stripBaseRef(result.defaultBaseRef)) - } - }) - .catch(() => undefined) - return () => { - stale = true - } - }, [base, open, repoId, settings]) - - useEffect(() => { - if (!open || baseQuery.trim().length < 2) { - setBaseResults([]) - setBaseSearchError(null) - return - } - let stale = false - const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) - .then((results) => { - if (!stale) { - setBaseResults(results.map(stripBaseRef)) - setBaseSearchError(null) - } - }) - .catch(() => { - if (!stale) { - setBaseResults([]) - setBaseSearchError('Branch discovery failed.') - } - }) - }, 200) - return () => { - stale = true - window.clearTimeout(timer) - } - }, [baseQuery, open, repoId, settings]) + submitInFlightRef.current = false + setSubmitting(false) + setError(null) + }, [open]) const submitDisabled = submitting || + generating || title.trim().length === 0 || base.trim().length === 0 || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() @@ -181,7 +135,8 @@ export function CreatePullRequestDialog({ head: normalizeHostedReviewHeadRef(branch), title: title.trim(), body, - draft + draft, + worktreePath }) if (result.ok) { toast.success(`Pull request #${result.number} created`, { @@ -228,7 +183,8 @@ export function CreatePullRequestDialog({ pushBeforeCreate, repoPath, submitDisabled, - title + title, + worktreePath ]) const handleOpenChange = useCallback( @@ -247,7 +203,47 @@ export function CreatePullRequestDialog({

- Create Pull Request +
+ Create Pull Request + {aiGenerationEnabled ? ( +
+ {generating ? ( + + + + + + Generating PR details. Click to stop. + + + ) : ( + + )} +
+ ) : null} +
Confirm the target branch and PR details before creating the hosted review. @@ -343,6 +339,7 @@ export function CreatePullRequestDialog({ Choose a different base branch before creating a pull request.

) : null} + {generateError ?

{generateError}

: null} {error ?

{error}

: null} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 4d86a39adfc..afcc064b98e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -700,6 +700,7 @@ function SourceControlInner(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: activeRepo.path, + ...(worktreePath ? { worktreePath } : {}), branch: branchName, base: effectiveBaseRef ?? null, hasUncommittedChanges: hasUncommittedEntries, @@ -735,7 +736,8 @@ function SourceControlInner(): React.JSX.Element { linkedGitLabMR, remoteStatus?.ahead, remoteStatus?.behind, - remoteStatus?.hasUpstream + remoteStatus?.hasUpstream, + worktreePath ]) const grouped = useMemo(() => { @@ -1306,7 +1308,9 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, @@ -1319,6 +1323,8 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -1338,7 +1344,9 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, @@ -1351,6 +1359,8 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -2395,6 +2405,8 @@ function SourceControlInner(): React.JSX.Element { open={createPrDialogOpen} repoId={activeRepo.id} repoPath={activeRepo.path} + worktreeId={currentWorktreeId} + worktreePath={activeWorktree.path} branch={branchName} eligibility={hostedReviewCreation} pushBeforeCreate={createPrPushFirst} diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 9cfcca3ed82..33e3611519e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -190,6 +190,21 @@ describe('resolveDropdownItems', () => { expect(byKind.publish.disabled).toBe(false) }) + it('does not show Publish Branch when an unpublished branch has no commits ahead', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + branchCommitsAhead: 0 + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.publish.label).toBe('No Branch Changes') + expect(byKind.publish.title).toBe('Nothing to publish') + expect(byKind.publish.disabled).toBe(true) + }) + it('does not mention Publish Branch when the linked PR is already merged', () => { const items = resolveDropdownItems( inputs({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index eeb11621622..1231ca659fb 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -65,7 +65,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry upstreamStatus, prState, isPRStateLoading, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs const hasStaged = stagedCount > 0 @@ -80,6 +81,7 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const hasUpstream = upstreamStatus?.hasUpstream ?? false const publishBlockedByMergedPR = !hasUpstream && prState === 'merged' const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading + const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0 const ahead = upstreamStatus?.ahead ?? 0 const behind = upstreamStatus?.behind ?? 0 @@ -229,22 +231,30 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const publishItem: DropdownItem = { kind: 'publish', - label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch', + label: + publishBlockedByMergedPR || publishBlockedByPRLoading + ? 'PR Status' + : publishBlockedByNoBranchCommits + ? 'No Branch Changes' + : 'Publish Branch', title: upstreamLoading ? 'Checking branch status…' : publishBlockedByPRLoading ? 'Checking PR status…' : publishBlockedByMergedPR ? 'PR is already merged' - : hasUpstream - ? 'Branch is already published' - : 'Publish this branch to origin', + : publishBlockedByNoBranchCommits + ? 'Nothing to publish' + : hasUpstream + ? 'Branch is already published' + : 'Publish this branch to origin', disabled: globalBusy || upstreamLoading || hasUpstream || publishBlockedByPRLoading || - publishBlockedByMergedPR + publishBlockedByMergedPR || + publishBlockedByNoBranchCommits } const createBlockedHint = (() => { diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index dbec18d243b..6fec6468391 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -174,7 +174,7 @@ describe('resolvePrimaryAction', () => { it('returns Publish Branch on a clean tree when no upstream exists', () => { const result = resolvePrimaryAction( - inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 }) ) expect(result).toEqual({ kind: 'publish', @@ -184,6 +184,18 @@ describe('resolvePrimaryAction', () => { }) }) + it('does not offer Publish Branch when an unpublished branch has no commits ahead', () => { + const result = resolvePrimaryAction( + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 }) + ) + expect(result).toEqual({ + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + }) + }) + it.each([ [{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'], [{ isPRStateLoading: true }, 'Checking PR status…'] diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts index b5266a5e1de..e6bc470dc40 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts @@ -56,6 +56,10 @@ export type PrimaryActionInputs = { // stale label that no longer matches what the slice is doing. inFlightRemoteOpKind?: RemoteOpKind | null hostedReviewCreation?: HostedReviewCreationEligibility | null + // Why: an unpublished branch is only worth publishing when it actually + // carries commits beyond the compare base. Undefined preserves the old + // behavior while the branch compare request is still unavailable/loading. + branchCommitsAhead?: number } const PRIMARY_LABEL_BY_KIND: Record, string> = { @@ -112,7 +116,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction prState, isPRStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs // 1. Commit in flight — lock the primary no matter what else is true. @@ -245,6 +250,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction } if (!upstreamStatus.hasUpstream) { + if (branchCommitsAhead === 0) { + return { + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + } + } + if (isPRStateLoading) { return { kind: 'commit', diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts new file mode 100644 index 00000000000..37bee7d330f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getConnectionId } from '@/lib/connection-context' +import { useAppStore, type AppState } from '@/store' +import { + cancelRuntimeGeneratePullRequestFields, + generateRuntimePullRequestFields +} from '@/runtime/runtime-git-client' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' +import { + isCustomAgentId, + resolveCommitMessageAgentChoice +} from '../../../../shared/commit-message-agent-spec' +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' + +type UseCreatePullRequestDialogFieldsOptions = { + open: boolean + repoId: string + worktreeId: string | null + worktreePath: string + branch: string + eligibility: HostedReviewCreationEligibility | null + settings: AppState['settings'] + submitting: boolean +} + +type GenerationSeed = { + requestId: number + base: string + title: string + body: string + draft: boolean +} + +export function stripBaseRef(ref: string): string { + return normalizeHostedReviewBaseRef(ref) +} + +export function useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting +}: UseCreatePullRequestDialogFieldsOptions) { + const commitMessageAi = settings?.commitMessageAi + const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice( + commitMessageAi?.agentId, + settings?.defaultTuiAgent + ) + const initializedFromEligibilityRef = useRef(null) + const generateInFlightRef = useRef(false) + const generationRequestIdRef = useRef(0) + const generationSeedRef = useRef(null) + const latestFieldsRef = useRef({ + base: '', + title: '', + body: '', + draft: false + }) + const [base, setBase] = useState('') + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [draft, setDraft] = useState(false) + const [baseQuery, setBaseQuery] = useState('') + const [baseResults, setBaseResults] = useState([]) + const [baseSearchError, setBaseSearchError] = useState(null) + const [generating, setGenerating] = useState(false) + const [generateError, setGenerateError] = useState(null) + + useEffect(() => { + latestFieldsRef.current = { base, title, body, draft } + }, [base, body, draft, title]) + + useEffect(() => { + if (!open) { + generationRequestIdRef.current += 1 + if (generateInFlightRef.current && worktreePath) { + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings, + worktreeId, + worktreePath, + connectionId + }) + } + generateInFlightRef.current = false + generationSeedRef.current = null + initializedFromEligibilityRef.current = null + setGenerating(false) + setGenerateError(null) + return + } + if (!eligibility) { + return + } + const initializationKey = `${repoId}:${branch}` + if (initializedFromEligibilityRef.current === initializationKey) { + return + } + // Why: eligibility refreshes while the dialog is open; only seed fields + // once per branch so late refreshes do not overwrite user edits. + initializedFromEligibilityRef.current = initializationKey + const initialBase = eligibility.defaultBaseRef ?? '' + setBase(stripBaseRef(initialBase)) + setTitle(eligibility.title ?? '') + setBody(eligibility.body ?? '') + setDraft(false) + setBaseQuery('') + setBaseResults([]) + setBaseSearchError(null) + setGenerateError(null) + }, [branch, eligibility, open, repoId, settings, worktreeId, worktreePath]) + + useEffect(() => { + if (!open || base) { + return + } + let stale = false + void getRuntimeRepoBaseRefDefault(settings, repoId) + .then((result) => { + if (!stale && result.defaultBaseRef) { + setBase(stripBaseRef(result.defaultBaseRef)) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [base, open, repoId, settings]) + + useEffect(() => { + if (!open || baseQuery.trim().length < 2) { + setBaseResults([]) + setBaseSearchError(null) + return + } + let stale = false + const timer = window.setTimeout(() => { + void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) + .then((results) => { + if (!stale) { + setBaseResults(results.map(stripBaseRef)) + setBaseSearchError(null) + } + }) + .catch(() => { + if (!stale) { + setBaseResults([]) + setBaseSearchError('Branch discovery failed.') + } + }) + }, 200) + return () => { + stale = true + window.clearTimeout(timer) + } + }, [baseQuery, open, repoId, settings]) + + let generateDisabledReason: string | undefined + if (submitting) { + generateDisabledReason = 'Create PR in progress...' + } else if (!commitMessageAi?.enabled) { + generateDisabledReason = 'Enable AI commit messages in Settings -> Git.' + } else if (!effectiveCommitMessageAgentId) { + generateDisabledReason = 'Pick an agent in Settings -> Git -> AI Commit Messages.' + } else if (isCustomAgentId(effectiveCommitMessageAgentId)) { + const command = commitMessageAi.customAgentCommand?.trim() ?? '' + if (!command) { + generateDisabledReason = + 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + } + } else if (!base.trim()) { + generateDisabledReason = 'Choose a base branch before generating.' + } + const generateDisabled = !generating && Boolean(generateDisabledReason) + + const handleGenerate = useCallback(async (): Promise => { + if (!worktreePath || !base.trim() || generateInFlightRef.current || generateDisabled) { + return + } + const requestId = generationRequestIdRef.current + 1 + generationRequestIdRef.current = requestId + const seed = { requestId, base, title, body, draft } + generationSeedRef.current = seed + generateInFlightRef.current = true + setGenerating(true) + setGenerateError(null) + try { + const connectionId = getConnectionId(worktreeId) ?? undefined + const result = await generateRuntimePullRequestFields( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { + base: stripBaseRef(base.trim()), + title, + body, + draft + } + ) + if (generationRequestIdRef.current !== requestId) { + return + } + if (!result.success) { + if (result.canceled) { + setGenerateError(null) + return + } + setGenerateError(result.error) + return + } + + const currentSeed = generationSeedRef.current + const latestFields = latestFieldsRef.current + if ( + !currentSeed || + currentSeed.requestId !== requestId || + currentSeed.base !== latestFields.base || + currentSeed.title !== latestFields.title || + currentSeed.body !== latestFields.body || + currentSeed.draft !== latestFields.draft + ) { + setGenerateError('Fields changed while generating. Run generate again for a fresh draft.') + return + } + setBase(stripBaseRef(result.fields.base)) + setBaseQuery('') + setBaseResults([]) + setTitle(result.fields.title) + setBody(result.fields.body) + setDraft(result.fields.draft) + setGenerateError(null) + } catch (error) { + if (generationRequestIdRef.current !== requestId) { + return + } + setGenerateError( + error instanceof Error ? error.message : 'Failed to generate pull request details' + ) + } finally { + if (generationRequestIdRef.current === requestId) { + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + } + } + }, [base, body, draft, generateDisabled, title, worktreeId, worktreePath]) + + const handleCancelGenerate = useCallback((): void => { + if (!worktreePath || !generateInFlightRef.current) { + return + } + generationRequestIdRef.current += 1 + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + setGenerateError(null) + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }) + }, [worktreeId, worktreePath]) + + return { + aiGenerationEnabled: commitMessageAi?.enabled === true, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } +} diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index d11f78191d6..ebd64b5e092 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -18,6 +18,14 @@ export type RuntimeGenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type RuntimeGeneratePullRequestFieldsResult = + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + } + | { success: false; error: string; canceled?: boolean } + type RuntimeGitSettings = Pick & Partial> @@ -356,6 +364,49 @@ export async function cancelRuntimeGenerateCommitMessage( ) } +export async function generateRuntimePullRequestFields( + context: RuntimeGitContext, + input: { base: string; title: string; body: string; draft: boolean } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.generatePullRequestFields({ + worktreePath: context.worktreePath, + connectionId: context.connectionId, + ...input + }) as Promise + } + return callRuntimeRpc( + target, + 'git.generatePullRequestFields', + { + worktree: context.worktreeId, + ...input, + ...getRuntimeCommitMessageSettings(context.settings) + }, + { timeoutMs: 75_000 } + ) +} + +export async function cancelRuntimeGeneratePullRequestFields( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.cancelGeneratePullRequestFields({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.cancelGeneratePullRequestFields', + { worktree: context.worktreeId }, + { timeoutMs: 5_000 } + ) +} + export async function stageRuntimeGitPath( context: RuntimeGitContext, filePath: string diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts new file mode 100644 index 00000000000..46b64cdf937 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' + +const runtimeRpc = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: runtimeRpc.callRuntimeRpc, + getActiveRuntimeTarget: ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined + ) => { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } + } +})) + +const mockApi = { + hostedReview: { + forBranch: vi.fn(), + getCreationEligibility: vi.fn(), + create: vi.fn() + } +} + +globalThis.window = { api: mockApi } as never + +function makeStore(settings: AppState['settings'] = null) { + return create< + Pick< + AppState, + | 'hostedReviewCache' + | 'fetchHostedReviewForBranch' + | 'getHostedReviewCreationEligibility' + | 'createHostedReview' + | 'settings' + | 'repos' + > + >()((...args) => ({ + settings, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]], + ...createHostedReviewSlice(...(args as Parameters)) + })) +} + +const review: HostedReviewInfo = { + provider: 'gitlab', + number: 5, + title: 'Shared MR status', + state: 'open', + url: 'https://gitlab.com/g/p/-/merge_requests/5', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' +} + +describe('hosted review cache revalidation', () => { + beforeEach(() => { + mockApi.hostedReview.forBranch.mockReset() + mockApi.hostedReview.getCreationEligibility.mockReset() + mockApi.hostedReview.create.mockReset() + runtimeRpc.callRuntimeRpc.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => { + let resolveLinkedLookup: (value: typeof review) => void = () => {} + const linkedLookup = new Promise((resolve) => { + resolveLinkedLookup = resolve + }) + mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup) + const store = makeStore() + + await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( + null + ) + + const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + resolveLinkedLookup(review) + await expect(firstLinkedFetch).resolves.toEqual(review) + await expect(secondLinkedFetch).resolves.toEqual(review) + }) + + it('serves stale hosted review metadata while revalidating in the background', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const updatedReview: HostedReviewInfo = { + ...review, + title: 'Updated linked PR status', + status: 'failure', + updatedAt: '2026-05-10T00:01:01.000Z' + } + let resolveRefresh: (value: typeof updatedReview) => void = () => {} + const refresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + mockApi.hostedReview.forBranch + .mockResolvedValueOnce(review) + .mockReturnValueOnce(refresh as Promise) + const store = makeStore() + + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + ).resolves.toEqual(review) + vi.setSystemTime(60_001) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(review) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr') + expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review) + + resolveRefresh(updatedReview) + await refresh + await Promise.resolve() + + expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview) + }) + + it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const linkedReview: HostedReviewInfo = { + ...review, + provider: 'github', + number: 42, + title: 'Exact linked PR', + url: 'https://github.com/acme/orca/pull/42' + } + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview) + const store = makeStore() + + await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( + review + ) + vi.setSystemTime(60_001) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(linkedReview) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 1cfeedd4758..0086cddaa64 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -1,11 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import { - createHostedReviewSlice, - getHostedReviewCacheKey, - refreshHostedReviewCard -} from './hosted-review' +import { createHostedReviewSlice, refreshHostedReviewCard } from './hosted-review' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ @@ -24,19 +20,30 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ const mockApi = { hostedReview: { - forBranch: vi.fn() + forBranch: vi.fn(), + getCreationEligibility: vi.fn(), + create: vi.fn() } } globalThis.window = { api: mockApi } as never function makeStore(settings: AppState['settings'] = null) { - return create>()( - (...args) => ({ - settings, - ...createHostedReviewSlice(...(args as Parameters)) - }) - ) + return create< + Pick< + AppState, + | 'hostedReviewCache' + | 'fetchHostedReviewForBranch' + | 'getHostedReviewCreationEligibility' + | 'createHostedReview' + | 'settings' + | 'repos' + > + >()((...args) => ({ + settings, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]], + ...createHostedReviewSlice(...(args as Parameters)) + })) } const review: HostedReviewInfo = { @@ -53,6 +60,8 @@ const review: HostedReviewInfo = { describe('hosted review slice', () => { beforeEach(() => { mockApi.hostedReview.forBranch.mockReset() + mockApi.hostedReview.getCreationEligibility.mockReset() + mockApi.hostedReview.create.mockReset() runtimeRpc.callRuntimeRpc.mockReset() }) @@ -115,6 +124,100 @@ describe('hosted review slice', () => { ) }) + it('forwards the selected worktree path when creating a local pull request', async () => { + mockApi.hostedReview.create.mockResolvedValueOnce({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + const store = makeStore() + + await expect( + store.getState().createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: '/worktrees/feature' + }) + ).resolves.toMatchObject({ ok: true, number: 12 }) + + expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ + repoPath: '/repo', + connectionId: null, + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: '/worktrees/feature' + }) + }) + + it('uses the selected worktree selector for runtime pull request creation', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await store.getState().createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: 'C:\\worktrees\\feature' + }) + + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.create', + { + repo: 'repo-1', + worktree: 'path:C:\\worktrees\\feature', + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR' + }, + { timeoutMs: 60_000 } + ) + }) + + it('uses the selected worktree selector for runtime pull request creation eligibility', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({ + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + }) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await store.getState().getHostedReviewCreationEligibility({ + repoPath: '/repo', + worktreePath: 'C:\\worktrees\\feature', + branch: 'feature/create-pr', + base: 'main' + }) + + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.getCreationEligibility', + { + repo: 'repo-1', + worktree: 'path:C:\\worktrees\\feature', + branch: 'feature/create-pr', + base: 'main' + }, + { timeoutMs: 30_000 } + ) + }) + it('forces card refresh with repo-scoped identity and linked review ids', async () => { const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) await refreshHostedReviewCard(fetchHostedReviewForBranch, { @@ -187,104 +290,4 @@ describe('hosted review slice', () => { await expect(plainFetch).resolves.toBeNull() await expect(linkedFetch).resolves.toEqual(review) }) - - it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => { - let resolveLinkedLookup: (value: typeof review) => void = () => {} - const linkedLookup = new Promise((resolve) => { - resolveLinkedLookup = resolve - }) - mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup) - const store = makeStore() - - await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( - null - ) - - const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - resolveLinkedLookup(review) - await expect(firstLinkedFetch).resolves.toEqual(review) - await expect(secondLinkedFetch).resolves.toEqual(review) - }) - - it('serves stale hosted review metadata while revalidating in the background', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const updatedReview: HostedReviewInfo = { - ...review, - title: 'Updated linked PR status', - status: 'failure', - updatedAt: '2026-05-10T00:01:01.000Z' - } - let resolveRefresh: (value: typeof updatedReview) => void = () => {} - const refresh = new Promise((resolve) => { - resolveRefresh = resolve - }) - mockApi.hostedReview.forBranch - .mockResolvedValueOnce(review) - .mockReturnValueOnce(refresh as Promise) - const store = makeStore() - - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - ).resolves.toEqual(review) - vi.setSystemTime(60_001) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(review) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(review) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr') - expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review) - - resolveRefresh(updatedReview) - await refresh - await Promise.resolve() - - expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview) - }) - - it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const linkedReview: HostedReviewInfo = { - ...review, - provider: 'github', - number: 42, - title: 'Exact linked PR', - url: 'https://github.com/acme/orca/pull/42' - } - mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview) - const store = makeStore() - - await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( - review - ) - vi.setSystemTime(60_001) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(linkedReview) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 085b8ebc2ea..1a5dd5f84bd 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -134,12 +134,16 @@ export const createHostedReviewSlice: StateCreator candidate.path === args.repoPath) - const { repoPath: _repoPath, ...runtimeArgs } = args + const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args void _repoPath return callRuntimeRpc( target, 'hostedReview.getCreationEligibility', - { repo: repo?.id ?? args.repoPath, ...runtimeArgs }, + { + repo: repo?.id ?? args.repoPath, + ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}), + ...runtimeArgs + }, { timeoutMs: 30_000 } ) } @@ -155,10 +159,15 @@ export const createHostedReviewSlice: StateCreator candidate.path === repoPath) + const { worktreePath, ...runtimeInput } = input return callRuntimeRpc( target, 'hostedReview.create', - { repo: repo?.id ?? repoPath, ...input }, + { + repo: repo?.id ?? repoPath, + ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}), + ...runtimeInput + }, { timeoutMs: 60_000 } ) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f51f9a02470..27bee11fadc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -553,6 +553,11 @@ function createGitApi(): NonNullable['git']> { error: 'Commit message generation is unavailable in the web client.' }), cancelGenerateCommitMessage: () => Promise.resolve(), + generatePullRequestFields: async () => ({ + success: false, + error: 'Pull request detail generation is unavailable in the web client.' + }), + cancelGeneratePullRequestFields: () => Promise.resolve(), stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), bulkStage: async ({ worktreePath, filePaths }) => mutateGitPaths('git.bulkStage', worktreePath, filePaths), diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 7c86785b432..3424b405d37 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -46,6 +46,7 @@ export type CreateHostedReviewInput = { title: string body?: string draft?: boolean + worktreePath?: string } export type CreateHostedReviewArgs = CreateHostedReviewInput & { @@ -108,6 +109,7 @@ export type HostedReviewCreationEligibility = { export type HostedReviewCreationEligibilityArgs = { repoPath: string + worktreePath?: string connectionId?: string | null branch: string base?: string | null diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts new file mode 100644 index 00000000000..934be13767e --- /dev/null +++ b/src/shared/pull-request-generation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + buildPullRequestFieldsPrompt, + parseGeneratedPullRequestFields, + type PullRequestDraftContext +} from './pull-request-generation' + +const context: PullRequestDraftContext = { + branch: 'feature/pr-details', + base: 'main', + currentTitle: 'Feature pr details', + currentBody: '- Add form', + currentDraft: false, + commitSummary: '- feat: add generated PR details', + changeSummary: 'M\tsrc/file.ts', + patch: 'diff --git a/src/file.ts b/src/file.ts\n+export const value = true' +} + +describe('buildPullRequestFieldsPrompt', () => { + it('asks for compact JSON and includes PR context', () => { + const prompt = buildPullRequestFieldsPrompt(context, 'Use conventional PR titles.') + + expect(prompt).toContain('Return ONLY compact JSON') + expect(prompt).toContain('Head branch: feature/pr-details') + expect(prompt).toContain('Current base: main') + expect(prompt).toContain('Additional instructions from user:') + expect(prompt).toContain('Use conventional PR titles.') + }) +}) + +describe('parseGeneratedPullRequestFields', () => { + it('parses fenced JSON output', () => { + const fields = parseGeneratedPullRequestFields( + '```json\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\n```', + context + ) + + expect(fields).toEqual({ + base: 'main', + title: 'fix: add details', + body: 'Summary', + draft: true + }) + }) + + it('falls back for missing optional values', () => { + const fields = parseGeneratedPullRequestFields('{"title":""}', context) + + expect(fields).toEqual({ + base: 'main', + title: 'Feature pr details', + body: '- Add form', + draft: false + }) + }) +}) diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts new file mode 100644 index 00000000000..96ed52be8b3 --- /dev/null +++ b/src/shared/pull-request-generation.ts @@ -0,0 +1,114 @@ +import { truncateDiffForPrompt } from './commit-message-prompt' + +export type PullRequestDraftContext = { + branch: string | null + base: string + currentTitle: string + currentBody: string + currentDraft: boolean + commitSummary: string + changeSummary: string + patch: string +} + +export type GeneratedPullRequestFields = { + base: string + title: string + body: string + draft: boolean +} + +function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value + } + const omitted = value.length - maxChars + return `${value.slice(0, maxChars)}\n\n[truncated: ${omitted} characters omitted]` +} + +export function buildPullRequestFieldsPrompt( + context: PullRequestDraftContext, + customInstructions: string +): string { + const base = [ + 'You are generating pull request details.', + 'Return ONLY compact JSON with this exact shape:', + '{"base":"branch-name","title":"short title","body":"markdown description","draft":false}', + '', + 'Rules:', + '- Use the branch diff and commits below as source of truth.', + '- Keep the base branch as the current base unless the diff clearly targets a different branch.', + '- Title: concise, specific, no trailing period.', + '- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.', + '- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.', + '- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.', + '', + `Head branch: ${context.branch ?? '(detached)'}`, + `Current base: ${context.base}`, + `Current title: ${context.currentTitle || '(empty)'}`, + `Current description: ${context.currentBody || '(empty)'}`, + `Current draft: ${context.currentDraft ? 'true' : 'false'}`, + '', + 'Commits:', + limitSection(context.commitSummary || '(none)', 8_000), + '', + 'Changed files:', + limitSection(context.changeSummary || '(none)', 8_000), + '', + 'Patch:', + '```diff', + truncateDiffForPrompt(context.patch), + '```' + ].join('\n') + + const trimmedInstructions = customInstructions.trim() + if (!trimmedInstructions) { + return base + } + return [ + base, + '', + 'Additional instructions from user:', + limitSection(trimmedInstructions, 4_000) + ].join('\n') +} + +function stripJsonFence(raw: string): string { + let text = raw.replace(/\r\n/g, '\n').trim() + const fenced = text.match(/^```(?:json)?\n([\s\S]*?)\n```$/i) + if (fenced) { + text = fenced[1].trim() + } + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start !== -1 && end > start) { + return text.slice(start, end + 1) + } + return text +} + +export function parseGeneratedPullRequestFields( + raw: string, + fallback: Pick +): GeneratedPullRequestFields { + const parsed = JSON.parse(stripJsonFence(raw)) as unknown + if (!parsed || typeof parsed !== 'object') { + throw new Error('Expected a JSON object.') + } + const record = parsed as Record + const base = typeof record.base === 'string' ? record.base.trim() : fallback.base + const title = + typeof record.title === 'string' && record.title.trim() + ? record.title.trim().replace(/[.]+$/g, '') + : fallback.currentTitle.trim() + const body = + typeof record.body === 'string' ? record.body.replace(/\s+$/g, '') : fallback.currentBody + const draft = typeof record.draft === 'boolean' ? record.draft : fallback.currentDraft + + return { + base: base || fallback.base, + title: title || 'Update project files', + body, + draft + } +} From 503b44a2c71fe86085b29825ab7651645cda0e00 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 16 May 2026 23:15:01 -0700 Subject: [PATCH 155/177] fix: drain runtime watcher unsubscribes (#2142) --- src/main/index.ts | 2 + ...lesystem-watcher-local-unsubscribe.test.ts | 41 ++++++++++-- src/main/ipc/filesystem-watcher.ts | 6 +- src/main/runtime/orca-runtime-files.test.ts | 65 +++++++++++++++++-- src/main/runtime/orca-runtime-files.ts | 26 +++++++- 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 7458db5f1e7..8b93367376c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,7 @@ import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' +import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' @@ -1060,6 +1061,7 @@ app.on('will-quit', (e) => { const rpcStopAndClear = runtimeRpc ? runtimeRpc .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) .then(() => { if (ownedRuntimeId) { clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId) diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 4384b6a8718..427b8ee39f1 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -32,11 +32,10 @@ import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' type HandlerMap = Record Promise | unknown> -describe('local filesystem watcher shutdown', () => { +describe('local filesystem watcher unsubscribe cleanup', () => { const handlers: HandlerMap = {} - beforeEach(() => { - vi.useRealTimers() + beforeEach(async () => { handleMock.mockReset() vi.mocked(stat).mockReset() vi.mocked(subscribeParcelWatcher).mockReset() @@ -47,9 +46,10 @@ describe('local filesystem watcher shutdown', () => { handlers[channel] = handler }) registerFilesystemWatcherHandlers() + await closeAllWatchers() }) - it('awaits unsubscribe already started by sender cleanup during shutdown', async () => { + it('awaits an unsubscribe already started by sender cleanup during shutdown', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) let resolveUnsubscribe: () => void = () => {} const unsubscribeMock = vi.fn( @@ -87,4 +87,37 @@ describe('local filesystem watcher shutdown', () => { await shutdownPromise expect(shutdownResolved).toBe(true) }) + + it('awaits an unsubscribe already started by watcher error cleanup during shutdown', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: (err: Error | null, events: []) => void = () => {} + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: unsubscribeMock } as never + }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + watcherCallback(new Error('root disappeared'), []) + + let shutdownResolved = false + const shutdownPromise = closeAllWatchers().then(() => { + shutdownResolved = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(shutdownResolved).toBe(false) + + resolveUnsubscribe() + await shutdownPromise + expect(shutdownResolved).toBe(true) + }) }) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 79d3d38b187..afaeb9a8d4a 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -278,9 +278,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { - // Already errored — ignore cleanup failures - }) + void trackLocalUnsubscribe(rootKey, root) } errorCleanedUp = true watchedRoots.delete(rootKey) @@ -298,7 +296,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise {}) + void trackLocalUnsubscribe(rootKey, root) throw new Error(`Watcher for ${rootKey} errored during subscribe`) } } catch (err) { diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e77943d1049..e18e287f1ee 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -3,11 +3,14 @@ import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' -const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - watchMock: vi.fn() -})) +const { resolveAuthorizedPathMock, statMock, subscribeParcelWatcherMock, watchMock } = vi.hoisted( + () => ({ + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + subscribeParcelWatcherMock: vi.fn(), + watchMock: vi.fn() + }) +) vi.mock('fs', async () => { const actual = await vi.importActual('fs') @@ -25,6 +28,10 @@ vi.mock('fs/promises', async () => { } }) +vi.mock('@parcel/watcher', () => ({ + subscribe: subscribeParcelWatcherMock +})) + vi.mock('../ipc/filesystem-auth', async () => { const actual = await vi.importActual('../ipc/filesystem-auth') return { @@ -37,7 +44,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn() })) -import { RuntimeFileCommands } from './orca-runtime-files' +import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -46,6 +53,7 @@ describe('RuntimeFileCommands', () => { vi.useFakeTimers() resolveAuthorizedPathMock.mockReset() statMock.mockReset() + subscribeParcelWatcherMock.mockReset() watchMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, @@ -53,7 +61,8 @@ describe('RuntimeFileCommands', () => { }) }) - afterEach(() => { + afterEach(async () => { + await awaitRuntimeFileWatcherUnsubscribes() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -109,4 +118,46 @@ describe('RuntimeFileCommands', () => { unsubscribe() expect(close).toHaveBeenCalledTimes(1) }) + + it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => { + const store = { getRepo: vi.fn(() => undefined) } + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock }) + + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + unsubscribe() + + let drained = false + const drainPromise = awaitRuntimeFileWatcherUnsubscribes().then(() => { + drained = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(drained).toBe(false) + + resolveUnsubscribe() + await drainPromise + expect(drained).toBe(true) + }) }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index ca326ae42ad..2346e4c02ff 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -56,6 +56,9 @@ const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +// Why: runtime files.watch subscriptions are cleaned up through synchronous RPC +// callbacks. Track native Parcel unsubscribe work so app shutdown can drain it. +const pendingRuntimeFileWatcherUnsubscribes = new Set>() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -84,6 +87,25 @@ const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } +function trackRuntimeFileWatcherUnsubscribe( + rootPath: string, + unsubscribe: () => Promise +): void { + const promise = Promise.resolve() + .then(unsubscribe) + .catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + .finally(() => { + pendingRuntimeFileWatcherUnsubscribes.delete(promise) + }) + pendingRuntimeFileWatcherUnsubscribes.add(promise) +} + +export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { + await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) +} + export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } export type RuntimeFileCommandHost = { @@ -269,9 +291,7 @@ export class RuntimeFileCommands { } ) return () => { - void subscription.unsubscribe().catch((err: unknown) => { - console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) - }) + trackRuntimeFileWatcherUnsubscribe(rootPath, () => subscription.unsubscribe()) } } From 673e558f49bbc5328ad61cbdbf8d602f9eb75bd5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 17 May 2026 00:07:42 -0700 Subject: [PATCH 156/177] fix: address review findings (#2143) --- src/main/codex-accounts/runtime-home-service.test.ts | 2 +- src/main/codex-accounts/service.test.ts | 2 +- src/main/persistence.test.ts | 2 +- src/renderer/src/components/editor/CombinedDiffViewer.tsx | 4 +++- src/shared/constants.ts | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 498294880fe..ec15accdfed 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,7 +74,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalCwd: '~', floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', - combinedDiffFileTreeVisibleByDefault: true, + combinedDiffFileTreeVisibleByDefault: false, notifications: { enabled: true, agentTaskComplete: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index ca978d04c28..27a881a9ca5 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -67,7 +67,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalCwd: '~', floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', - combinedDiffFileTreeVisibleByDefault: true, + combinedDiffFileTreeVisibleByDefault: false, notifications: { enabled: true, agentTaskComplete: true, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 01cdd7feafd..d74124435e0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -596,7 +596,7 @@ describe('Store', () => { expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) - expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(true) + expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false) expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(store.getSettings().experimentalActivity).toBe(true) expect(store.getSettings().notifications.customSoundPath).toBeNull() diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index fcb7262094a..58e6a7eedae 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -121,7 +121,9 @@ function getInitialCombinedDiffSideBySide(diffDefaultView: string | undefined): function getInitialCombinedDiffFileTreeCollapsed( combinedDiffFileTreeVisibleByDefault: boolean | undefined ): boolean { - return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault === false + // Why: the tree is opt-in for new sessions; only an explicit saved setting + // should make it the opening surface while settings are still loading. + return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true } function commitMessageBody(message: string | undefined, subject: string | undefined): string { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 92df6558f73..7ce34db456d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -222,7 +222,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { floatingTerminalTriggerLocation: 'floating-button', notifications: getDefaultNotificationSettings(), diffDefaultView: 'inline', - combinedDiffFileTreeVisibleByDefault: true, + combinedDiffFileTreeVisibleByDefault: false, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, codexManagedAccounts: [], From 8370d78e715ea536534bf0440da79189a07bcd5c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 17 May 2026 05:47:12 -0400 Subject: [PATCH 157/177] Fix Windows Claude CLI detection (#2145) --- src/main/codex-cli/command.test.ts | 18 ++++++ src/main/codex-cli/command.ts | 3 + src/main/startup/configure-process.test.ts | 24 +++++++- src/main/startup/configure-process.ts | 64 +++++++++++++--------- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index 65e773d9663..e88d432d790 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -158,6 +158,16 @@ describe('resolveClaudeCommand', () => { expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath) }) + it('finds native Windows claude.exe in user-local bin', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const nativePath = join(root, '.local', 'bin', 'claude.exe') + makeExecutable(nativePath) + + expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe( + nativePath + ) + }) + it('returns the bare command when no filesystem candidate exists', () => { const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) @@ -195,4 +205,12 @@ describe('getVersionManagerBinPaths', () => { expect(paths).toContain(join(root, '.local', 'share', 'pnpm')) expect(paths).not.toContain(join(root, 'Library', 'pnpm')) }) + + it('includes Windows user-local bin for native CLI installers', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root }) + + expect(paths).toContain(join(root, '.local', 'bin')) + expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm')) + }) }) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 2215583ac54..c11cf07ff78 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -98,6 +98,9 @@ function getVersionManagerDirectories( } if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 79439432426..2552323e9eb 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,3 +1,4 @@ +import { homedir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -70,9 +71,9 @@ describe('patchPackagedProcessPath', () => { // fallback install locations for the opencode and Pi CLI install scripts. // Without them on PATH, GUI-launched Orca reports both as "Not installed" // even when `which` resolves them in the user's shell. - expect(segments).toContain('/Users/tester/.opencode/bin') - expect(segments).toContain('/Users/tester/.vite-plus/bin') - expect(segments).toContain('/Users/tester/bin') + expect(segments).toContain(join('/Users/tester', '.opencode/bin')) + expect(segments).toContain(join('/Users/tester', '.vite-plus/bin')) + expect(segments).toContain(join('/Users/tester', 'bin')) }) it('leaves PATH untouched when the app is not packaged', async () => { @@ -88,6 +89,23 @@ describe('patchPackagedProcessPath', () => { expect(process.env.PATH).toBe('/usr/bin:/bin') }) + + it('prepends Windows user-local CLI dirs for packaged Start Menu launches', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('win32') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + const pathDelimiter = process.platform === 'win32' ? ';' : ':' + process.env.PATH = `C:\\Windows\\System32${pathDelimiter}C:\\Windows` + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(pathDelimiter) + const userLocalBin = join(homedir(), '.local', 'bin') + expect(segments).toContain(userLocalBin) + expect(segments.indexOf(userLocalBin)).toBeLessThan(segments.indexOf('C:\\Windows\\System32')) + }) }) describe('configureDevUserDataPath', () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 66d842beb05..7a23599b3b6 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -5,6 +5,10 @@ import { getMainE2EConfig } from '../e2e-config' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 +function getProcessPathDelimiter(): string { + return process.platform === 'win32' ? ';' : ':' +} + function requestDevParentShutdown(): void { app.quit() @@ -36,35 +40,39 @@ export function installUncaughtPipeErrorGuard(): void { } export function patchPackagedProcessPath(): void { - if (!app.isPackaged || process.platform === 'win32') { + if (!app.isPackaged) { return } const home = process.env.HOME ?? '' - const extraPaths = [ - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/bin', - '/usr/local/sbin', - '/snap/bin', - '/home/linuxbrew/.linuxbrew/bin', - '/nix/var/nix/profiles/default/bin' - ] + const extraPaths: string[] = [] - if (home) { + if (process.platform !== 'win32') { extraPaths.push( - join(home, 'bin'), - join(home, '.local/bin'), - join(home, '.nix-profile/bin'), - // Why: several agent CLIs ship install scripts that drop binaries into - // tool-specific ~/./bin directories (opencode's documented fallback, - // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH - // without shell rc files, so these stay invisible to `which` probes — and - // the Agents settings page reports them as "Not installed" even when the - // user can run them from Terminal. See stablyai/orca#829. - join(home, '.opencode/bin'), - join(home, '.vite-plus/bin') + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + '/snap/bin', + '/home/linuxbrew/.linuxbrew/bin', + '/nix/var/nix/profiles/default/bin' ) + + if (home) { + extraPaths.push( + join(home, 'bin'), + join(home, '.local/bin'), + join(home, '.nix-profile/bin'), + // Why: several agent CLIs ship install scripts that drop binaries into + // tool-specific ~/./bin directories (opencode's documented fallback, + // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH + // without shell rc files, so these stay invisible to `which` probes — and + // the Agents settings page reports them as "Not installed" even when the + // user can run them from Terminal. See stablyai/orca#829. + join(home, '.opencode/bin'), + join(home, '.vite-plus/bin') + ) + } } // Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm, @@ -72,14 +80,20 @@ export function patchPackagedProcessPath(): void { // resolveCodexCommand() can locate the codex binary in these directories, but // spawning it still fails if node itself isn't in PATH. Adding version manager // bin paths here fixes all spawn sites (login, rate limits, usage tracking). + // On Windows this also seeds user-local installer dirs, since shell hydration + // is POSIX-only and Start Menu launches can miss user-level PATH updates. extraPaths.push(...getVersionManagerBinPaths()) - const currentPath = process.env.PATH ?? '' - const existing = new Set(currentPath.split(':')) + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const currentPath = process.env[pathKey] ?? '' + const pathDelimiter = getProcessPathDelimiter() + const existing = new Set(currentPath.split(pathDelimiter)) const missing = extraPaths.filter((path) => !existing.has(path)) if (missing.length > 0) { - process.env.PATH = [...missing, ...currentPath.split(':').filter(Boolean)].join(':') + process.env[pathKey] = [...missing, ...currentPath.split(pathDelimiter).filter(Boolean)].join( + pathDelimiter + ) } } From 664849a7087e7b5c374e18851dac59b0e277a1f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 10:34:25 +0000 Subject: [PATCH 158/177] release: v1.4.4-rc.0 [rc-slot:2026-05-17-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a3f875859e..ea489c6d83c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.3", + "version": "1.4.4-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f9191c6f2b60c40524ba2a53ceaadc12faa468b8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 17 May 2026 13:24:45 -0400 Subject: [PATCH 159/177] fix(status-bar): restore weekly usage label (#2149) Co-authored-by: Orca --- src/main/rate-limits/codex-fetcher.ts | 2 +- src/renderer/src/lib/window-label-formatter.test.ts | 4 ++-- src/renderer/src/lib/window-label-formatter.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c3cc96d659f..a3143daf1ba 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -74,7 +74,7 @@ function mapRpcWindow( return { usedPercent: Math.min(100, Math.max(0, raw.usedPercent)), // Why: Codex currently reports remaining minutes in `windowDurationMins`. - // Orca's UI needs the fixed bucket duration so labels stay "5h" / "7d". + // Orca's UI needs the fixed bucket duration so labels stay "5h" / "wk". windowMinutes: expectedWindowMinutes, resetsAt, resetDescription diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 57364dc25a7..730afbbd5ad 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -10,8 +10,8 @@ describe('formatWindowLabel', () => { expect(formatWindowLabel(60)).toBe('1h') }) - it('returns "7d" for 10080 minutes (7 days)', () => { - expect(formatWindowLabel(10080)).toBe('7d') + it('returns "wk" for 10080 minutes (7 days)', () => { + expect(formatWindowLabel(10080)).toBe('wk') }) it('returns "1d" for 1440 minutes (1 day)', () => { diff --git a/src/renderer/src/lib/window-label-formatter.ts b/src/renderer/src/lib/window-label-formatter.ts index c233c6df9f2..1fbbcdb54f5 100644 --- a/src/renderer/src/lib/window-label-formatter.ts +++ b/src/renderer/src/lib/window-label-formatter.ts @@ -1,12 +1,12 @@ /** * Returns a short human-readable label for a usage window duration. * - * Why: the status bar uses duration labels, so the weekly bucket is shown as - * "7d" instead of a calendar-period label like "wk". + * Why: 10080 minutes (7 days) is hard-coded as "wk" for backward + * compatibility with the original StatusBar implementation. */ export function formatWindowLabel(windowMinutes: number): string { if (windowMinutes === 10080) { - return '7d' + return 'wk' } if (windowMinutes === 300) { return '5h' From a9fbcbc0f6eab3b7f87b074f318627c33425cf4f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 17 May 2026 10:58:50 -0700 Subject: [PATCH 160/177] Add feature tour Help menu hint (#2150) Co-authored-by: Orca --- .../src/components/feature-wall/FeatureTourNudge.tsx | 8 +++++++- .../src/components/feature-wall/FeatureWallModal.tsx | 3 ++- tests/e2e/feature-wall.spec.ts | 7 ++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx b/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx index 843d07abc78..d8347561ccf 100644 --- a/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx +++ b/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx @@ -127,9 +127,15 @@ export function FeatureTourNudge(): JSX.Element | null { )} -

+

{FEATURE_TOUR_NUDGE_TILE.caption}

+

+ Reopen this any time from Help > Feature tour. +

+ + + + Project actions + + + event.stopPropagation()} + > + { + if (row.repo) { + handleRemoveRepo(row.repo) + } + }} + > + + Remove Project + + + + ) : null} + {row.repo && groupBy === 'repo' ? ( @@ -865,6 +923,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp size="icon-xs" className="size-5 shrink-0 rounded-md text-muted-foreground hover:bg-accent/70 hover:text-foreground transition-opacity" aria-label={`Create worktree for ${row.label}`} + onKeyDown={stopRepoHeaderKeyboardToggle} onClick={(event) => { event.preventDefault() event.stopPropagation() @@ -1618,6 +1677,16 @@ const WorktreeList = React.memo(function WorktreeList({ [openModal] ) + const handleRemoveRepo = useCallback( + (repo: Repo) => { + openModal('confirm-remove-folder', { + repoId: repo.id, + displayName: repo.displayName + }) + }, + [openModal] + ) + const moveWorktreeToStatus = useCallback( (worktreeId: string, status: WorkspaceStatus) => { const current = worktreeMap.get(worktreeId) @@ -1732,6 +1801,7 @@ const WorktreeList = React.memo(function WorktreeList({ toggleGroup={toggleGroup} collapsedGroups={collapsedGroups} handleCreateForRepo={handleCreateForRepo} + handleRemoveRepo={handleRemoveRepo} activeModal={activeModal} pendingRevealWorktreeId={pendingRevealWorktreeId} clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} From c9b12d1618cab04fa84be5492b87d2580c78752b Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 14:55:21 -0700 Subject: [PATCH 166/177] fix: pr-bug-scan validated finding from #2130 (#2140) Co-authored-by: orca-bug-scan-bot Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/automations/external-manager.ts | 10 ++- .../automations/hermes-cron-output.test.ts | 25 ++++++ src/main/automations/hermes-cron-output.ts | 40 ++++++++- .../external-automations-handler.test.ts | 88 +++++++++++++++++++ src/relay/external-automations-handler.ts | 42 ++++++++- 5 files changed, 202 insertions(+), 3 deletions(-) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 99437378abd..08c3e208cf6 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -20,7 +20,10 @@ import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' import { getActiveMultiplexer } from '../ipc/ssh' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' -import { readHermesCronOutputRunsPage } from './hermes-cron-output' +import { + clearHermesCronOutputRunCountCache, + readHermesCronOutputRunsPage +} from './hermes-cron-output' const execFileAsync = promisify(execFile) const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') @@ -398,6 +401,7 @@ export async function createExternalAutomation( const normalized = normalizeHermesCronMutationInput(input) if (input.target.type === 'local') { await execFileAsync('hermes', hermesCronCreateArgs(normalized), { encoding: 'utf-8' }) + clearHermesCronOutputRunCountCache() return } const mux = getActiveMultiplexer(input.target.connectionId) @@ -421,6 +425,7 @@ export async function updateExternalAutomation( await execFileAsync('hermes', hermesCronEditArgs(input.jobId, normalized), { encoding: 'utf-8' }) + clearHermesCronOutputRunCountCache(input.jobId) return } const mux = getActiveMultiplexer(input.target.connectionId) @@ -446,6 +451,9 @@ export async function runExternalAutomationAction( : openClawCommandForAction(input.action) if (input.target.type === 'local') { await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' }) + if (input.provider === 'hermes') { + clearHermesCronOutputRunCountCache(input.jobId) + } return } const mux = getActiveMultiplexer(input.target.connectionId) diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 7627e3a8f22..b0eb79867d6 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -204,4 +204,29 @@ Run summary: monitor automation completed successfully. expect(page).toEqual({ total: 1, runs: [] }) expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false) }) + + it('caches count-only reads until the cache is cleared', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + await mkdir(outputDir, { recursive: true }) + await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), 'first run', 'utf-8') + + const { clearHermesCronOutputRunCountCache, readHermesCronOutputRunsPage } = await loadReader() + await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({ + total: 1, + runs: [] + }) + + await writeFile(join(outputDir, '2026-05-15_09-03-00.md'), 'second run', 'utf-8') + await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({ + total: 1, + runs: [] + }) + + clearHermesCronOutputRunCountCache('job-1') + await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({ + total: 2, + runs: [] + }) + }) }) diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index 53dd3be15fa..a54400dc7fc 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -401,8 +401,46 @@ async function readHermesCronOutputRunRefs(jobId: string): Promise + expiresAt: number +} +const hermesRunCountCache = new Map() + +export function clearHermesCronOutputRunCountCache(jobId?: string): void { + if (jobId) { + hermesRunCountCache.delete(jobId) + return + } + hermesRunCountCache.clear() +} + async function readHermesCronOutputRunCount(jobId: string): Promise { - return (await readHermesCronOutputRunRefs(jobId)).length + const now = Date.now() + const cached = hermesRunCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + const entry: HermesRunCountCacheEntry = { + promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length), + expiresAt: Number.POSITIVE_INFINITY + } + hermesRunCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (hermesRunCountCache.get(jobId) === entry) { + hermesRunCountCache.delete(jobId) + } + throw error + } } async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index ce33422dec7..2228ff57c4d 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -107,4 +107,92 @@ describe('ExternalAutomationsHandler', () => { expect(result).toEqual({ total: 42, runs: [] }) }) + + it('deduplicates concurrent remote Hermes count reads', async () => { + const { handler, requestHandlers } = createHandlerHarness() + let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} + const readHermesRunRefs = vi.fn( + () => + new Promise<{ id: string; run_at: string }[]>((resolve) => { + resolveRefs = resolve + }) + ) + const handlerInternals = handler as unknown as { + readHermesRunRefs: typeof readHermesRunRefs + } + handlerInternals.readHermesRunRefs = readHermesRunRefs + + const first = requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + const second = requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + + expect(readHermesRunRefs).toHaveBeenCalledTimes(1) + resolveRefs([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { total: 2, runs: [] }, + { total: 2, runs: [] } + ]) + }) + + it('clears the remote Hermes count cache after lifecycle actions', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const readHermesRunRefs = vi + .fn() + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } + ]) + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) + const handlerInternals = handler as unknown as { + readHermesRunRefs: typeof readHermesRunRefs + } + handlerInternals.readHermesRunRefs = readHermesRunRefs + + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + + await requestHandlers.get('externalAutomations.act')?.({ + provider: 'hermes', + action: 'run', + jobId: 'job-1' + }) + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readHermesRunRefs).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 91adf78d6af..bc9956d1ff7 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -55,8 +55,16 @@ type HermesMergedRunRef = { output: HermesOutputRunRef | null session: HermesSessionRunRef | null } +type HermesRunCountCacheEntry = { + promise: Promise + expiresAt: number +} + +const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 export class ExternalAutomationsHandler { + private readonly hermesRunCountCache = new Map() + constructor(private readonly dispatcher: RelayDispatcher) { this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params)) this.dispatcher.onRequest('externalAutomations.runs', (params) => this.listRuns(params)) @@ -510,7 +518,34 @@ export class ExternalAutomationsHandler { if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { return 0 } - return (await this.readHermesRunRefs(jobId)).length + const now = Date.now() + const cached = this.hermesRunCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + const entry: HermesRunCountCacheEntry = { + promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), + expiresAt: Number.POSITIVE_INFINITY + } + this.hermesRunCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (this.hermesRunCountCache.get(jobId) === entry) { + this.hermesRunCountCache.delete(jobId) + } + throw error + } + } + + private clearHermesRunCountCache(jobId?: string): void { + if (jobId) { + this.hermesRunCountCache.delete(jobId) + return + } + this.hermesRunCountCache.clear() } private async listRuns(params: Record = {}): Promise<{ @@ -808,6 +843,7 @@ export class ExternalAutomationsHandler { args.push('--workdir', input.workdir) } await this.runHermesCronCommand(args) + this.clearHermesRunCountCache() return { ok: true } } @@ -832,6 +868,7 @@ export class ExternalAutomationsHandler { args.push('--workdir', input.workdir) } await this.runHermesCronCommand(args) + this.clearHermesRunCountCache(jobId) return { ok: true } } @@ -851,6 +888,9 @@ export class ExternalAutomationsHandler { encoding: 'utf-8', timeout: 30_000 }) + if (provider === 'hermes') { + this.clearHermesRunCountCache(jobId) + } return { ok: true } } } From 9e30fe31bd4cbd278753eecf5b8e79a0de86c35e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 17 May 2026 15:03:15 -0700 Subject: [PATCH 167/177] Fix Kanban unread bell dismissal (#2163) --- .../WorktreeCard.quick-actions.test.tsx | 113 ++++++++++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 11 ++ 2 files changed, 124 insertions(+) create mode 100644 src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx new file mode 100644 index 00000000000..fa32f0d2d13 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -0,0 +1,113 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const openModal = vi.fn() +const updateWorktreeMeta = vi.fn() + +let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'unread'] + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + openModal, + remoteBranchConflictByWorktreeId: {}, + settings: null, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + worktreeCardProperties + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'idle' +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () => null +})) + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/worktrees/quick-action', + repoId: 'repo-1', + path: '/repo/worktrees/quick-action', + displayName: 'Quick action', + branch: 'quick-action', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +describe('WorktreeCard quick actions', () => { + beforeEach(() => { + vi.clearAllMocks() + worktreeCardProperties = ['status', 'unread'] + }) + + it('marks the unread toggle as a workspace-board-preserving action', async () => { + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-label="Mark as read"') + expect(markup).toContain('data-workspace-board-preserve-open=""') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 13bb326d4d5..13455fc6c30 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -336,6 +336,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ [isDeleting, isMultiSelected, selectedWorktrees, worktree.id] ) + const stopQuickActionPointerPropagation = useCallback( + (event: React.PointerEvent) => { + // Why: the Kanban board is dismissed by document-level pointer handling. + // Quick card actions mutate metadata, but must not count as card activation. + event.stopPropagation() + }, + [] + ) + // Why: the 'unread' card property is the user's opt-out. When off, we render // as if the workspace is read so bold emphasis never appears. The persisted // `worktree.isUnread` flag is unchanged; only the rendering changes. @@ -386,6 +395,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ + + + Collapse All + + + + + {gitIgnoredLabel} + + + ) : null}