diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 0148b3cbafd..61f38c081c4 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -2,7 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
-const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs')
+const {
+ assertPackagedDaemonEntryExists,
+ verifyPackagedDaemonEntryBoots
+} = require('./scripts/verify-packaged-daemon-entry.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
@@ -141,8 +144,12 @@ module.exports = {
if (context.arch === hostArchEnum || context.arch === 4) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
+ // Why: a cross-arch slice can't be booted by the host Node, but the
+ // unpacked entry must still exist — its absence is a layout regression
+ // regardless of arch, so only the boot is skipped, not the check.
+ assertPackagedDaemonEntryExists(resourcesDir)
console.log(
- `[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})`
+ `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 850b8810f9b..cd46c8003fa 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -353,6 +353,15 @@ describe('electron-builder config', () => {
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
+ // Why: afterPack now fails hard when the unpacked daemon entry is
+ // missing, so the fixture must carry one like a real package layout.
+ const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
+ await mkdir(unpackedMainDir, { recursive: true })
+ await writeFile(
+ join(unpackedMainDir, 'daemon-entry.js'),
+ 'console.error("Usage: daemon-entry "); process.exit(1)\n',
+ 'utf8'
+ )
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs
index 6ad91285ca3..8f5e802825c 100644
--- a/config/scripts/verify-packaged-daemon-entry.cjs
+++ b/config/scripts/verify-packaged-daemon-entry.cjs
@@ -2,6 +2,24 @@ const { existsSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
+// Why: `asarUnpack` in config/electron-builder.config.cjs lists
+// out/main/daemon-entry.js on every platform, and the packaged daemon fork
+// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A
+// missing entry means the package layout regressed, so the check throws
+// instead of skipping — a silent skip false-passed exactly the layout bug
+// this gate exists to catch.
+function assertPackagedDaemonEntryExists(resourcesDir) {
+ const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
+ if (!existsSync(entryPath)) {
+ throw new Error(
+ `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` +
+ `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` +
+ `daemon cannot be forked from this layout`
+ )
+ }
+ return entryPath
+}
+
// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron
// `require` leaked into its bundle) while every build check passed. This boots
// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout,
@@ -14,13 +32,7 @@ const { join } = require('node:path')
// /resources elsewhere). execPath defaults to the packaging Node.
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
const execPath = options.execPath || process.execPath
- const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
- if (!existsSync(entryPath)) {
- // Why: some targets/layouts do not unpack here; skip rather than fail so
- // the hook stays safe across platforms it has not verified.
- console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`)
- return
- }
+ const entryPath = assertPackagedDaemonEntryExists(resourcesDir)
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
if (result.error) {
@@ -43,4 +55,4 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
}
-module.exports = { verifyPackagedDaemonEntryBoots }
+module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots }
diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs
new file mode 100644
index 00000000000..8705953ba61
--- /dev/null
+++ b/config/scripts/verify-packaged-daemon-entry.test.mjs
@@ -0,0 +1,57 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const {
+ assertPackagedDaemonEntryExists,
+ verifyPackagedDaemonEntryBoots
+} = require('./verify-packaged-daemon-entry.cjs')
+
+describe('verify-packaged-daemon-entry', () => {
+ let resourcesDir
+
+ beforeEach(() => {
+ resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-'))
+ })
+
+ afterEach(() => {
+ rmSync(resourcesDir, { recursive: true, force: true })
+ })
+
+ function writePackagedEntry(source) {
+ const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
+ mkdirSync(entryDir, { recursive: true })
+ writeFileSync(join(entryDir, 'daemon-entry.js'), source)
+ }
+
+ // Why: a silent skip on a missing entry false-passed exactly the packaged
+ // layout regression this gate exists to catch (rc.1 daemon-load incident).
+ it('throws when the unpacked daemon entry is missing', () => {
+ expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow(
+ /missing unpacked daemon entry/
+ )
+ expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
+ /missing unpacked daemon entry/
+ )
+ })
+
+ it('passes when the packaged entry loads and reaches argv parsing', () => {
+ writePackagedEntry('console.error("Usage: daemon-entry "); process.exit(1)\n')
+ expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow()
+ })
+
+ it('fails when the packaged entry cannot resolve its module graph', () => {
+ writePackagedEntry('require("orca-module-that-does-not-exist")\n')
+ expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
+ /failed to load under plain Node/
+ )
+ })
+
+ it('fails when the packaged entry never reaches argv parsing', () => {
+ writePackagedEntry('process.exit(0)\n')
+ expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/)
+ })
+})
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 21cae9319cc..8d92fa208ca 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -14,6 +14,8 @@
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
+ "../src/main/codex/codex-wsl-hook-install-plan.ts",
+ "../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index b712f768dce..1333939252a 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
@@ -1774,7 +1833,7 @@ export function AccountsPane({
}
setRemoveAccountId(null)
void runCodexAccountAction(`remove:${accountId}`, () =>
- window.api.codexAccounts.remove({ accountId })
+ removeCodexProviderAccount(settings, accountId)
)
}}
>
@@ -1815,7 +1874,7 @@ export function AccountsPane({
}
setRemoveClaudeAccountId(null)
void runClaudeAccountAction(`remove:${accountId}`, () =>
- window.api.claudeAccounts.remove({ accountId })
+ removeClaudeProviderAccount(settings, accountId)
)
}}
>
diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts
index 5b7b8e020cd..8992e73499a 100644
--- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts
+++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts
@@ -44,8 +44,10 @@ const updateCapableCallers = new Map([
['ORCA_CLI_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}']
],
[
+ // Why: the single-skill update command selection moved into
+ // getLinearAgentSkillUpdateCommand so the settings install CTA shares it.
'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx',
- ['ORCA_LINEAR_SKILL_UPDATE_COMMAND', 'installedCommand={installedCommand}']
+ ['getLinearAgentSkillUpdateCommand', 'installedCommand={installedCommand}']
],
[
'src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx',
diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx
new file mode 100644
index 00000000000..a3a4a1db3dd
--- /dev/null
+++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx
@@ -0,0 +1,185 @@
+// @vitest-environment happy-dom
+
+import { act, type ComponentProps } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import type { DiscoveredSkill } from '../../../../shared/skills'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '../ui/tooltip'
+import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
+
+const mocks = vi.hoisted(() => ({
+ skillState: {
+ installed: false,
+ loading: false,
+ error: null as string | null,
+ skills: [] as DiscoveredSkill[],
+ refresh: vi.fn(async () => true)
+ },
+ useInstalledAgentSkillNames: vi.fn(),
+ clipboardWrite: vi.fn(async () => {}),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn()
+}))
+
+vi.mock('@/hooks/useInstalledAgentSkills', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useInstalledAgentSkillNames: mocks.useInstalledAgentSkillNames
+}))
+
+vi.mock('./CliSkillRuntimeSetup', () => ({
+ buildSkillCommandForRuntime: (command: string) => command
+}))
+
+vi.mock('sonner', () => ({
+ toast: { success: mocks.toastSuccess, error: mocks.toastError }
+}))
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function discoveredSkill(overrides: Partial): DiscoveredSkill {
+ return {
+ id: 'skill-1',
+ name: 'orca-linear',
+ description: null,
+ providers: ['agent-skills'],
+ sourceKind: 'home',
+ sourceLabel: 'Agent skills home',
+ rootPath: '/Users/test/.agents/skills',
+ directoryPath: '/Users/test/.agents/skills/orca-linear',
+ skillFilePath: '/Users/test/.agents/skills/orca-linear/SKILL.md',
+ installed: true,
+ fileCount: 1,
+ updatedAt: null,
+ ...overrides
+ }
+}
+
+async function renderCta(
+ props: Partial> = {}
+): Promise {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ await act(async () => {
+ root?.render(
+
+
+
+ )
+ })
+ if (!container) {
+ throw new Error('Container was not created')
+ }
+ return container
+}
+
+describe('LinearAgentSkillInstallCta', () => {
+ beforeEach(() => {
+ mocks.skillState.installed = false
+ mocks.skillState.loading = false
+ mocks.skillState.error = null
+ mocks.skillState.skills = []
+ mocks.skillState.refresh.mockClear()
+ mocks.useInstalledAgentSkillNames.mockReset()
+ mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState)
+ mocks.clipboardWrite.mockClear()
+ mocks.toastSuccess.mockClear()
+ mocks.toastError.mockClear()
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { ui: { writeClipboardText: mocks.clipboardWrite } }
+ })
+ })
+
+ afterEach(async () => {
+ if (root) {
+ await act(async () => {
+ root?.unmount()
+ })
+ }
+ root = null
+ container?.remove()
+ container = null
+ Reflect.deleteProperty(window, 'api')
+ })
+
+ it('shows the install command and explanation when the skill is missing', async () => {
+ const rendered = await renderCta()
+
+ expect(rendered.textContent).toContain('Agent skill:')
+ expect(rendered.textContent).toContain('orca-linear')
+ expect(rendered.textContent).toContain('Not installed')
+ expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.')
+ expect(rendered.textContent).toContain(
+ 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
+ )
+ })
+
+ it('copies the install command to the clipboard', async () => {
+ const rendered = await renderCta()
+
+ await act(async () => {
+ rendered
+ .querySelector('button[aria-label="Copy command"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(mocks.clipboardWrite).toHaveBeenCalledWith(
+ 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
+ )
+ expect(mocks.toastSuccess).toHaveBeenCalled()
+ })
+
+ it('shows a subtle confirmation and the update command when installed', async () => {
+ mocks.skillState.installed = true
+ mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
+
+ const rendered = await renderCta()
+
+ expect(rendered.textContent).toContain('Installed')
+ expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
+ expect(rendered.textContent).toContain('npx skills update orca-linear --global')
+ expect(rendered.textContent).not.toContain('Not installed')
+ })
+
+ it('updates through the legacy skill name when only linear-tickets is installed', async () => {
+ mocks.skillState.installed = true
+ mocks.skillState.skills = [
+ discoveredSkill({
+ name: 'linear-tickets',
+ directoryPath: '/Users/test/.agents/skills/linear-tickets',
+ skillFilePath: '/Users/test/.agents/skills/linear-tickets/SKILL.md'
+ })
+ ]
+
+ const rendered = await renderCta()
+
+ expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
+ })
+
+ it('notes that remote agent environments need their own setup', async () => {
+ const rendered = await renderCta({
+ settings: { activeRuntimeEnvironmentId: 'runtime-1', terminalWindowsShell: '' }
+ })
+
+ expect(rendered.textContent).toContain(
+ 'This installs host setup; remote agent environments may need separate setup.'
+ )
+ })
+
+ it('re-checks the skill scan on demand', async () => {
+ const rendered = await renderCta()
+
+ await act(async () => {
+ Array.from(rendered.querySelectorAll('button'))
+ .find((button) => button.textContent?.includes('Re-check'))
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(mocks.skillState.refresh).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx
new file mode 100644
index 00000000000..43e755dcbe2
--- /dev/null
+++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx
@@ -0,0 +1,186 @@
+import { useMemo } from 'react'
+import { Copy, RefreshCw } from 'lucide-react'
+import { toast } from 'sonner'
+import { IntegrationStatusPill } from '@/components/integration-status-pill'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+ GLOBAL_AGENT_SKILL_SOURCE_KINDS,
+ useInstalledAgentSkillNames
+} from '@/hooks/useInstalledAgentSkills'
+import {
+ LINEAR_AGENT_SKILL_NAMES,
+ ORCA_LINEAR_SKILL_INSTALL_COMMAND,
+ ORCA_LINEAR_SKILL_NAME
+} from '@/lib/agent-feature-install-commands'
+import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
+import { cn } from '@/lib/utils'
+import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy'
+import {
+ getCurrentPlatform,
+ getLinearPromptAgentRuntime,
+ getLinearPromptSkillDiscoveryTarget,
+ type LinearAgentSkillPromptSettings
+} from '../sidebar/linear-agent-skill-runtime'
+import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup'
+import {
+ useIntegrationCommandRowClass,
+ useIntegrationSubordinateRowClass
+} from './integration-card-presentation'
+import { translate } from '@/i18n/i18n'
+
+type LinearAgentSkillInstallCtaProps = {
+ settings: LinearAgentSkillPromptSettings | null | undefined
+}
+
+// Why: the Linear task provider and the orca-linear agent skill are decoupled
+// setups; this section bridges them so connecting the provider also surfaces
+// the skill agents need to read and edit Linear tasks.
+export function LinearAgentSkillInstallCta({
+ settings
+}: LinearAgentSkillInstallCtaProps): React.JSX.Element {
+ // Why: mirror the sidebar setup prompt's runtime resolution so both surfaces
+ // agree on which machine (host vs. WSL distro) is scanned and installed to.
+ const remote = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
+ const agentRuntime = useMemo(
+ () => getLinearPromptAgentRuntime(settings, getCurrentPlatform(), remote),
+ [remote, settings]
+ )
+ const skillDiscoveryTarget = useMemo(
+ () => getLinearPromptSkillDiscoveryTarget(agentRuntime),
+ [agentRuntime]
+ )
+ const skill = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, {
+ discoveryTarget: skillDiscoveryTarget,
+ sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
+ })
+ const command = useMemo(
+ () =>
+ buildSkillCommandForRuntime(
+ skill.installed
+ ? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed)
+ : ORCA_LINEAR_SKILL_INSTALL_COMMAND,
+ agentRuntime
+ ),
+ [agentRuntime, skill.installed, skill.skills]
+ )
+ const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5')
+ const commandRowClass = useIntegrationCommandRowClass()
+
+ const copyCommand = async (): Promise => {
+ try {
+ await window.api.ui.writeClipboardText(command)
+ toast.success(
+ translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.copiedCommand',
+ 'Copied command.'
+ )
+ )
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.copyFailed',
+ 'Failed to copy command.'
+ )
+ )
+ }
+ }
+
+ return (
+
+
+
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.skillLabel',
+ 'Agent skill:'
+ )}{' '}
+ {ORCA_LINEAR_SKILL_NAME}
+
+ {skill.loading ? (
+
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.checking',
+ 'Checking...'
+ )}
+
+ ) : skill.installed ? (
+
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.installed',
+ 'Installed'
+ )}
+
+ ) : (
+
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.notInstalled',
+ 'Not installed'
+ )}
+
+ )}
+
+
+ {skill.error ?
{skill.error}
: null}
+ {!skill.loading && (
+ <>
+
+ {skill.installed
+ ? translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.installedDescription',
+ 'Agent skill installed. To update it, run:'
+ )
+ : translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.description',
+ 'Let your agents read and edit Linear tasks.'
+ )}
+
+
+
+ {command}
+
+
+
+
+
+
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
+ 'Copy command'
+ )}
+
+
+
+ {remote || agentRuntime.runtime === 'wsl' ? (
+
+ {getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)}
+
+ ) : null}
+ >
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx
index 0dd26191914..9fce3fc84fc 100644
--- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx
+++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx
@@ -2,9 +2,10 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
+import { TooltipProvider } from '../ui/tooltip'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
@@ -86,12 +87,27 @@ async function renderCard(): Promise {
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
- root?.render()
+ root?.render(
+
+
+
+ )
})
return container
}
describe('LinearIntegrationCard account scope', () => {
+ beforeEach(() => {
+ // Why: the embedded agent-skill CTA scans installed skills through
+ // window.api; without a stub it renders a scan error instead of a state.
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ skills: { discover: async () => ({ skills: [], sources: [], scannedAt: 0 }) }
+ }
+ })
+ })
+
afterEach(async () => {
if (root) {
await act(async () => {
@@ -102,6 +118,7 @@ describe('LinearIntegrationCard account scope', () => {
container?.remove()
container = null
mocks.store.current = null
+ Reflect.deleteProperty(window, 'api')
})
it('shows local-client account ownership when Linear is disconnected', async () => {
diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx
index aca7be03f84..6c2cc1b9575 100644
--- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx
+++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx
@@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { useIntegrationSubordinateRowClass } from './integration-card-presentation'
+import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
import { getProviderAccountScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
@@ -200,6 +201,7 @@ export function LinearIntegrationCard(): React.JSX.Element {
>
) : null}
+
buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime),
[agentRuntime]
)
- const canonicalSkillInstalled = hasInstalledAgentSkill(skill.skills, ORCA_LINEAR_SKILL_NAME, {
- sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
- })
- const legacySkillInstalled = hasInstalledAgentSkill(skill.skills, LINEAR_TICKETS_SKILL_NAME, {
- sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
- })
- // Why: legacy-only installs must update the installed legacy skill, while
- // fresh/canonical/both-name states should move through the canonical name.
- const updateCommand =
- !skill.installed || canonicalSkillInstalled || !legacySkillInstalled
- ? ORCA_LINEAR_SKILL_UPDATE_COMMAND
- : LINEAR_TICKETS_SKILL_UPDATE_COMMAND
const installedCommand = useMemo(
- () => buildSkillCommandForRuntime(updateCommand, agentRuntime),
- [agentRuntime, updateCommand]
+ () =>
+ buildSkillCommandForRuntime(
+ getLinearAgentSkillUpdateCommand(skill.skills, skill.installed),
+ agentRuntime
+ ),
+ [agentRuntime, skill.installed, skill.skills]
)
const terminalShellOverride = getLinearPromptTerminalShellOverride(
currentPlatform,
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 003878d3acf..20cf2861051 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -23,6 +23,7 @@ import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
+import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
@@ -606,6 +607,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName)
const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay
const isDeleting = deleteState?.isDeleting ?? false
+ const isQueuedForDeletion = deleteState?.phase === 'queued'
+ const deleteLabel = isQueuedForDeletion
+ ? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion')
+ : translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')
const deleteModifierPressed = useWorkspaceDeleteModifierPressed()
const showStatus = cardProps.includes('status')
@@ -1723,6 +1728,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
+ {isActive && worktree.linkedLinearIssue ? (
+
+ ) : null}
+
{/* Why: inline agent list. Gated on the 'inline-agents' card
property so users can hide it. Layout coupling: this block
grows the card height dynamically — WorktreeList uses
@@ -1877,8 +1891,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
{isDeleting && (
-
- {translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')}
+ {!isQueuedForDeletion ? (
+
+ ) : null}
+ {deleteLabel}
)}
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx
index 5e333f2ff4b..d3dd012135e 100644
--- a/src/renderer/src/components/status-bar/StatusBar.tsx
+++ b/src/renderer/src/components/status-bar/StatusBar.tsx
@@ -72,6 +72,11 @@ import {
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import {
+ fetchProviderAccountsSnapshot,
+ selectClaudeProviderAccount,
+ selectCodexProviderAccount
+} from '@/runtime/runtime-provider-accounts-client'
import { translate } from '@/i18n/i18n'
type StatusBarProps = {
@@ -132,6 +137,7 @@ export type ClaudeStatusSwitchGroup = {
type StatusSwitchGroupOptions = {
fallbackWslDistro?: string | null
includeFallbackWsl?: boolean
+ hostLabel?: string
}
function getHostRuntimeLabel(): string {
@@ -157,9 +163,12 @@ function getCodexStatusWslKey(wslDistro: string | null | undefined): string {
return trimmed ? trimmed : '__default__'
}
-function getCodexStatusRuntimeLabel(target: CodexStatusRuntimeTarget): string {
+function getCodexStatusRuntimeLabel(
+ target: CodexStatusRuntimeTarget,
+ hostLabel = getHostRuntimeLabel()
+): string {
if (target.runtime === 'host') {
- return getHostRuntimeLabel()
+ return hostLabel
}
return target.wslDistro ? `WSL ${target.wslDistro}` : 'WSL default'
}
@@ -261,7 +270,7 @@ export function buildCodexStatusSwitchGroups(
const accountsForTarget = getCodexStatusAccountsForTarget(state, target)
return {
key: getCodexStatusRuntimeKey(target),
- label: getCodexStatusRuntimeLabel(target),
+ label: getCodexStatusRuntimeLabel(target, options.hostLabel),
runtimeTarget: target,
targets: [
{
@@ -420,7 +429,7 @@ export function buildClaudeStatusSwitchGroups(
const accountsForTarget = getClaudeStatusAccountsForTarget(state, target)
return {
key: getCodexStatusRuntimeKey(target),
- label: getCodexStatusRuntimeLabel(target),
+ label: getCodexStatusRuntimeLabel(target, options.hostLabel),
runtimeTarget: target,
targets: [
{
@@ -510,6 +519,29 @@ function getClaudeStatusAccountsFromSettings(
}
}
+// Why: with a Remote Orca Server active, accounts persisted in local
+// GlobalSettings describe this desktop, not the account owner; the snapshot
+// fetched from the server must win (#7973).
+export function resolveCodexStatusAccountState(
+ settings: GlobalSettings | null | undefined,
+ runtimeState: CodexRateLimitAccountsState
+): CodexRateLimitAccountsState {
+ if (settings?.activeRuntimeEnvironmentId?.trim()) {
+ return runtimeState
+ }
+ return getCodexStatusAccountsFromSettings(settings) ?? runtimeState
+}
+
+export function resolveClaudeStatusAccountState(
+ settings: GlobalSettings | null | undefined,
+ runtimeState: ClaudeRateLimitAccountsState
+): ClaudeRateLimitAccountsState {
+ if (settings?.activeRuntimeEnvironmentId?.trim()) {
+ return runtimeState
+ }
+ return getClaudeStatusAccountsFromSettings(settings) ?? runtimeState
+}
+
function CodexRestartStatusPrompt(): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
@@ -648,8 +680,15 @@ function ClaudeSwitcherMenu({
const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts)
const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget)
const settings = useAppStore((s) => s.settings)
+ const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
+ const providerAccountHostLabel = hasActiveRuntimeEnvironment
+ ? (runtimeEnvironments.find(
+ (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim()
+ )?.name ??
+ translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server'))
+ : undefined
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
@@ -661,9 +700,9 @@ function ClaudeSwitcherMenu({
if (!settings) {
return 'no-settings'
}
- return `${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}`
+ return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}`
})
- const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts
+ const accountState = resolveClaudeStatusAccountState(settings, accounts)
useEffect(() => {
mountedRef.current = true
@@ -672,12 +711,15 @@ function ClaudeSwitcherMenu({
}
}, [])
+ const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
+ // Why: keyed on the owner id, not the settings object identity, so routine
+ // settings mutations don't re-run the remote snapshot round trip.
const loadAccounts = useCallback(async () => {
- const next = await window.api.claudeAccounts.list()
+ const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId })
if (mountedRef.current) {
- setAccounts(next)
+ setAccounts(snapshot.claude)
}
- }, [])
+ }, [activeRuntimeEnvironmentId])
useEffect(() => {
void loadAccounts().catch((error) => {
@@ -694,13 +736,14 @@ function ClaudeSwitcherMenu({
// Why: inactive-account usage is needed only for the explicit switcher
// expansion, so fetch it on that event instead of one render later.
+ // Remote-owned accounts have no local inactive-usage cache to fill.
const handleAccountsExpandedToggle = useCallback((): void => {
const nextExpanded = !accountsExpanded
setAccountsExpanded(nextExpanded)
- if (nextExpanded) {
+ if (nextExpanded && !hasActiveRuntimeEnvironment) {
void fetchInactiveClaudeAccountUsage()
}
- }, [accountsExpanded, fetchInactiveClaudeAccountUsage])
+ }, [accountsExpanded, fetchInactiveClaudeAccountUsage, hasActiveRuntimeEnvironment])
const handleSelectAccount = async (
accountId: string | null,
@@ -711,7 +754,7 @@ function ClaudeSwitcherMenu({
}
setIsSwitching(true)
try {
- const next = await window.api.claudeAccounts.select({
+ const next = await selectClaudeProviderAccount(settings, {
accountId,
runtime: target.runtime,
wslDistro: target.wslDistro
@@ -720,7 +763,11 @@ function ClaudeSwitcherMenu({
if (mountedRef.current) {
setAccounts(next)
}
- await fetchSettings()
+ // Why: remote selections live on the server; local GlobalSettings
+ // account fields are untouched, so refetching them is pure churn.
+ if (!hasActiveRuntimeEnvironment) {
+ await fetchSettings()
+ }
if (mountedRef.current) {
setAccountsExpanded(false)
}
@@ -760,7 +807,8 @@ function ClaudeSwitcherMenu({
toCodexStatusRuntimeTarget(claudeTarget),
{
fallbackWslDistro,
- includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings)
+ includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings),
+ hostLabel: providerAccountHostLabel
}
)
const selectedGroup =
@@ -1201,8 +1249,15 @@ function CodexSwitcherMenu({
const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts)
const codexTarget = useAppStore((s) => s.rateLimits.codexTarget)
const settings = useAppStore((s) => s.settings)
+ const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
+ const providerAccountHostLabel = hasActiveRuntimeEnvironment
+ ? (runtimeEnvironments.find(
+ (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim()
+ )?.name ??
+ translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server'))
+ : undefined
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
@@ -1214,16 +1269,19 @@ function CodexSwitcherMenu({
if (!settings) {
return 'no-settings'
}
- return `${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}`
+ return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}`
})
- const accountState = getCodexStatusAccountsFromSettings(settings) ?? accounts
+ const accountState = resolveCodexStatusAccountState(settings, accounts)
+ const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
+ // Why: keyed on the owner id, not the settings object identity, so routine
+ // settings mutations don't re-run the remote snapshot round trip.
const loadAccounts = useCallback(async () => {
- const next = await window.api.codexAccounts.list()
+ const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId })
if (mountedRef.current) {
- setAccounts(next)
+ setAccounts(snapshot.codex)
}
- }, [])
+ }, [activeRuntimeEnvironmentId])
useEffect(() => {
mountedRef.current = true
@@ -1256,7 +1314,7 @@ function CodexSwitcherMenu({
const previousActiveAccountId = getCodexStatusActiveId(accountState, target)
setIsSwitching(true)
try {
- const next = await window.api.codexAccounts.select({
+ const next = await selectCodexProviderAccount(settings, {
accountId,
runtime: target.runtime,
wslDistro: target.wslDistro
@@ -1265,7 +1323,11 @@ function CodexSwitcherMenu({
if (mountedRef.current) {
setAccounts(next)
}
- await fetchSettings()
+ // Why: remote selections live on the server; local GlobalSettings
+ // account fields are untouched, so refetching them is pure churn.
+ if (!hasActiveRuntimeEnvironment) {
+ await fetchSettings()
+ }
const nextActiveAccountId = getCodexStatusActiveId(next, target)
if (previousActiveAccountId !== nextActiveAccountId) {
await markLiveCodexSessionsForRestart({
@@ -1381,12 +1443,13 @@ function CodexSwitcherMenu({
const handleAccountsExpandedToggle = useCallback((): void => {
const nextExpanded = !accountsExpanded
setAccountsExpanded(nextExpanded)
- if (nextExpanded) {
+ if (nextExpanded && !hasActiveRuntimeEnvironment) {
// Why: inactive-account usage is needed only for the explicit switcher
// expansion, so fetch it on that event instead of one render later.
+ // Remote-owned accounts have no local inactive-usage cache to fill.
void fetchInactiveCodexAccountUsage()
}
- }, [accountsExpanded, fetchInactiveCodexAccountUsage])
+ }, [accountsExpanded, fetchInactiveCodexAccountUsage, hasActiveRuntimeEnvironment])
const selectedRuntimeKey = getCodexStatusRuntimeKey(
normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget))
@@ -1400,7 +1463,8 @@ function CodexSwitcherMenu({
toCodexStatusRuntimeTarget(codexTarget),
{
fallbackWslDistro,
- includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings)
+ includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings),
+ hostLabel: providerAccountHostLabel
}
)
const selectedGroup =
@@ -1411,7 +1475,10 @@ function CodexSwitcherMenu({
resetCreditCount !== null
? formatResetCreditExpiry(codex.rateLimitResetCredits?.nextExpiresAt, resetCreditCount)
: null
- const canRedeemReset = resetCreditCount !== null && resetCreditCount > 0
+ // Why: reset credits redeem against the desktop's own Codex login; with a
+ // remote account owner the credit does not apply to the server's account.
+ const canRedeemReset =
+ !hasActiveRuntimeEnvironment && resetCreditCount !== null && resetCreditCount > 0
return (
a.accountId === target.id)
: null
+ // Why: sign-in spawns a local `codex login`; a remote-owned
+ // account cannot be re-authenticated from this desktop.
const showSignInAction =
+ !hasActiveRuntimeEnvironment &&
!target.active &&
target.id !== null &&
isUnavailableInactiveUsage(inactiveUsage?.rateLimits)
diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts
index 652b7f7448d..db8b96abaa2 100644
--- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts
+++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts
@@ -7,7 +7,9 @@ import type {
import {
buildClaudeStatusSwitchGroups,
buildCodexStatusSwitchGroups,
- getStatusBarPreferredWslDistro
+ getStatusBarPreferredWslDistro,
+ resolveClaudeStatusAccountState,
+ resolveCodexStatusAccountState
} from './StatusBar'
const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device'
@@ -133,4 +135,97 @@ describe('status bar runtime switch groups', () => {
)
).toBe('Fedora')
})
+
+ it('labels the host account group with the active remote server name', () => {
+ const state: CodexRateLimitAccountsState = {
+ accounts: [],
+ activeAccountId: null,
+ activeAccountIdsByRuntime: { host: null, wsl: {} }
+ }
+
+ expect(
+ buildCodexStatusSwitchGroups(
+ state,
+ { runtime: 'host', wslDistro: null },
+ { hostLabel: 'Repro Server' }
+ )[0]?.label
+ ).toBe('Repro Server')
+ })
+
+ it('prefers the runtime snapshot over local settings accounts when a remote server is active', () => {
+ const remoteState: CodexRateLimitAccountsState = {
+ accounts: [
+ {
+ id: 'server-codex-1',
+ email: 'server@example.com',
+ managedHomeRuntime: 'host',
+ wslDistro: null,
+ providerAccountId: null,
+ workspaceLabel: null,
+ workspaceAccountId: null,
+ createdAt: 1,
+ updatedAt: 1,
+ lastAuthenticatedAt: 1
+ }
+ ],
+ activeAccountId: 'server-codex-1',
+ activeAccountIdsByRuntime: { host: 'server-codex-1', wsl: {} }
+ }
+ const settings = {
+ activeRuntimeEnvironmentId: 'env-1',
+ activeCodexManagedAccountId: 'desktop-codex-1',
+ activeCodexManagedAccountIdsByRuntime: { host: 'desktop-codex-1', wsl: {} },
+ codexManagedAccounts: [
+ {
+ id: 'desktop-codex-1',
+ email: 'desktop@example.com',
+ managedHomePath: '/tmp/desktop-codex-1',
+ createdAt: 1,
+ updatedAt: 1,
+ lastAuthenticatedAt: 1
+ }
+ ]
+ } as GlobalSettings
+
+ expect(resolveCodexStatusAccountState(settings, remoteState)).toBe(remoteState)
+ // Without a remote owner, settings-derived accounts win as before.
+ expect(
+ resolveCodexStatusAccountState(
+ { ...settings, activeRuntimeEnvironmentId: null },
+ remoteState
+ ).accounts.map((account) => account.id)
+ ).toEqual(['desktop-codex-1'])
+ })
+
+ it('prefers the runtime snapshot for Claude accounts when a remote server is active', () => {
+ const remoteState: ClaudeRateLimitAccountsState = {
+ accounts: [],
+ activeAccountId: null,
+ activeAccountIdsByRuntime: { host: null, wsl: {} }
+ }
+ const settings = {
+ activeRuntimeEnvironmentId: 'env-1',
+ activeClaudeManagedAccountId: 'desktop-claude-1',
+ activeClaudeManagedAccountIdsByRuntime: { host: 'desktop-claude-1', wsl: {} },
+ claudeManagedAccounts: [
+ {
+ id: 'desktop-claude-1',
+ email: 'desktop@example.com',
+ managedAuthPath: '/tmp/desktop-claude-1',
+ authMethod: 'subscription-oauth',
+ createdAt: 1,
+ updatedAt: 1,
+ lastAuthenticatedAt: 1
+ }
+ ]
+ } as GlobalSettings
+
+ expect(resolveClaudeStatusAccountState(settings, remoteState)).toBe(remoteState)
+ expect(
+ resolveClaudeStatusAccountState(
+ { ...settings, activeRuntimeEnvironmentId: ' ' },
+ remoteState
+ ).accounts.map((account) => account.id)
+ ).toEqual(['desktop-claude-1'])
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index ce885163fed..63a28b7819a 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -1872,6 +1872,64 @@ describe('connectPanePty', () => {
expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake)
})
+ it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => {
+ // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane
+ // (no isVisible flip): the armed cold-restore --resume must fire exactly once
+ // even when the wake is delivered twice (INV-1 idempotency).
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(1)
+ const deps = createDeps({
+ consumeSuppressedPtyExit: vi.fn(() => true),
+ isVisibleRef: { current: false }
+ })
+ const pane = createPane(2)
+ const paneKey = `tab-1:${leafIdForPane(2)}`
+ mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
+ paneKey,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-hibernated-bg' },
+ prompt: 'test prompt',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+
+ const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
+ wakeHibernatedAgentIfArmed: () => void
+ dispose: () => void
+ }
+ await flushAsyncTicks()
+
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
+ expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty')
+ const connectCallsBeforeExit = transport.connect.mock.calls.length
+ onPtyExit?.('tab-pty')
+ await flushAsyncTicks()
+ // Still hidden: no reveal happened, so nothing respawned on exit.
+ expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit)
+
+ binding.wakeHibernatedAgentIfArmed()
+ await flushAsyncTicks()
+
+ expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit)
+ const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as
+ | { command?: string }
+ | undefined
+ expect(resumeConnectOptions?.command).toContain('--resume')
+ expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg')
+
+ // A second navigation-free wake must not spawn again (one-pane/one-PTY).
+ const connectCallsAfterWake = transport.connect.mock.calls.length
+ binding.wakeHibernatedAgentIfArmed()
+ await flushAsyncTicks()
+ expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake)
+ })
+
it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => {
// Race: the user reveals the background tab in the window between the
// coordinator confirming the candidate and the kill's exit arriving. The
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 2d534757a41..df753a47114 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -673,6 +673,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
noteVisibilityResume: () => void
+ /** Navigation-free hibernation wake: fires the armed cold-restore --resume
+ * without the size-reassert/foreground-sample side effects of a real reveal.
+ * Used by the mobile wake fanout so a hidden hibernated pane resumes with no
+ * desktop hidden→visible transition. */
+ wakeHibernatedAgentIfArmed: () => void
/** Re-sample process identity when the pane gains intra-tab focus: the tab
* icon follows the active leaf, and a shell-marked entry on a still-running
* agent pane has no OSC boundary left to correct it. */
@@ -6244,6 +6249,11 @@ export function connectPanePty(
consumeHibernatedAgentWake()
sampleVisiblePaneForegroundAgent()
},
+ // Why: mobile wake reaches this pane while it stays hidden on the desktop, so
+ // it must consume only the armed hibernation wake — no size/foreground reads.
+ wakeHibernatedAgentIfArmed() {
+ consumeHibernatedAgentWake()
+ },
sampleForegroundAgentOnFocus() {
sampleVisiblePaneForegroundAgent()
},
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index e342778eed5..4f9d93ed934 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -113,8 +113,10 @@ import {
import {
SPLIT_TERMINAL_PANE_EVENT,
CLOSE_TERMINAL_PANE_EVENT,
+ WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT,
type SplitTerminalPaneDetail,
- type CloseTerminalPaneDetail
+ type CloseTerminalPaneDetail,
+ type WakeHibernatedAgentsWorktreeDetail
} from '@/constants/terminal'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
@@ -881,11 +883,12 @@ export function useTerminalPaneLifecycle({
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
pane.terminal.attachCustomKeyEventHandler((e) => {
const now = Date.now()
- const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease(
- e,
- pendingTerminalImeCandidateKeyReleases,
- now
- )
+ const pendingCandidateReleaseGuardActive =
+ shouldApplyTerminalImePendingCandidateKeyRelease(
+ e,
+ pendingTerminalImeCandidateKeyReleases,
+ now
+ )
const imeKeyboardOptions = {
compositionActive: imeCompositionTracker.isActive(),
candidateKeyGuardActive:
@@ -1746,6 +1749,28 @@ export function useTerminalPaneLifecycle({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
+ // Why: mobile wake fanout — this pane self-selects by worktreeId and fires its
+ // own armed hibernation --resume while staying hidden on the desktop (no
+ // reveal, no focus/navigation change). Not-yet-mounted panes are covered by
+ // the background-mount fresh-connect cold-restore path instead.
+ useEffect(() => {
+ const onWakeHibernatedAgents = (event: Event): void => {
+ const detail = (event as CustomEvent).detail
+ if (!detail || detail.worktreeId !== worktreeId) {
+ return
+ }
+ for (const panePtyBinding of panePtyBindingsRef.current.values()) {
+ ;(
+ panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void }
+ ).wakeHibernatedAgentIfArmed?.()
+ }
+ }
+ window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents)
+ return () => {
+ window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents)
+ }
+ }, [worktreeId, panePtyBindingsRef])
+
useEffect(() => {
const previousIsVisible = getPreviousVisibleForTerminalPane({
previous: previousVisibleForReconcileRef.current,
diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
index efedfb65665..d5ec61ca3f3 100644
--- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
+++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
@@ -4,8 +4,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
- Check,
- EyeOff,
Loader2,
RefreshCcw,
Search,
@@ -38,6 +36,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
+import { Progress } from '@/components/ui/progress'
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -48,16 +47,13 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
canQueueWorkspaceCleanupCandidate,
- type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupScanError,
type WorkspaceCleanupScanProgress
} from '../../../../shared/workspace-cleanup'
import {
filterWorkspaceCleanupCandidates,
- getWorkspaceCleanupGitLabel,
getWorkspaceCleanupReviewInfo,
- hasWorkspaceCleanupLocalContext,
sortWorkspaceCleanupCandidates,
type WorkspaceCleanupContextFilter,
type WorkspaceCleanupFilters,
@@ -68,11 +64,25 @@ import {
type WorkspaceCleanupSortKey,
type WorkspaceCleanupTimeFilter
} from './workspace-cleanup-presentation'
+import {
+ startWorkspaceCleanupBackgroundRemoval,
+ type WorkspaceCleanupRemovalProgress
+} from './workspace-cleanup-background-removal'
+import { CandidateRow } from './workspace-cleanup-candidate-row'
+import {
+ getCandidateStatus,
+ getContextPillLabel,
+ getDirtyGitLabel,
+ getReviewPillTone,
+ shouldShowGitMetadataChip
+} from './workspace-cleanup-candidate-row-data'
+import { StatusPill } from './workspace-cleanup-status-pill'
import {
resolveWorkspaceCleanupActiveView,
type WorkspaceCleanupView,
type WorkspaceCleanupViewCounts
} from './workspace-cleanup-view-selection'
+import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates'
import { translate } from '@/i18n/i18n'
const DEFAULT_FILTERS: WorkspaceCleanupFilters = {
@@ -91,25 +101,6 @@ const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
title: null
}
-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'
@@ -204,13 +195,29 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates)
const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals)
const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
+ const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion)
+ const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
+ const deletingWorktreeIds = useAppStore(
+ useShallow(
+ (s) =>
+ new Set(
+ Object.entries(s.deleteStateByWorktreeId)
+ .filter(([, state]) => state.isDeleting)
+ .map(([worktreeId]) => worktreeId)
+ )
+ )
+ )
const open = activeModal === 'workspace-cleanup'
const openRef = useRef(open)
const [selectedIds, setSelectedIds] = useState>(() => new Set())
+ const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set())
const [activeView, setActiveView] = useState('ready')
const [confirming, setConfirming] = useState(false)
- const [removing, setRemoving] = useState(false)
+ const [confirmCandidates, setConfirmCandidates] = useState([])
+ const [removalProgress, setRemovalProgress] = useState(
+ null
+ )
const [rowFailures, setRowFailures] = useState>({})
const [repoSelection, setRepoSelection] = useState>(() => new Set())
const [filters, setFilters] = useState(DEFAULT_FILTERS)
@@ -220,6 +227,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
const autoScanAttemptedForOpenRef = useRef(false)
const latestReadyToastScanAtRef = useRef(null)
const wasOpenRef = useRef(false)
+ const removalInFlightRef = useRef(false)
const mountedRef = useMountedRef()
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos])
@@ -289,21 +297,24 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
if (!wasOpenRef.current) {
wasOpenRef.current = true
autoScanAttemptedForOpenRef.current = false
- setActiveView('ready')
- setConfirming(false)
- setRowFailures({})
- setFilters(DEFAULT_FILTERS)
- setSortKey('activity')
- setSortDirection('asc')
- if (scan) {
- setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates))
+ if (!removalInFlightRef.current) {
+ setActiveView('ready')
+ setConfirming(false)
+ setRowFailures({})
+ setFilters(DEFAULT_FILTERS)
+ setSortKey('activity')
+ setSortDirection('asc')
+ setSelectedIds(new Set())
}
}
- if (!loading && !autoScanAttemptedForOpenRef.current) {
+ // Why: reopening mid-batch keeps the deletion progress view; a broad scan
+ // started here would be discarded by the removal's scan invalidation, so
+ // skip it while a removal batch is running (matches the reset guard above).
+ if (!loading && !autoScanAttemptedForOpenRef.current && !removalInFlightRef.current) {
autoScanAttemptedForOpenRef.current = true
startWorkspaceCleanupScan({ notifyWhenReady: true })
}
- }, [loading, open, scan, startWorkspaceCleanupScan])
+ }, [loading, open, startWorkspaceCleanupScan])
useEffect(() => {
if (!open) {
@@ -337,14 +348,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
}, [candidates, effectiveRepoSelection, eligibleRepoIds.length])
useEffect(() => {
- if (!scan || selectedDefaultsScanAtRef.current === scan.scannedAt) {
+ if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) {
return
}
selectedDefaultsScanAtRef.current = scan.scannedAt
- setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates))
+ if (removalInFlightRef.current) {
+ return
+ }
+ setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds))
setConfirming(false)
setRowFailures({})
- }, [scan])
+ }, [deletingWorktreeIds, loading, scan])
const visibleCandidates = useMemo(() => {
const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed'))
@@ -426,9 +440,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
.map((id) => byId.get(id))
.filter(
(candidate): candidate is WorkspaceCleanupCandidate =>
- candidate != null && canQueueWorkspaceCleanupCandidate(candidate)
+ candidate != null &&
+ canQueueWorkspaceCleanupCandidate(candidate) &&
+ !deletingWorktreeIds.has(candidate.worktreeId)
)
- }, [activeRows, selectedIds])
+ }, [activeRows, deletingWorktreeIds, selectedIds])
useEffect(() => {
if (!open || confirming) {
return
@@ -436,18 +452,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
// Why: destructive selection must stay scoped to the rows the user can
// currently review after tier/filter changes.
setSelectedIds((current) => {
- const next = new Set([...current].filter((id) => activeRowIds.has(id)))
+ const next = new Set(
+ [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id))
+ )
return next.size === current.size ? current : next
})
- }, [activeRowIds, confirming, open])
+ }, [activeRowIds, confirming, deletingWorktreeIds, open])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
- if (!nextOpen && !removing) {
+ if (!nextOpen) {
closeModal()
}
},
- [closeModal, removing]
+ [closeModal]
)
const refresh = useCallback(() => {
@@ -483,65 +501,114 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
[dismissCandidates, mountedRef]
)
- const confirmRemove = useCallback(async () => {
- if (selectedCandidates.length === 0) {
+ const toggleExpandedRow = useCallback((worktreeId: string) => {
+ setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
+ }, [])
+
+ const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => {
+ const nextCandidates = filterWorkspaceCleanupRemovalCandidates(
+ candidates,
+ useAppStore.getState().deleteStateByWorktreeId
+ )
+ if (nextCandidates.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
- }
- if (mountedRef.current) {
- 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) {
- if (mountedRef.current) {
- toast.success(
- translate(
- 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0f00612b6d',
- 'Removed {{value0}} workspace{{value1}}',
- {
- value0: result.removedIds.length,
- value1: result.removedIds.length === 1 ? '' : 's'
- }
- )
- )
- }
- }
- if (result.failures.length > 0) {
- if (mountedRef.current) {
- toast.error(
- translate(
- 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.41d594d01e',
- '{{value0}} workspace{{value1}} could not be removed',
- { value0: result.failures.length, value1: result.failures.length === 1 ? '' : 's' }
- )
- )
- }
- } else {
- if (mountedRef.current) {
- setConfirming(false)
- }
- }
- } finally {
- if (mountedRef.current) {
- setRemoving(false)
- }
+ setConfirmCandidates(nextCandidates)
+ setConfirming(true)
+ }, [])
+
+ const cancelConfirmRemove = useCallback(() => {
+ if (removalProgress) {
+ closeModal()
+ return
}
- }, [mountedRef, removeCandidates, selectedCandidates])
+ setConfirming(false)
+ setConfirmCandidates([])
+ }, [closeModal, removalProgress])
+
+ const clearQueuedDeleteState = useCallback(
+ (worktreeId: string) => {
+ const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
+ // Why: candidates that fail before removal starts would otherwise stay
+ // marked "Queued for deletion" in the sidebar; rows already in the
+ // 'deleting' phase or failed with an error keep their own state.
+ if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') {
+ clearWorktreeDeleteState(worktreeId)
+ }
+ },
+ [clearWorktreeDeleteState]
+ )
+
+ const confirmRemove = useCallback(() => {
+ if (confirmCandidates.length === 0 || removalInFlightRef.current) {
+ return
+ }
+ const removableCandidates = filterWorkspaceCleanupRemovalCandidates(
+ confirmCandidates,
+ useAppStore.getState().deleteStateByWorktreeId
+ )
+ if (removableCandidates.length === 0) {
+ setConfirming(false)
+ setConfirmCandidates([])
+ return
+ }
+ removalInFlightRef.current = true
+ setRowFailures({})
+ markWorktreesQueuedForDeletion(removableCandidates.map((candidate) => candidate.worktreeId))
+ startWorkspaceCleanupBackgroundRemoval({
+ candidates: removableCandidates,
+ removeCandidates,
+ onProgress: (progress) => {
+ if (mountedRef.current) {
+ setRemovalProgress(progress)
+ }
+ },
+ onRowFailed: (failure) => {
+ clearQueuedDeleteState(failure.worktreeId)
+ },
+ onResult: (result) => {
+ const nextFailures: Record = {}
+ for (const failure of result.failures) {
+ nextFailures[failure.worktreeId] = failure.message
+ clearQueuedDeleteState(failure.worktreeId)
+ }
+ if (mountedRef.current) {
+ setRowFailures(nextFailures)
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ for (const id of result.removedIds) {
+ next.delete(id)
+ }
+ return next
+ })
+ }
+ if (mountedRef.current) {
+ setRemovalProgress(null)
+ setConfirming(false)
+ setConfirmCandidates([])
+ }
+ removalInFlightRef.current = false
+ },
+ onError: () => {
+ for (const candidate of removableCandidates) {
+ clearWorktreeDeleteState(candidate.worktreeId)
+ }
+ if (mountedRef.current) {
+ setRemovalProgress(null)
+ setConfirming(false)
+ setConfirmCandidates([])
+ }
+ removalInFlightRef.current = false
+ }
+ })
+ }, [
+ clearQueuedDeleteState,
+ clearWorktreeDeleteState,
+ confirmCandidates,
+ markWorktreesQueuedForDeletion,
+ mountedRef,
+ removeCandidates
+ ])
const selectedCount = selectedCandidates.length
@@ -594,7 +661,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
'Close'
)}
onClick={() => closeModal()}
- disabled={removing}
>
@@ -649,8 +715,8 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {