= {}): 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 (
GH Issue
- 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"
+ />
+
+
+
+ {openingIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ 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 && (
-
+
+
+
+
+ Clean up old workspaces ({oldWorkspaceCount})
+
+
+
+ {orphanCount > 0 ? (
void handleKillOrphans()}
- className="inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
+ className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
-
- )}
+ ) : 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
+
+
+ closeModal()}
+ disabled={removing}
+ >
+
+
+
+
+
+
+ {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}
+
+
+ setConfirming(true)}
+ disabled={selectedCount === 0}
+ >
+
+ Remove selected
+
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : scanNoticeMessage ? (
+
+
+
{scanNoticeMessage}
+
+ ) : null}
+
+
+
+
+
+
+ {activeView !== 'hidden' && activeQueueableRows.length > 0 ? (
+
+ {activeSelectionState === 'checked' ? (
+
+ ) : activeSelectionState === 'mixed' ? (
+
+ ) : null}
+
+ ) : null}
+
+ {activeView === 'hidden'
+ ? 'Ignored cleanup suggestions'
+ : TIER_LABELS[activeView]}
+
+
+ {activeView === 'hidden' && hiddenByKeepCount > 0 ? (
+
void resetDismissals()}
+ >
+ Restore ignored suggestions
+
+ ) : (
+
+ 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 (
+
+
+ {items.map((item) => (
+ onViewChange(item.view)}
+ >
+ {item.label}
+ {counts[item.view]}
+
+ ))}
+
+
+ )
+}
+
+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 ? (
+
onToggleSelected(candidate.worktreeId)}
+ className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent"
+ >
+ {selected ? : null}
+
+ ) : (
+
+ )}
+
+
+ {candidate.displayName}
+ {status.label}
+
+ Last active {formatRelativeTime(candidate.lastActivityAt)}
+
+ {blockers.length > 0 ? (
+
+ {blockers.slice(0, 2).join(', ')}
+
+ ) : null}
+
+
+ {candidate.path}
+
+
+ Repo {candidate.repoName}
+ Branch {candidate.branch}
+ {formatGitStatus(candidate)}
+ {branchSafetyDetails.slice(0, 1).map((detail) => (
+ {detail}
+ ))}
+ {contextDetails ? {contextDetails} : null}
+
+ {failure ? (
+
+ ) : null}
+
+
+
+
+ onView(candidate)}
+ >
+
+
+
+
+ View
+
+
+ {!ignored ? (
+
+
+ onIgnore(candidate)}
+ >
+
+
+
+
+ Ignore
+
+
+ ) : null}
+ {selectable ? (
+
+
+ onRemove(candidate)}
+ >
+
+
+
+
+ Remove
+
+
+ ) : null}
+
+
+
+ )
+}
+
+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.
+
+
+
+ Cancel
+
+
+ {removing ? : }
+ Remove {count}
+
+
+ >
+ )
+}
+
+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 ? (
+
+ {actionLabel}
+
+ ) : 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 (
+ toggleProvider(option.id)}
+ className={cn(
+ 'flex w-full items-center gap-3 rounded-md border border-border/60 px-3 py-2.5 text-left transition-colors',
+ enabled
+ ? 'bg-accent/70 text-accent-foreground'
+ : 'bg-transparent hover:bg-muted/50',
+ isLastEnabled && 'cursor-not-allowed'
+ )}
+ >
+
+
+
+
+ {option.label}
+ {option.description}
+
+
+ {enabled ? : null}
+
+
+ )
+ })}
+
+
+
+ )
+}
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. */}
-
-
-
- {
- event.stopPropagation()
- onJump()
- }}
- onMouseDown={(event) => event.stopPropagation()}
- >
-
-
-
- Jump to workspace
-
-
+ {canJump ? (
+
+
+
+ {
+ event.stopPropagation()
+ onJump()
+ }}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
+
+
+ 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.
+
+
+
void setOpenCodeUsageEnabled(true)}
+ className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
+ >
+
+
+
+
+ )
+ }
+
+ 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]}
+
+ ))}
+
+
+
+
+
+
+ void refreshOpenCodeUsage()}
+ disabled={scanState.isScanning}
+ aria-label="Refresh OpenCode usage"
+ >
+
+
+
+
+ Refresh
+
+
+
+ void setOpenCodeUsageEnabled(false)}
+ className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
+ >
+
+
+
+
+
+
+
+ {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.
+
+
+
+
+
+
+ Last active
+ Project
+ Model
+ Events
+ Input
+ Output
+ Total
+
+
+
+ {recentSessions.map((row) => (
+
+
+ {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) => (
-
+
+ setActiveUsageTab(tab)}
- className={cn(
- 'inline-flex h-8 items-center justify-center rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all',
- activeUsageTab === tab
- ? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
- : 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
- )}
+ variant="outline"
+ size="sm"
+ data-testid="usage-provider-select"
+ aria-label={`Usage analytics provider: ${activeUsageOption.label}`}
+ className="min-w-36 justify-between"
>
- {tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
-
- ))}
-
+
+
+ {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 {
void enableCodexUsage()}>
Enable Codex
+ void enableOpenCodeUsage()}>
+ Enable OpenCode
+
@@ -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