From 48528c01288e64eff2c96c6f02e146676a43e6a2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:26:21 -0700 Subject: [PATCH 01/54] Fix roadmap crash with plugin language packs (#26860) Co-authored-by: m4air --- .../github-project/ProjectRoadmap.test.tsx | 60 ++++++++++++++++++- .../github-project/ProjectRoadmap.tsx | 4 +- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx index 2b4c799b8612..0f5a0a2e115e 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx @@ -4,6 +4,9 @@ import type { ReactNode } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import ProjectRoadmap from './ProjectRoadmap' +import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from '@/i18n/i18n' +import { DEFAULT_LOCALE } from '@/i18n/supported-languages' +import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact' import type { GitHubProjectField, GitHubProjectFieldValue, @@ -94,13 +97,68 @@ function table( } } -afterEach(() => { +afterEach(async () => { cleanup() vi.useRealTimers() window.localStorage.clear() + setRendererPluginLanguagePacks([]) + await setRendererUiLanguage(DEFAULT_LOCALE) }) describe('ProjectRoadmap', () => { + it.each([ + ['pt-BR', 'pt-BR'], + ['en-US-US', DEFAULT_LOCALE] + ])('renders date ticks and bars with plugin locale %s', async (packLocale, dateLocale) => { + const packId = 'plugin:stablyai.orca-portuguese/pt-BR' as const + const resourceLanguage = pluginLanguageResourceId(packId) + // The resource tag recorded in the Windows crash report on #25021. + expect(resourceLanguage).toBe( + 'plugin0070006c007500670069006e003a0073007400610062006c007900610069002e006f007200630061002d0070006f00720074007500670075006500730065002f00700074002d00420052' + ) + setRendererPluginLanguagePacks([ + { + id: packId, + resourceLanguage, + pluginKey: 'stablyai.orca-portuguese', + locale: packLocale, + catalog: { common: { cancel: 'Cancelar' } } + } + ]) + await setRendererUiLanguage(packId) + expect(i18n.resolvedLanguage).toBe(resourceLanguage) + + render( + list} + /> + ) + + const month = new Intl.DateTimeFormat(dateLocale, { + month: 'short', + timeZone: 'UTC' + }).format(new Date(Date.UTC(2026, 8, 1))) + expect(screen.getAllByText(month, { exact: false }).length).toBeGreaterThan(0) + const dateFormat = new Intl.DateTimeFormat(dateLocale, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC' + }) + const start = dateFormat.format(new Date(Date.UTC(2026, 8, 1))) + const end = dateFormat.format(new Date(Date.UTC(2026, 9, 15))) + expect(screen.getByRole('button', { name: `Scheduled — ${start} – ${end}` })).toBeTruthy() + }) + it('moves the today marker across local midnight without resetting scroll and cleans up its timer', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 8, 5, 23, 59, 59)) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.tsx index 5e06e41d8700..26c0c40824ee 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.tsx @@ -3,7 +3,7 @@ import { CalendarClock } from 'lucide-react' import { Button } from '@/components/ui/button' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { cn } from '@/lib/utils' -import { i18n, translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRoadmapBar from './ProjectRoadmapBar' import { ProjectTitleCell } from './ProjectCellIdentity' @@ -48,7 +48,7 @@ export default function ProjectRoadmap({ }: Props): React.JSX.Element { const view = table.selectedView const prefersReducedMotion = usePrefersReducedMotion() - const locale = i18n.resolvedLanguage ?? i18n.language + const locale = getIntlLocale() // Why: the grid lives on UTC calendar days (parseRoadmapDate), so "today" // must be the viewer's LOCAL calendar date mapped to UTC midnight — the raw // instant would shift the marker into the wrong day off UTC. From f50b89b5f68fb352d811d6fce95151b9f3c80120 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:26:36 -0700 Subject: [PATCH 02/54] fix(linear): exclude label groups from assignable labels (#26861) Co-authored-by: kazu-42 <91968271+kazu-42@users.noreply.github.com> --- src/main/linear/linear-team-pages.ts | 5 +- src/main/linear/teams.test.ts | 70 +++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index 624f57c6d8e2..b0e6da1613a4 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -19,6 +19,7 @@ type TeamLabelNode = { id: string name: string color: string + isGroup: boolean } type TeamMemberNode = { @@ -83,7 +84,9 @@ export async function fetchAllTeamLabels(team: { while (labels.pageInfo.hasNextPage) { await labels.fetchNext() } - return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })) + return labels.nodes + .filter((l) => !l.isGroup) + .map((l) => ({ id: l.id, name: l.name, color: l.color })) } export async function fetchAllTeamMembers(team: { diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index 55808711824f..0d86b04f3b93 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -32,6 +32,7 @@ type LabelNode = { id: string name: string color: string + isGroup: boolean } type MemberNode = { @@ -55,7 +56,7 @@ function team(id: string, name = id, key = id.toUpperCase()): TeamNode { } function makeLabel(id: string, name = id): LabelNode { - return { id, name, color: '#ff0000' } + return { id, name, color: '#ff0000', isGroup: false } } function makeMember(id: string, displayName = id): MemberNode { @@ -224,6 +225,73 @@ describe('Linear teams', () => { expect(labels).toHaveBeenCalledWith({ first: 100 }) }) + it.each(['getTeamLabels', 'getTeamLabelsOrThrow'] as const)( + '%s excludes groups across pages while keeping child and standalone labels', + async (reader) => { + const connection = makeConnection([ + [{ ...makeLabel('group-1', 'Type'), isGroup: true }], + [ + makeLabel('label-1', 'Urgent'), + { ...makeLabel('label-2', 'Bug'), parent: { id: 'group-1' } } + ], + [{ ...makeLabel('group-2', 'Area'), isGroup: true }], + [{ ...makeLabel('label-3', 'Backend'), parent: { id: 'group-2' } }] + ]) + const labels = vi.fn().mockResolvedValue(connection) + const entry = makeTeamLookupEntry('workspace-1', 'Workspace', { labels }) + getClients.mockReturnValue([entry]) + const readers = await import('./teams') + + await expect(readers[reader]('team-1', 'workspace-1')).resolves.toEqual([ + { id: 'label-1', name: 'Urgent', color: '#ff0000' }, + { id: 'label-2', name: 'Bug', color: '#ff0000' }, + { id: 'label-3', name: 'Backend', color: '#ff0000' } + ]) + expect(connection.fetchNext).toHaveBeenCalledTimes(3) + expect(getClients).toHaveBeenCalledWith('workspace-1') + expect(entry.client.team).toHaveBeenCalledWith('team-1') + expect(labels).toHaveBeenCalledWith({ first: 100 }) + } + ) + + it.each(['getTeamLabels', 'getTeamLabelsOrThrow'] as const)( + '%s finishes pagination when only groups are available', + async (reader) => { + const connection = makeConnection([ + [{ ...makeLabel('group-1'), isGroup: true }], + [{ ...makeLabel('group-2'), isGroup: true }] + ]) + getClients.mockReturnValue([ + makeTeamLookupEntry('workspace-1', 'Workspace', { + labels: vi.fn().mockResolvedValue(connection) + }) + ]) + const readers = await import('./teams') + + await expect(readers[reader]('team-1', 'workspace-1')).resolves.toEqual([]) + expect(connection.fetchNext).toHaveBeenCalledTimes(1) + } + ) + + it('preserves error handling when a later label page fails', async () => { + const error = new Error('later label page failed') + const connection = makeConnection([[makeLabel('label-1')], [makeLabel('label-2')]]) + connection.fetchNext.mockRejectedValue(error) + getClients.mockReturnValue([ + makeTeamLookupEntry('workspace-1', 'Workspace', { + labels: vi.fn().mockResolvedValue(connection) + }) + ]) + const { getTeamLabels, getTeamLabelsOrThrow } = await import('./teams') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(getTeamLabels('team-1', 'workspace-1')).resolves.toEqual([]) + await expect(getTeamLabelsOrThrow('team-1', 'workspace-1')).rejects.toBe(error) + expect(warn).toHaveBeenCalledWith('[linear] getTeamLabels failed:', error) + expect(release).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + it('fetches every page of team states', async () => { const states = vi .fn() From c22f4b00f2c9ad277cb01d54d1f7b979b4dba667 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:27:48 -0700 Subject: [PATCH 03/54] fix(agents): show DeepSeek icon for observed Build sessions (#26863) Co-authored-by: Wooseong Kim --- src/renderer/src/components/agent/AgentCombobox.test.tsx | 7 +++++++ src/renderer/src/lib/agent-catalog.tsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index d9dd02c751f2..7ac5a9187bcd 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -210,6 +210,13 @@ describe('AgentCombobox', () => { expect(markup).not.toContain(' { + const markup = renderToStaticMarkup() + + expect(markup).toContain(AGENT_FAVICON_ASSETS.dsh) + expect(AGENT_CATALOG.map((agent) => agent.id)).not.toContain('dsb') + }) + it('renders bundled favicons for favicon-domain agents instead of the remote Google service', () => { // Why: previously loaded from Google's favicon service (#8451). Iterate the // full asset map so missing files/key mismatches fail the test. diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 800561394378..ea3f814c7f38 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -84,7 +84,9 @@ export function AgentIcon({ // Why: prefer the favicon bundled at build time so the icon renders without a // live network request — Google's favicon service is unreachable in some // regions and offline, which left these icons broken (#8451). - const bundledFaviconUrl = AGENT_FAVICON_ASSETS[agent] + // Why: recognition-only DSB shares the bundled DeepSeek mark. + const iconAgent = agent === 'dsb' ? 'dsh' : agent + const bundledFaviconUrl = AGENT_FAVICON_ASSETS[iconAgent] // Why: one resolved src for guard + attribute so empty `iconUrl` cannot pass // a truthy `||` check while `??` still renders a broken ``. const iconSrc = catalogEntry?.iconUrl ?? bundledFaviconUrl From b96144281445d16a0519a1878cf08b5160acf2a1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 00:32:53 -0700 Subject: [PATCH 04/54] test(wire): give the preflight downgrade journey a non-Windows old host (#27177) #27164 made an old host without a known non-Windows platform ask for an update instead of answering its default list. The cross-version journey mocked the capability probe but not the status read, so no request ever reached the wire. Co-authored-by: m4air --- .../preflight-workspace-scoped-downgrade.unit.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts index 75099b3ed675..6a25280a7984 100644 --- a/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts @@ -55,6 +55,11 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { ...actual, runtimeEnvironmentSupportsCapability: async (_environmentId: string, capability: string) => wire.host?.capabilities.includes(capability) ?? false, + // Why linux: an old Windows host is asked to update instead of answering its default list. + getRuntimeEnvironmentStatus: async () => ({ + capabilities: wire.host?.capabilities ?? [], + hostPlatform: 'linux' + }), callRuntimeRpc: async (_target: unknown, method: string, params?: unknown) => { const hostSaw = wire.host?.parse(method, params) wire.sent.push({ method, params, hostSaw }) From e3bca6c5a8097be5e96394352015fed10c35aeba Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:34:54 -0700 Subject: [PATCH 05/54] fix(a11y): name Agent Dashboard column regions (#26865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name Agent Dashboard columns using their translated visible headings. Co-authored-by: Matheus Breguêz --- .../AgentKanbanBoard.test.tsx | 23 +++++++++++++++++++ .../dashboard-popout/AgentKanbanBoard.tsx | 9 ++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 173a1346d34f..80eb86f1691a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -127,6 +127,29 @@ describe('AgentKanbanBoard', () => { expect(screen.getByText('Needs You')).toBeInTheDocument() }) + it('names the visible column regions and keeps cards inside their region', () => { + renderBoard([card({ bucket: 'attention', worktreeName: 'needs-review' })]) + + expect( + screen.getAllByRole('region').map((region) => region.getAttribute('aria-label')) + ).toEqual(['Needs You', 'Working', 'Done']) + expect( + within(screen.getByRole('region', { name: 'Needs You' })).getByText('needs-review') + ).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Idle' })).not.toBeInTheDocument() + }) + + it('uses the visible translated names for all regions when idle agents are shown', async () => { + await i18n.changeLanguage('zh') + renderBoard([], { showIdle: true }) + + expect(screen.getAllByRole('region')).toHaveLength(4) + for (const name of ['需要你', '工作中', '已完成', '空闲']) { + const region = screen.getByRole('region', { name }) + expect(within(region).getByText(name)).toBeInTheDocument() + } + }) + it('offers project filters without agent-state map filters', async () => { renderBoard([card({ paneKey: 'busy' })]) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 067ed2a2136a..cc1669e0ca26 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -80,13 +80,18 @@ function KanbanColumn({ now: number onOpenTerminal: (card: DashboardCard) => void }): React.JSX.Element { + const label = bucketLabel(bucket) + return ( // Why: attention no longer tints the whole column — the cards inside carry // their own state color, so a column border would double-signal it. -
+
- {bucketLabel(bucket)} + {label} {cards.length} From f4c4eaeeceb6c9218d9de5aa450c3a6236c79921 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:34:58 -0700 Subject: [PATCH 06/54] fix(i18n): correct Korean Finder menu label (#26866) Use the approved Korean Finder wording and preserve it during locale regeneration. Co-authored-by: YEONGUK LEE --- config/scripts/locale-ko-key-overrides.json | 3 +++ src/renderer/src/i18n/locales/ko.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 6f4948712dd7..964de9620bc9 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1784,6 +1784,9 @@ "auto.components.right.sidebar.FileExplorerRow.3161c4e425": { "ko": "폴더" }, + "auto.components.right.sidebar.FileExplorerRow.revealInFinder": { + "ko": "Finder에서 보기" + }, "auto.components.right.sidebar.GitHistoryPanel.8232c8b2f2": { "ko": "{{value0}} commit 열기: {{value1}}" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb599463cd4b..9139145462a6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12588,7 +12588,7 @@ "e887fa4b2e": "터미널에서 열기", "1d8e182c32": "파일 보기", "clipboardStagingUnavailable": "Orca의 임시 저장소를 사용할 수 없기 때문에 파일을 복사할 수 없습니다.", - "revealInFinder": "Finder에 공개", + "revealInFinder": "Finder에서 보기", "openContainingFolder": "포함된 폴더 열기", "revealInFileExplorer": "파일 탐색기에 표시" }, From cb69d524557506df3f05eaa5b217301e80e6f37b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 00:44:55 -0700 Subject: [PATCH 07/54] fix(repos): deleting a project with live terminals warns and needs --force (#27172) * fix(repos): deleting a project with live terminals warns and needs --force orca project setup-delete now refuses a repo-backed project that still has terminals not known to have exited or saved workspace details, unless --force. The sidebar remove dialog says how many open terminals will close. Co-Authored-By: Claude * fix(repos): count a sibling host's host-qualified workspace metadata in the delete guard Co-Authored-By: Claude * test(sidebar): cover the remove dialog's open-terminal warning Co-Authored-By: Claude * test(sidebar): type the dialog mock state without assertions Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- src/cli/handlers/project.ts | 3 +- src/cli/index-project-setup.test.ts | 10 +- src/cli/root-help-text-secondary.ts | 2 +- src/cli/specs/project.ts | 7 +- .../orca-runtime-preserved-branch-cleanup.ts | 7 +- .../project-host-setup-delete-guard.test.ts | 150 ++++++++++++++++++ src/main/runtime/project-removal-impact.ts | 73 +++++++++ ...time-project-host-setup-controller.test.ts | 3 +- .../runtime-project-host-setup-controller.ts | 36 +++++ .../sidebar/RemoveFolderDialog.test.tsx | 53 +++++-- .../components/sidebar/RemoveFolderDialog.tsx | 21 +++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../src/store/repos/repo-removal.test.ts | 25 +++ src/renderer/src/store/repos/repo-removal.ts | 31 +++- src/shared/project-types.ts | 2 + .../rpc-contract/project-runtime-params.ts | 3 +- 21 files changed, 418 insertions(+), 32 deletions(-) create mode 100644 src/main/runtime/project-host-setup-delete-guard.test.ts create mode 100644 src/main/runtime/project-removal-impact.ts create mode 100644 src/renderer/src/store/repos/repo-removal.test.ts diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index a7bf9ee2abe8..7b17e9632dd0 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -208,7 +208,8 @@ export const PROJECT_HANDLERS: Record = { client, 'projectHostSetup.delete', { - setupId: getRequiredStringFlag(flags, 'setup') + setupId: getRequiredStringFlag(flags, 'setup'), + force: flags.get('force') === true } ) printResult(result, json, formatProjectHostSetupDeleteResult) diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index f104e45c39a1..abc668e5fb9b 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -776,7 +776,10 @@ describe('orca cli worktree awareness', () => { }) }) - it('deletes project host setup metadata through the project-first runtime API', async () => { + it.each([ + { flags: [], force: false }, + { flags: ['--force'], force: true } + ])('deletes project host setup metadata with force=$force', async ({ flags, force }) => { queueFixtures( callMock, okFixture('req_project_setup_delete', { @@ -806,10 +809,11 @@ describe('orca cli worktree awareness', () => { ) vi.spyOn(console, 'log').mockImplementation(() => {}) - await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo') + await main(['project', 'setup-delete', '--setup', 'setup-gpu', ...flags, '--json'], '/tmp/repo') expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { - setupId: 'setup-gpu' + setupId: 'setup-gpu', + force }) }) }) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 4b61499ffc10..c34ab0884af1 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -74,7 +74,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', - ' orca project setup-delete --setup [--json]', + ' orca project setup-delete --setup [--force] [--json]', ' orca repo list [--json]', ' orca repo add --path [--json]', ' orca repo show --repo [--json]', diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 56b33827c4b3..064d01880449 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -115,11 +115,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ path: ['project', 'setup-delete'], destructive: true, summary: 'Remove a project host setup', - usage: 'orca project setup-delete --setup [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'setup'], + usage: 'orca project setup-delete --setup [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup', 'force'], notes: [ 'Independent setups are removed directly.', - 'Repo-backed setups remove the registered repo compatibility record.' + 'Repo-backed setups remove the registered repo compatibility record.', + 'Refuses while the project has open terminals or saved workspace details; --force removes it anyway.' ], examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json'] } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index be727819a06d..18604c368b74 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -45,6 +45,7 @@ import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-c import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' +import { countRepoTerminalsNotKnownExited } from './project-removal-impact' export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => @@ -265,7 +266,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin (this as RuntimeCommandSurfaceHost).cloneRepo(url, destination, hostId), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), - notifyReposChanged: () => this.notifyReposChanged() + notifyReposChanged: () => this.notifyReposChanged(), + countLiveTerminalsForRepo: (repo) => + countRepoTerminalsNotKnownExited(repo, this.ptysById.values(), (ptyId) => + this.isPtyKnownExited(ptyId) + ) }) protected readonly projectGroups = new RuntimeProjectGroupController({ diff --git a/src/main/runtime/project-host-setup-delete-guard.test.ts b/src/main/runtime/project-host-setup-delete-guard.test.ts new file mode 100644 index 000000000000..37c2e5d7368d --- /dev/null +++ b/src/main/runtime/project-host-setup-delete-guard.test.ts @@ -0,0 +1,150 @@ +// `orca project setup-delete` used to drop a repo-backed project even while its terminals were +// running and its workspaces had saved details, orphaning both with no warning. +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import { + countRepoTerminalsNotKnownExited, + countRepoWorkspacesWithMetadata +} from './project-removal-impact' + +const repo: Repo = { + id: 'repo-1', + path: '/work/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +function makeController(options: { + liveTerminals: number + metaIds: string[] + canonicalMetaIdsForHost?: string[] + targetRepo?: Repo +}) { + const targetRepo = options.targetRepo ?? repo + const { projects, setups: projectHostSetups } = projectHostSetupProjectionFromRepos([targetRepo]) + const deleteProjectHostSetup = vi.fn(() => ({ + project: projects[0], + setup: projectHostSetups[0], + repo: targetRepo + })) + const store = { + getProjects: () => projects, + getProjectHostSetups: () => projectHostSetups, + getAllWorktreeMeta: () => + Object.fromEntries(options.metaIds.map((id) => [id, { hostId: undefined }])), + getAllWorktreeMetaForHost: (hostId: string) => + options.canonicalMetaIdsForHost + ? Object.fromEntries(options.canonicalMetaIdsForHost.map((id) => [id, { hostId }])) + : undefined, + deleteProjectHostSetup + } + const controller = new RuntimeProjectHostSetupController({ + getStore: () => store as never, + listRepos: () => [targetRepo], + addRepo: vi.fn(), + addRemoteRepo: vi.fn(), + cloneRepo: vi.fn(), + invalidateResolvedWorktrees: vi.fn(), + invalidateWorktreeScan: vi.fn(), + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => options.liveTerminals + }) + return { controller, deleteProjectHostSetup, setupId: projectHostSetups[0].id } +} + +describe('project setup delete guard', () => { + it('refuses a CLI delete while terminals are live, and deletes with --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 2, + metaIds: [] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /2 terminals still open.*--force/ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + + controller.deleteSetup({ setupId, force: true }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('refuses while workspaces have saved details', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app', 'other-repo::/work/other'] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('sees sibling-host metadata kept only in the host-qualified maps', () => { + // Same repo id and path on local and SSH: the raw-id map holds only the local row. + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app'], + canonicalMetaIdsForHost: ['repo-1::/work/app'], + targetRepo: { ...repo, connectionId: 'box' } + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('deletes an unused project without --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['other-repo::/work/other'] + }) + + controller.deleteSetup({ setupId, force: false }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('keeps the unchecked delete for older clients that omit force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 3, + metaIds: ['repo-1::/work/app'] + }) + + controller.deleteSetup({ setupId }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) +}) + +describe('project removal impact', () => { + it('counts only this repo and host, and treats unverifiable terminals as live', () => { + const ptys = [ + { ptyId: 'local-1', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'local-exited', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'ssh-1', worktreeId: 'repo-1::/work/app', connectionId: 'box' }, + { ptyId: 'other-repo', worktreeId: 'repo-10::/work/app', connectionId: null } + ] + const isKnownExited = (ptyId: string) => ptyId === 'local-exited' + + expect(countRepoTerminalsNotKnownExited(repo, ptys, isKnownExited)).toBe(1) + expect( + countRepoTerminalsNotKnownExited({ ...repo, connectionId: 'box' }, ptys, isKnownExited) + ).toBe(1) + }) + + it('counts metadata on the repo host only, reading unstamped rows as local', () => { + const meta = { + 'repo-1::/work/app': {}, + 'repo-1::/work/feature': { hostId: 'local' as const }, + 'repo-1::/srv/app': { hostId: 'ssh:box' as const }, + 'repo-10::/work/app': {} + } + + expect(countRepoWorkspacesWithMetadata(repo, meta)).toBe(2) + expect(countRepoWorkspacesWithMetadata({ ...repo, connectionId: 'box' }, meta)).toBe(1) + }) +}) diff --git a/src/main/runtime/project-removal-impact.ts b/src/main/runtime/project-removal-impact.ts new file mode 100644 index 000000000000..04708e0c4904 --- /dev/null +++ b/src/main/runtime/project-removal-impact.ts @@ -0,0 +1,73 @@ +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId +} from '../../shared/execution-host' +import { splitWorktreeId } from '../../shared/worktree/id' +import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence' + +export type ProjectRemovalImpact = { + liveTerminals: number + workspacesWithMetadata: number +} + +type RepoIdentity = Pick + +function repoPtyHostFence(repo: RepoIdentity): WorktreePtyHostFence { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: host.environmentId } + : { resolvedConnectionId: host?.kind === 'ssh' ? host.targetId : null } +} + +/** Terminals not known to have exited count: a lost-contact PTY may still be running. */ +export function countRepoTerminalsNotKnownExited( + repo: RepoIdentity, + ptys: Iterable<{ ptyId: string; worktreeId: string; connectionId: string | null }>, + isKnownExited: (ptyId: string) => boolean +): number { + const fence = repoPtyHostFence(repo) + let count = 0 + for (const pty of ptys) { + if ( + splitWorktreeId(pty.worktreeId)?.repoId === repo.id && + worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, fence) && + !isKnownExited(pty.ptyId) + ) { + count += 1 + } + } + return count +} + +/** Same host rule as the repo prune: unstamped metadata predates host stamping and is local. */ +export function countRepoWorkspacesWithMetadata( + repo: RepoIdentity, + metaById: Record> +): number { + const hostId = getRepoExecutionHostId(repo) + const prefix = `${repo.id}::` + return Object.entries(metaById).filter( + ([id, meta]) => id.startsWith(prefix) && (meta.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId + ).length +} + +export function describeProjectRemovalRefusal(impact: ProjectRemovalImpact): string | null { + const parts: string[] = [] + if (impact.liveTerminals > 0) { + parts.push( + `${impact.liveTerminals} terminal${impact.liveTerminals === 1 ? '' : 's'} still open` + ) + } + if (impact.workspacesWithMetadata > 0) { + parts.push( + `saved details for ${impact.workspacesWithMetadata} workspace${impact.workspacesWithMetadata === 1 ? '' : 's'}` + ) + } + if (parts.length === 0) { + return null + } + return `This project has ${parts.join(' and ')}. Removing it detaches those terminals from Orca and deletes the saved workspace details. Re-run with --force to remove it anyway.` +} diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index bb3d928d4061..6defe84800bb 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -43,7 +43,8 @@ function makeController(): { cloneRepo, invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged: vi.fn() + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => 0 }) return { controller, diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index bfcbb4e5fe19..c3661f06acd2 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -24,6 +24,11 @@ import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-look import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' +import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' +import { + countRepoWorkspacesWithMetadata, + describeProjectRemovalRefusal +} from './project-removal-impact' type RuntimeProjectHostSetupDependencies = { getStore: () => RuntimeStore | null @@ -40,6 +45,7 @@ type RuntimeProjectHostSetupDependencies = { invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void notifyReposChanged: () => void + countLiveTerminalsForRepo: (repo: Repo) => number } // Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs @@ -147,6 +153,9 @@ export class RuntimeProjectHostSetupController { if (!store?.deleteProjectHostSetup) { throw new Error('runtime_unavailable') } + if (args.force === false) { + this.assertSetupRemovalIsUnused(store, args.setupId) + } const result = store.deleteProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) @@ -154,6 +163,33 @@ export class RuntimeProjectHostSetupController { return result } + // Why only for an explicit `force: false`: older clients omit it and must keep their delete. + private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { + const setup = this.listSetups().find((entry) => entry.id === setupId) + // Same exact-host match the store's delete uses, so a sibling host's row is never measured. + const repo = setup?.repoId + ? this.deps + .listRepos() + .find( + (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId + ) + : undefined + if (!repo) { + return + } + const refusal = describeProjectRemovalRefusal({ + liveTerminals: this.deps.countLiveTerminalsForRepo(repo), + workspacesWithMetadata: countRepoWorkspacesWithMetadata( + repo, + // Host-qualified read: a same-id, same-path sibling host keeps its rows only in canonical maps. + readAllWorktreeMetaForRepo(store, repo) + ) + }) + if (refusal) { + throw new Error(refusal) + } + } + private completeSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo, diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx index 7a5ac841d536..45c04b08a56f 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx @@ -3,21 +3,30 @@ import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' -const mocks = vi.hoisted(() => ({ - state: { - activeModal: 'confirm-remove-folder' as string | null, - modalData: { - repoId: 'repo-1', - displayName: 'Example', - hostId: 'ssh:target-1' - } as Record, - repos: [] as Repo[], - sshTargetLabels: new Map(), - removedSshTargetLabels: new Map(), - closeModal: vi.fn(), - removeProject: vi.fn() +const mocks = vi.hoisted(() => { + const worktreesByRepo: Record = {} + const tabsByWorktree: Record = {} + const ptyIdsByTabId: Record = {} + return { + state: { + activeModal: 'confirm-remove-folder' as string | null, + modalData: { + repoId: 'repo-1', + displayName: 'Example', + hostId: 'ssh:target-1' + } as Record, + repos: [] as Repo[], + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + worktreesByRepo, + detectedWorktreesByRepo: {}, + tabsByWorktree, + ptyIdsByTabId, + closeModal: vi.fn(), + removeProject: vi.fn() + } } -})) +}) vi.mock('@/store', () => ({ useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) @@ -70,6 +79,9 @@ describe('RemoveFolderDialog', () => { } mocks.state.sshTargetLabels = new Map([['target-1', 'Persistent host']]) mocks.state.removedSshTargetLabels = new Map() + mocks.state.worktreesByRepo = {} + mocks.state.tabsByWorktree = {} + mocks.state.ptyIdsByTabId = {} }) it('warns that VM recipe cleanup controls file deletion', () => { @@ -91,4 +103,17 @@ describe('RemoveFolderDialog', () => { expect(html).toContain('Its files stay on Persistent host') expect(html).not.toContain('VM recipe') }) + + it('says how many open terminals removal will close', () => { + mocks.state.repos = [repo('target-1', 'ssh:target-1')] + mocks.state.worktreesByRepo = { + 'repo-1': [{ id: 'repo-1::/workspace/example', hostId: 'ssh:target-1' }] + } + mocks.state.tabsByWorktree = { 'repo-1::/workspace/example': [{ id: 'a' }, { id: 'b' }] } + mocks.state.ptyIdsByTabId = { a: ['pty-a'], b: ['pty-b'] } + + expect(renderToStaticMarkup()).toContain( + '2 open terminals in this project will be closed.' + ) + }) }) diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index f1b8856e14f3..f7b9776a6e8b 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -15,6 +15,7 @@ import { isRuntimeOwnedSshTargetId, normalizeExecutionHostId } from '../../../../shared/execution-host' +import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal' // Why: interpolated into the sentence so locales control where the name sits; // U+0000 cannot appear in a real project name, so the split is unambiguous. @@ -53,6 +54,23 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { ) }) + const openTerminalCount = useAppStore((s) => + repoId && hostId ? countOpenRepoTerminalTabs(s, repoId, hostId) : 0 + ) + const openTerminalWarning = + openTerminalCount === 0 + ? null + : openTerminalCount === 1 + ? translate( + 'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningOne', + '1 open terminal in this project will be closed.' + ) + : translate( + 'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningMany', + '{{count}} open terminals in this project will be closed.', + { count: openTerminalCount } + ) + // Why: fragment concatenation around the styled name cannot be reordered by // SOV locales (#9294). Translate one full sentence with the name as a // sentinel token, then split on it to re-apply the inline emphasis. @@ -104,6 +122,9 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { {displayName} {descriptionAfterName} + {openTerminalWarning ? ( +

{openTerminalWarning}

+ ) : null} + + + {translate('auto.components.right.sidebar.PortsPanel.7822e3edc6', 'Refresh Ports')} + + + + + {scan?.unavailableReason ? ( +
+ {translate( + 'auto.components.right.sidebar.PortsPanel.f59c783b7a', + 'Port scan unavailable on {{value0}}: {{value1}}', + { value0: hostLabel, value1: scan.unavailableReason } + )} +
+ ) : scan && ports.length === 0 ? ( +
+ +

+ {translate('auto.components.right.sidebar.PortsPanel.38b16cfbef', 'No ports detected')} +

+
+ ) : ( + setCollapsed((value) => !value)} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + /> + )} + + setDetailsPort(null)} /> + + ) +} diff --git a/src/renderer/src/components/right-sidebar/local-port-row.tsx b/src/renderer/src/components/right-sidebar/local-port-row.tsx index 644ed0002977..dc11aa4a4b2c 100644 --- a/src/renderer/src/components/right-sidebar/local-port-row.tsx +++ b/src/renderer/src/components/right-sidebar/local-port-row.tsx @@ -30,7 +30,8 @@ export function LocalPortRow({ port: WorkspacePort onStop: (port: WorkspacePort) => void onShowDetails: (port: WorkspacePort) => void - onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void + /** Absent when this client has no route to the port's host. */ + onOpenInBrowser?: (port: WorkspacePort, event?: React.MouseEvent) => void }): React.JSX.Element { const handleCopy = useCallback(() => { void window.api.ui.writeClipboardText(addressForPort(port)) @@ -38,7 +39,7 @@ export function LocalPortRow({ const handleOpenBrowser = useCallback( (event?: React.MouseEvent) => { - void onOpenInBrowser(port, event) + void onOpenInBrowser?.(port, event) }, [onOpenInBrowser, port] ) @@ -127,23 +128,25 @@ export function LocalPortRow({
- - - - - - {getPortOpenBrowserTooltipLabel(openBrowserLabel)} - - + {onOpenInBrowser && ( + + + + + + {getPortOpenBrowserTooltipLabel(openBrowserLabel)} + + + )} + {session.readOnly ? null : ( + + )}
) @@ -182,10 +184,12 @@ export function WorktreeRow({ {/* Why: gate the chip on SSH connectionId, not missing data — warm-reattached local PTYs land here with hasLocalSamples=false. */} {worktree.isRemote && ( - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} + {worktree.hostLabel + ? `· ${worktree.hostLabel}` + : translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', + '· remote' + )} )} diff --git a/src/renderer/src/components/status-bar/resource-workspace-host.ts b/src/renderer/src/components/status-bar/resource-workspace-host.ts index 5ea2e9df33eb..66aeb1019552 100644 --- a/src/renderer/src/components/status-bar/resource-workspace-host.ts +++ b/src/renderer/src/components/status-bar/resource-workspace-host.ts @@ -21,12 +21,16 @@ export function resolveResourceWorkspaceHost( ctx: MergeContext, worktreeId: string, repoId: string -): { isRemote: boolean; isRuntimeScoped: boolean } { +): { isRemote: boolean; runtimeHostId: string | null } { const folder = resolveResourceFolderWorkspace(ctx, worktreeId) const host = folder ? parseExecutionHostId(folder.hostId ?? 'local') : null return { // Folder siblings may execute on different hosts within the same project group. isRemote: host ? host.kind === 'ssh' : ctx.repoConnectionIdById.get(repoId) != null, - isRuntimeScoped: host ? host.kind === 'runtime' : ctx.repoRuntimeScopedById.get(repoId) === true + runtimeHostId: host + ? host.kind === 'runtime' + ? host.id + : null + : (ctx.repoRuntimeHostIdById.get(repoId) ?? null) } } diff --git a/src/renderer/src/components/status-bar/runtime-host-resources.test.ts b/src/renderer/src/components/status-bar/runtime-host-resources.test.ts new file mode 100644 index 000000000000..246269bad6da --- /dev/null +++ b/src/renderer/src/components/status-bar/runtime-host-resources.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +import { + collectRuntimeResourceEnvironmentIds, + getRuntimeHostResourceNotices, + readRuntimeHostResources, + toRuntimeHostResourceSamples +} from './runtime-host-resources' + +describe('runtime host resources', () => { + it('polls only paired servers that host this client’s workspaces', () => { + expect( + collectRuntimeResourceEnvironmentIds([ + 'local', + 'ssh:box', + 'runtime:env%20b', + 'runtime:env-a', + 'runtime:env-a', + undefined + ]) + ).toEqual(['env b', 'env-a']) + }) + + it("keeps a server's well-formed rows and drops malformed ones", async () => { + const call = vi.fn().mockResolvedValue({ + worktrees: [ + { + worktreeId: 'r::/w', + worktreeName: 'w', + repoId: 'r', + repoName: 'R', + cpu: 3, + memory: 30, + history: [10, 'x', 30], + sessions: [ + { sessionId: 's1', paneKey: 'tab:1', pid: 4, cpu: 3, memory: 30 }, + { sessionId: 's2', cpu: 'bad', memory: 1 } + ], + extra: true + }, + { worktreeId: 'r::/broken', repoId: 'r', cpu: Number.NaN, memory: 1 }, + null + ] + }) + + await expect(readRuntimeHostResources('env-a', call)).resolves.toEqual({ + status: 'sampled', + worktrees: [ + { + worktreeId: 'r::/w', + worktreeName: 'w', + repoId: 'r', + repoName: 'R', + cpu: 3, + memory: 30, + history: [10, 30], + sessions: [{ sessionId: 's1', paneKey: 'tab:1', pid: 4, cpu: 3, memory: 30 }] + } + ] + }) + expect(call).toHaveBeenCalledWith('env-a') + }) + + it('says a server that cannot answer is unknown, never idle', async () => { + const missing = Object.assign(new Error('nope'), { code: 'method_not_found' }) + const results = { + old: await readRuntimeHostResources('old', vi.fn().mockRejectedValue(missing)), + gone: await readRuntimeHostResources('gone', vi.fn().mockRejectedValue(new Error('timeout'))), + odd: await readRuntimeHostResources('odd', vi.fn().mockResolvedValue({ worktrees: 'no' })) + } + + expect(results).toEqual({ + old: { status: 'update-required' }, + gone: { status: 'unreachable' }, + odd: { status: 'update-required' } + }) + const label = (id: string): string => `Host ${id}` + expect(toRuntimeHostResourceSamples(results, label)).toEqual([]) + expect(getRuntimeHostResourceNotices(results, label)).toEqual([ + 'Update Host old to see its resources.', + "Couldn't reach Host gone; its resources are unknown.", + 'Update Host odd to see its resources.' + ]) + }) + + it('tags sampled rows with the server host id and name', () => { + expect( + toRuntimeHostResourceSamples({ 'env b': { status: 'sampled', worktrees: [] } }, () => 'Lab') + ).toEqual([{ hostId: 'runtime:env%20b', hostLabel: 'Lab', worktrees: [] }]) + }) +}) diff --git a/src/renderer/src/components/status-bar/runtime-host-resources.ts b/src/renderer/src/components/status-bar/runtime-host-resources.ts new file mode 100644 index 000000000000..21d6e6351b8c --- /dev/null +++ b/src/renderer/src/components/status-bar/runtime-host-resources.ts @@ -0,0 +1,150 @@ +import { translate } from '@/i18n/i18n' +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { SessionMemory, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { RuntimeHostResourceSample } from './resource-usage-merge-types' + +export type RuntimeHostResourceResult = + | { status: 'sampled'; worktrees: WorktreeMemory[] } + // Why: a server that never answered is unverifiable, so the panel names it instead of showing it idle. + | { status: 'unreachable' } + | { status: 'update-required' } + +export type RuntimeHostResourceCall = (environmentId: string) => Promise + +/** Paired servers that host at least one of this client's projects or folder workspaces. */ +export function collectRuntimeResourceEnvironmentIds( + hostIds: Iterable +): string[] { + const environmentIds = new Set() + for (const hostId of hostIds) { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + environmentIds.add(parsed.environmentId) + } + } + return [...environmentIds].sort() +} + +export async function readRuntimeHostResources( + environmentId: string, + call: RuntimeHostResourceCall +): Promise { + try { + const worktrees = parseSnapshotWorktrees(await call(environmentId)) + return worktrees ? { status: 'sampled', worktrees } : { status: 'update-required' } + } catch (error) { + return hasErrorCode(error, 'method_not_found') + ? { status: 'update-required' } + : { status: 'unreachable' } + } +} + +export function toRuntimeHostResourceSamples( + results: Readonly>, + hostLabel: (environmentId: string) => string +): RuntimeHostResourceSample[] { + const samples: RuntimeHostResourceSample[] = [] + for (const [environmentId, result] of Object.entries(results)) { + if (result.status === 'sampled') { + samples.push({ + hostId: toRuntimeExecutionHostId(environmentId), + hostLabel: hostLabel(environmentId), + worktrees: result.worktrees + }) + } + } + return samples +} + +export function getRuntimeHostResourceNotices( + results: Readonly>, + hostLabel: (environmentId: string) => string +): string[] { + return Object.entries(results).flatMap(([environmentId, result]) => { + if (result.status === 'unreachable') { + return [ + translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.8a0472ab54', + "Couldn't reach {{value0}}; its resources are unknown.", + { value0: hostLabel(environmentId) } + ) + ] + } + if (result.status === 'update-required') { + return [ + translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.1ea066b73f', + 'Update {{value0}} to see its resources.', + { value0: hostLabel(environmentId) } + ) + ] + } + return [] + }) +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +// Why: servers ship independently; keep well-formed rows and drop the rest rather than trusting the shape. +function parseSnapshotWorktrees(value: unknown): WorktreeMemory[] | null { + if (!isRecord(value) || !Array.isArray(value.worktrees)) { + return null + } + const worktrees: WorktreeMemory[] = [] + for (const row of value.worktrees) { + const cpu = isRecord(row) ? finiteNumber(row.cpu) : null + const memory = isRecord(row) ? finiteNumber(row.memory) : null + if ( + !isRecord(row) || + typeof row.worktreeId !== 'string' || + typeof row.repoId !== 'string' || + cpu === null || + memory === null + ) { + continue + } + worktrees.push({ + worktreeId: row.worktreeId, + worktreeName: typeof row.worktreeName === 'string' ? row.worktreeName : row.worktreeId, + repoId: row.repoId, + repoName: typeof row.repoName === 'string' ? row.repoName : row.repoId, + cpu, + memory, + sessions: Array.isArray(row.sessions) ? row.sessions.flatMap(parseSessionMemory) : [], + history: Array.isArray(row.history) + ? row.history.flatMap((sample) => finiteNumber(sample) ?? []) + : [] + }) + } + return worktrees +} + +function parseSessionMemory(value: unknown): SessionMemory[] { + if (!isRecord(value) || typeof value.sessionId !== 'string') { + return [] + } + const cpu = finiteNumber(value.cpu) + const memory = finiteNumber(value.memory) + if (cpu === null || memory === null) { + return [] + } + return [ + { + sessionId: value.sessionId, + paneKey: typeof value.paneKey === 'string' ? value.paneKey : null, + pid: finiteNumber(value.pid) ?? 0, + cpu, + memory + } + ] +} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts index f94237a0bbee..edd1396b3e87 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts @@ -143,6 +143,9 @@ export function useResourceUsageActions({ const handleKillSession = useCallback( (session: UnifiedSessionRow): void => { + if (session.readOnly) { + return + } if (!requiresKillConfirmation(session)) { removeSession(session.sessionId) // Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row. diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx index f5e1bac9dbb0..58dccb8d3a10 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx @@ -44,6 +44,7 @@ function derive( useResourceUsageDerivedModel({ open: true, resourceSnapshot: snapshot, + runtimeHostResources: [], sessions, resourceSessionBindings: { tabsByWorktree: {}, diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts index cc56fd7f3578..076f7633ae10 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts @@ -4,7 +4,7 @@ import type { MemorySnapshot } from '../../../../shared/process-stats-types' import type { Worktree } from '../../../../shared/worktree/types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import type { DaemonSession } from './resource-usage-merge-types' +import type { DaemonSession, RuntimeHostResourceSample } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' import { countUnboundDaemonSessions } from './resource-session-bindings' import { @@ -22,6 +22,7 @@ import { findAmbiguousWorktreeIds, findDuplicateIds } from '../../lib/unified-ta export function useResourceUsageDerivedModel({ open, resourceSnapshot, + runtimeHostResources, sessions, resourceSessionBindings, runtimePaneTitlesByTabId, @@ -38,6 +39,7 @@ export function useResourceUsageDerivedModel({ }: { open: boolean resourceSnapshot: MemorySnapshot | null + runtimeHostResources: readonly RuntimeHostResourceSample[] sessions: readonly DaemonSession[] resourceSessionBindings: ResourceSessionBindingInputs runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] @@ -78,12 +80,14 @@ export function useResourceUsageDerivedModel({ return map }, [repos]) - // Why: runtime-hosted repos have no local daemon samples or killable sessions; this map drives their per-row exclusion in the merge. - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() + // Why: runtime-hosted repos have no local samples or killable sessions; their rows come only from that server's own samples. + const repoRuntimeHostIdById = useMemo(() => { + const map = new Map() for (const repo of repos) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') + if (parsed?.kind === 'runtime') { + map.set(repo.id, parsed.id) + } } return map }, [repos]) @@ -108,7 +112,8 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById, + repoRuntimeHostIdById, + runtimeHostResources, browserTabsByWorktree, worktreeById, ambiguousWorktreeIds @@ -122,7 +127,8 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById, + repoRuntimeHostIdById, + runtimeHostResources, browserTabsByWorktree, worktreeById, ambiguousWorktreeIds diff --git a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts index e565d0cc64bb..786c595a5818 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts @@ -25,6 +25,13 @@ import { import { useResourceSessionInventory } from './use-resource-session-inventory' import { useResourceUsageActions } from './use-resource-usage-actions' import { useResourceUsageDerivedModel } from './use-resource-usage-derived-model' +import { useRuntimeHostResources } from './use-runtime-host-resources' +import { + collectRuntimeResourceEnvironmentIds, + getRuntimeHostResourceNotices, + toRuntimeHostResourceSamples +} from './runtime-host-resources' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' const POLL_MS = 2_000 @@ -87,6 +94,25 @@ export function useResourceUsageStatusController() { getResourceUsageDeferredSshSessionIdsByTabId(s, open) ) const resourceSnapshot = snapshot + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeEnvironmentIds = useMemo( + () => + collectRuntimeResourceEnvironmentIds([ + ...repos.map(getRepoExecutionHostId), + ...allWorktrees.map((worktree) => worktree.hostId) + ]), + [repos, allWorktrees] + ) + const runtimeHostResults = useRuntimeHostResources(open, runtimeEnvironmentIds, POLL_MS) + const { runtimeHostResources, runtimeHostNotices } = useMemo(() => { + const hostLabel = (environmentId: string): string => + runtimeEnvironments.find((environment) => environment.id === environmentId)?.name || + environmentId + return { + runtimeHostResources: toRuntimeHostResourceSamples(runtimeHostResults, hostLabel), + runtimeHostNotices: getRuntimeHostResourceNotices(runtimeHostResults, hostLabel) + } + }, [runtimeHostResults, runtimeEnvironments]) // Why: ptyIdsByTabId tracks mounted/live panes only; Resource Manager reads restored wake hints only for classification. const resourceSessionBindings = useMemo( () => ({ @@ -190,6 +216,7 @@ export function useResourceUsageStatusController() { const derived = useResourceUsageDerivedModel({ open, resourceSnapshot, + runtimeHostResources, sessions, resourceSessionBindings, runtimePaneTitlesByTabId, @@ -243,6 +270,7 @@ export function useResourceUsageStatusController() { setPopoverBodyNode, daemonActions, resourceSnapshot, + runtimeHostNotices, spaceScanReady, recordFeatureInteraction, ...derived, diff --git a/src/renderer/src/components/status-bar/use-runtime-host-resources.ts b/src/renderer/src/components/status-bar/use-runtime-host-resources.ts new file mode 100644 index 000000000000..54c405c553cc --- /dev/null +++ b/src/renderer/src/components/status-bar/use-runtime-host-resources.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { readRuntimeHostResources, type RuntimeHostResourceResult } from './runtime-host-resources' + +const RUNTIME_RESOURCE_TIMEOUT_MS = 5_000 +const EMPTY_RESULTS: Readonly> = {} + +function callDiagnosticsMemory(environmentId: string): Promise { + return callRuntimeRpc( + { kind: 'environment', environmentId }, + 'diagnostics.memory', + undefined, + { + timeoutMs: RUNTIME_RESOURCE_TIMEOUT_MS, + reuseRecentCompatibilityFailure: true + } + ) +} + +/** Polls each paired server's own process samples while the Resource Manager is open. */ +export function useRuntimeHostResources( + open: boolean, + environmentIds: readonly string[], + pollMs: number +): Readonly> { + const environmentKey = environmentIds.join('\n') + const sessionKey = open ? environmentKey : null + // Why keyed and reset on cleanup: a reopened panel or changed host set never shows old samples. + const [state, setState] = useState<{ + key: string | null + results: Readonly> + }>({ key: null, results: {} }) + + useEffect(() => { + if (sessionKey === null || sessionKey === '') { + return + } + let cancelled = false + const inFlight = new Set() + const poll = (): void => { + for (const environmentId of sessionKey.split('\n')) { + // Why: a slow server must not stack requests or delay another server's sample. + if (inFlight.has(environmentId)) { + continue + } + inFlight.add(environmentId) + void readRuntimeHostResources(environmentId, callDiagnosticsMemory).then((result) => { + inFlight.delete(environmentId) + if (!cancelled) { + setState((prev) => ({ + key: sessionKey, + results: { ...(prev.key === sessionKey ? prev.results : {}), [environmentId]: result } + })) + } + }) + } + } + poll() + const timer = window.setInterval(poll, pollMs) + return () => { + cancelled = true + window.clearInterval(timer) + setState({ key: null, results: {} }) + } + }, [sessionKey, pollMs]) + + return state.key === sessionKey && sessionKey !== null ? state.results : EMPTY_RESULTS +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7d42ddc47ea1..da4f05761e4d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3869,6 +3869,8 @@ "c9382662bb": "Restart daemon", "59f178fe11": "{{value0}}, daemon unreachable", "21cacb16d1": "· remote", + "8a0472ab54": "Couldn't reach {{value0}}; its resources are unknown.", + "1ea066b73f": "Update {{value0}} to see its resources.", "73a3fd68a9": "Collapse repo", "b12e31dfcb": "Expand repo", "d659d71d2d": "Resume workspace {{value0}}", From 865183cff9880cb34ff7176af4d50e65544d270e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 08:47:25 -0700 Subject: [PATCH 44/54] feat(settings): host-owned settings panes choose their host explicitly (#27265) * feat(settings): host-owned settings panes choose their host explicitly Settings gets a Host menu (this computer or a saved server). Provider accounts, agent detection and terminal capabilities follow it instead of the Active Server setting, which is only the menu's starting value. A server that is no longer saved reads unavailable and never falls back to another host. The provider-account client now takes the owner target instead of settings. * chore(i18n): regenerate runtime-required English catalog * fix(accounts): Codex restart notices follow the host the switch wrote A Settings account switch on the chosen host flagged panes on the focused host instead. markLiveCodexSessionsForRestart and its lane matcher now take the owner target explicitly; Settings passes the picker's host, the status bar its focused target, and local sign-in this computer. --------- Co-authored-by: m4air --- config/owner-routing-baseline.txt | 5 +- .../components/agent/AgentSettingsDialog.tsx | 11 ++ .../AccountsPane.owner-scope.test.tsx | 63 +++++++---- .../AccountsPane.section-lifetime.test.tsx | 34 ++++-- .../components/settings/AccountsPane.test.tsx | 8 ++ .../src/components/settings/AccountsPane.tsx | 82 +++++++------- .../components/settings/AgentsPane.test.tsx | 17 +++ .../src/components/settings/AgentsPane.tsx | 17 ++- .../settings/SettingsHostScopePicker.tsx | 106 ++++++++++++++++++ .../settings/accounts-pane-account-actions.ts | 5 + .../accounts-pane-claude-section.test.tsx | 1 + .../settings/accounts-pane-claude-section.tsx | 5 +- .../accounts-pane-codex-account-row.tsx | 4 +- .../settings/accounts-pane-codex-section.tsx | 4 +- .../accounts-pane-removal-dialogs.tsx | 6 +- .../settings/accounts-pane-types.ts | 5 + .../settings/provider-account-scope.test.ts | 2 +- .../settings/provider-account-scope.ts | 4 +- .../settings-capability-section-renderers.tsx | 9 +- .../settings/settings-host-scope.test.ts | 58 ++++++++++ .../settings/settings-host-scope.ts | 43 +++++++ .../settings/settings-page-renderer.tsx | 12 ++ .../settings/use-settings-store-model.ts | 29 ++++- .../settings/use-settings-terminal-model.ts | 27 ++--- .../status-bar/ClaudeSwitcherMenu.tsx | 8 +- .../status-bar/codex-sign-in-action.ts | 2 + .../status-bar/codex-status-sign-in.test.tsx | 15 ++- .../use-codex-switcher-controller.ts | 9 +- .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 9 +- .../src/lib/codex-pane-selection-lane.test.ts | 14 +-- .../src/lib/codex-pane-selection-lane.ts | 12 +- ...ex-session-restart-account-recheck.test.ts | 6 + .../codex-session-restart-shell-flap.test.ts | 11 ++ .../src/lib/codex-session-restart.test.ts | 87 +++++++++----- src/renderer/src/lib/codex-session-restart.ts | 5 +- .../runtime-provider-accounts-client.test.ts | 10 +- .../runtime-provider-accounts-client.ts | 35 ++---- 38 files changed, 588 insertions(+), 195 deletions(-) create mode 100644 src/renderer/src/components/settings/SettingsHostScopePicker.tsx create mode 100644 src/renderer/src/components/settings/settings-host-scope.test.ts create mode 100644 src/renderer/src/components/settings/settings-host-scope.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 69a15da4f476..9742a9aa35e0 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -32,11 +32,9 @@ 1 src/renderer/src/components/pull-request-page/reviewers/panel.tsx 2 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts 1 src/renderer/src/components/quick-open-file-navigation.ts -3 src/renderer/src/components/settings/AccountsPane.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx -1 src/renderer/src/components/settings/use-settings-terminal-model.ts 1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts 1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 1 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -62,7 +60,7 @@ 1 src/renderer/src/lib/active-agent-note-send.ts 1 src/renderer/src/lib/active-agent-note-target.ts 1 src/renderer/src/lib/automation-session-observer.ts -2 src/renderer/src/lib/codex-pane-selection-lane.ts +1 src/renderer/src/lib/codex-pane-selection-lane.ts 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/github-source-runtime-context.ts 1 src/renderer/src/lib/hosted-review-repo-read.ts @@ -92,7 +90,6 @@ 3 src/renderer/src/runtime/runtime-hooks-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts 1 src/renderer/src/runtime/runtime-linear-client.ts -7 src/renderer/src/runtime/runtime-provider-accounts-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts 3 src/renderer/src/runtime/runtime-terminal-inspection.ts 1 src/renderer/src/runtime/runtime-terminal-stream.ts diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.tsx index f67556b33256..17c082352085 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/windows-terminal-capabilities' import { isWebClientLocation } from '@/lib/web-client-location' import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey' +import { resolveSettingsHostScope } from '@/components/settings/settings-host-scope' type AgentSettingsDialogProps = { open: boolean @@ -27,6 +28,8 @@ export default function AgentSettingsDialog({ }: AgentSettingsDialogProps): React.JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const isWindowsRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') const isWebClient = isWebClientLocation() @@ -49,6 +52,13 @@ export default function AgentSettingsDialog({ if (!settings) { return null } + // Why: no picker here; the pane names the default host it detects on. + const hostScope = resolveSettingsHostScope({ + choice: null, + defaultEnvironmentId: settings.activeRuntimeEnvironmentId, + savedEnvironmentIds: runtimeEnvironments.map((environment) => environment.id), + catalogHydrated: runtimeEnvironmentCatalogHydrated + }) return ( @@ -72,6 +82,7 @@ export default function AgentSettingsDialog({ ({ + target: environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + available: true +}) +const renderPane = (scope: SettingsHostScope): React.JSX.Element => ( + +) const fake = vi.hoisted(() => { const state: { pendingSelect: ((roster: unknown) => void) | null write: ReturnType rosters: Map - } = { pendingSelect: null, write: vi.fn(), rosters: new Map() } + watchedTargets: unknown[] + } = { pendingSelect: null, write: vi.fn(), rosters: new Map(), watchedTargets: [] } return state }) vi.mock('@/i18n/i18n', () => ({ @@ -49,16 +65,15 @@ vi.mock('@/runtime/runtime-provider-accounts-client', () => { return { emptyClaudeAccountsState: empty, emptyCodexAccountsState: empty, - getProviderAccountsOwnerKey: (settings: { activeRuntimeEnvironmentId?: string | null }) => - settings.activeRuntimeEnvironmentId ?? 'local', - hasRemoteProviderAccountOwner: (settings: { activeRuntimeEnvironmentId?: string | null }) => - Boolean(settings.activeRuntimeEnvironmentId), + getProviderAccountsOwnerKey: (target: RuntimeClientTarget) => + target.kind === 'environment' ? target.environmentId : 'local', watchProviderAccounts: ( - settings: { activeRuntimeEnvironmentId?: string | null }, + target: RuntimeClientTarget, handlers: { onSnapshot: (snapshot: unknown) => void } ) => { + fake.watchedTargets.push(target) handlers.onSnapshot({ - claude: fake.rosters.get(settings.activeRuntimeEnvironmentId ?? 'local'), + claude: fake.rosters.get(target.kind === 'environment' ? target.environmentId : 'local'), codex: empty(), rateLimits: null }) @@ -93,12 +108,17 @@ function claudeRoster(id: string, email: string, active: boolean): ClaudeRateLim beforeEach(() => { fake.pendingSelect = null + fake.watchedTargets = [] + fake.rosters.set('local', claudeRoster('acct-l', 'local@desktop.test', false)) fake.rosters.set('server-a', claudeRoster('acct-a', 'alpha@server-a.test', false)) fake.rosters.set('server-b', claudeRoster('acct-b', 'beta@server-b.test', false)) Object.assign(window, { api: { minimaxCredentials: { getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false })) + }, + codexConfigSync: { + status: vi.fn(async () => ({ state: 'synced', reason: null, systemConfigPath: '/x' })) } } }) @@ -108,19 +128,20 @@ afterEach(() => { Reflect.deleteProperty(window, 'api') }) -it("drops a server's late account result after the default runtime moves to another server", async () => { - const settingsFor = (environmentId: string) => ({ - ...getDefaultSettings('/synthetic'), - activeRuntimeEnvironmentId: environmentId - }) - const view = render() +it('lists the accounts of the host chosen in the page, not the focused server', async () => { + render(renderPane(scopeFor(null))) + await act(async () => {}) + expect(screen.getByText('local@desktop.test')).toBeTruthy() + expect(fake.watchedTargets).toEqual([{ kind: 'local' }]) +}) + +it("drops a server's late account result after the page moves to another server", async () => { + const view = render(renderPane(scopeFor('server-a'))) await act(async () => {}) fireEvent.click(screen.getByText('alpha@server-a.test')) expect(fake.pendingSelect).not.toBeNull() - await act(async () => - view.rerender() - ) + await act(async () => view.rerender(renderPane(scopeFor('server-b')))) expect(screen.getByText('beta@server-b.test')).toBeTruthy() await act(async () => fake.pendingSelect?.(claudeRoster('acct-a', 'alpha@server-a.test', true))) @@ -131,17 +152,11 @@ it("drops a server's late account result after the default runtime moves to anot }) it('keeps a late account result once the pane is back on the server it was sent to', async () => { - const settingsFor = (environmentId: string) => ({ - ...getDefaultSettings('/synthetic'), - activeRuntimeEnvironmentId: environmentId - }) - const view = render() + const view = render(renderPane(scopeFor('server-a'))) await act(async () => {}) fireEvent.click(screen.getByText('alpha@server-a.test')) for (const environmentId of ['server-b', 'server-a']) { - await act(async () => - view.rerender() - ) + await act(async () => view.rerender(renderPane(scopeFor(environmentId)))) } const alphaRow = (): HTMLElement => { const row = screen.getByText('alpha@server-a.test').closest('button') diff --git a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx index 66f1ab23f13b..a2a54b920b11 100644 --- a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { AccountsPane } from './AccountsPane' +import { LOCAL_SETTINGS_HOST_SCOPE } from './settings-host-scope' const fake = vi.hoisted(() => ({ query: '', @@ -68,10 +69,8 @@ vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } }), - getProviderAccountsOwnerKey: (settings: { activeRuntimeEnvironmentId?: string | null }) => - settings.activeRuntimeEnvironmentId ?? 'local', - hasRemoteProviderAccountOwner: (settings: { activeRuntimeEnvironmentId?: string }) => - Boolean(settings.activeRuntimeEnvironmentId), + getProviderAccountsOwnerKey: (target: { kind: string; environmentId?: string }) => + target.environmentId ?? 'local', watchProviderAccounts: fake.watcher, selectClaudeProviderAccount: fake.write, selectCodexProviderAccount: fake.write, @@ -124,6 +123,7 @@ it.each([false, true])( const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE, wslSupportedPlatform } const view = render() @@ -148,6 +148,7 @@ it.each([false, true])( const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE, wslSupportedPlatform } const view = render() @@ -178,7 +179,11 @@ it.each([false, true])( ) it('preserves explicit Grok refresh and real hide/reopen status reads', async () => { fake.query = 'grok' - const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write } + const props = { + settings: getDefaultSettings('/synthetic'), + updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE + } const view = render() await act(async () => {}) fireEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) @@ -196,7 +201,11 @@ it('preserves explicit Grok refresh and real hide/reopen status reads', async () it('keeps usage-driven Grok status refreshes while the section stays mounted', async () => { fake.query = 'grok' - const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write } + const props = { + settings: getDefaultSettings('/synthetic'), + updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE + } const view = render() await act(async () => {}) fake.grokUsage = { updatedAt: 10 } @@ -211,7 +220,11 @@ it('keeps usage-driven Grok status refreshes while the section stays mounted', a }) it('keeps the login subscription until Codex genuinely hides', async () => { - const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write } + const props = { + settings: getDefaultSettings('/synthetic'), + updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE + } const view = render() await act(async () => {}) const unsubscribe = fake.subscribe.mock.results[0].value @@ -234,6 +247,7 @@ it.each([false, true])( const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE, wslSupportedPlatform } const view = render() @@ -252,7 +266,11 @@ it.each([false, true])( it('keeps Cursor refresh driven by usage updates and genuine reopen', async () => { fake.query = 'cursor' - const props = { settings: getDefaultSettings('/synthetic'), updateSettings: fake.write } + const props = { + settings: getDefaultSettings('/synthetic'), + updateSettings: fake.write, + hostScope: LOCAL_SETTINGS_HOST_SCOPE + } const view = render() await act(async () => {}) fireEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index b1be5e1a3050..4def6f914166 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -6,6 +6,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { i18n } from '../../i18n/i18n' import { useAppStore } from '../../store' import { AccountsPane } from './AccountsPane' +import { resolveSettingsHostScope } from './settings-host-scope' function renderPane( settings: GlobalSettings, @@ -15,6 +16,13 @@ function renderPane( React.createElement(AccountsPane, { settings, updateSettings: vi.fn(), + // Why: mirrors the page before the user picks a host — the default host setting. + hostScope: resolveSettingsHostScope({ + choice: null, + defaultEnvironmentId: settings.activeRuntimeEnvironmentId, + savedEnvironmentIds: [], + catalogHydrated: false + }), ...props }) ) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 786324a708ed..9eb82c255241 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -13,7 +13,6 @@ import { emptyClaudeAccountsState, emptyCodexAccountsState, getProviderAccountsOwnerKey, - hasRemoteProviderAccountOwner, watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' import { @@ -42,7 +41,8 @@ import { } from './provider-account-visibility' import { GrokAccountsSection } from './GrokAccountsSection' import { AntigravityAccountsSection } from './AntigravityAccountsSection' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsHostScopeEnvironmentId } from './settings-host-scope' import { CursorAccountsSection } from './CursorAccountsSection' import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' import type { @@ -75,6 +75,7 @@ export { getAccountsPaneSearchEntries } export function AccountsPane({ settings, updateSettings, + hostScope, wslSupportedPlatform = false, wslAvailable = false, wslDistros = EMPTY_WSL_DISTROS, @@ -107,13 +108,14 @@ export function AccountsPane({ wslDistros, wslCapabilitiesLoading ) - // Why: with a Remote Orca Server active the server owns provider accounts - // (see #7973); every list/select/remove below must scope to it, not host/WSL. - const isRemoteAccountScope = hasRemoteProviderAccountOwner(settings) - const activeRuntimeEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null - // Why: a select/remove can outlive a default-runtime change; its result + // Why: a server owns its provider accounts (see #7973); every list/select/remove + // below goes to the host chosen in the page, never to the focused one. + const accountOwner = hostScope.target + const isRemoteAccountScope = accountOwner.kind === 'environment' + const scopeEnvironmentId = getSettingsHostScopeEnvironmentId(hostScope) + // Why: a select/remove can outlive a host change; its result // belongs to the owner it was sent to, not the one now shown. - const accountOwnerKey = getProviderAccountsOwnerKey(settings) + const accountOwnerKey = getProviderAccountsOwnerKey(accountOwner) const currentAccountOwnerKeyRef = useRef(accountOwnerKey) useEffect(() => { currentAccountOwnerKeyRef.current = accountOwnerKey @@ -122,8 +124,8 @@ export function AccountsPane({ // Why: keep the real name separate from the prose fallback below; the scope // label must not interpolate the fallback. const remoteServerName = isRemoteAccountScope - ? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId) - ?.name ?? null) + ? (runtimeEnvironments.find((environment) => environment.id === scopeEnvironmentId)?.name ?? + null) : null const remoteServerLabel = isRemoteAccountScope ? (remoteServerName ?? @@ -275,41 +277,42 @@ export function AccountsPane({ }, []) useEffect(() => { + const owner: RuntimeClientTarget = scopeEnvironmentId + ? { kind: 'environment', environmentId: scopeEnvironmentId } + : { kind: 'local' } // Why: remote snapshots stream usage refreshes after the synchronous ready // message, so the watcher stays open for the pane's lifetime; the local // path resolves once and the close() is a no-op. - const watcher = watchProviderAccounts( - { activeRuntimeEnvironmentId }, - { - onSnapshot: (snapshot) => { - // Why: a failed provider's half is a substituted empty roster, not - // authoritative data; keep prior state and leave the loaded gate shut. - if (!snapshot.failedProviders?.includes('codex')) { - setCodexAccounts(snapshot.codex) - setCodexAccountsLoaded(true) - } - if (!snapshot.failedProviders?.includes('claude')) { - setClaudeAccounts(snapshot.claude) - } - }, - onError: (error) => { - toast.error( - translate( - 'auto.components.settings.AccountsPane.loadAccountsFailed', - 'Could not load provider accounts.' - ), - { description: String((error as Error)?.message ?? error) } - ) + const watcher = watchProviderAccounts(owner, { + onSnapshot: (snapshot) => { + // Why: a failed provider's half is a substituted empty roster, not + // authoritative data; keep prior state and leave the loaded gate shut. + if (!snapshot.failedProviders?.includes('codex')) { + setCodexAccounts(snapshot.codex) + setCodexAccountsLoaded(true) } + if (!snapshot.failedProviders?.includes('claude')) { + setClaudeAccounts(snapshot.claude) + } + }, + onError: (error) => { + toast.error( + translate( + 'auto.components.settings.AccountsPane.loadAccountsFailed', + 'Could not load provider accounts.' + ), + { description: String((error as Error)?.message ?? error) } + ) } - ) + }) return () => { watcher.close() } - }, [activeRuntimeEnvironmentId]) + }, [scopeEnvironmentId]) const runCodexAccountAction = createCodexAccountActionRunner({ settings, + accountOwner, isCurrentAccountOwner, accountRuntime, isRemoteAccountScope, @@ -333,6 +336,7 @@ export function AccountsPane({ }) const model: AccountsPaneSectionModel = { settings, + accountOwner, updateSettings, searchQuery, recordFeatureInteraction, @@ -388,9 +392,9 @@ export function AccountsPane({ } const visibleSections = [ !searchQuery || /opencode|devin|account/i.test(searchQuery) ? ( -
- - +
+ +
) : null, wslSupportedPlatform && @@ -409,8 +413,8 @@ export function AccountsPane({ : null, matchesSettingsSearch(searchQuery, getAccountsAntigravitySearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 25cebb04b0d9..3970d5493d15 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -34,6 +34,7 @@ import { } from './AgentsPane' import { matchesSettingsSearch } from './settings-search' import { TooltipProvider } from '../ui/tooltip' +import { resolveSettingsHostScope } from './settings-host-scope' const detectedAgentsMock = vi.hoisted(() => ({ detectedIds: ['claude'] as TuiAgent[] | null, @@ -104,6 +105,13 @@ function renderPane( React.createElement(AgentsPane, { settings, updateSettings: vi.fn(), + // Why: mirrors the page before the user picks a host — the default host setting. + hostScope: resolveSettingsHostScope({ + choice: null, + defaultEnvironmentId: settings.activeRuntimeEnvironmentId, + savedEnvironmentIds: [], + catalogHydrated: false + }), ...props }) ) @@ -206,6 +214,15 @@ describe('AgentsPane', () => { } }) + it('detects agents on the host chosen in the page, not the focused server', () => { + renderPane( + { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' }, + { hostScope: { target: { kind: 'local' }, available: true } } + ) + + expect(detectedAgentsMock.lastTarget).toEqual({ kind: 'local' }) + }) + it('shows a retryable error when initial remote detection fails', () => { detectedAgentsMock.detectedIds = null detectedAgentsMock.isLoading = false diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index b4d9be1ceea0..2533fdec6919 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -51,6 +51,7 @@ import { import { AgentAvailabilityControl, type AgentCatalogRowProps } from './AgentCatalogRow' import { AgentDefaultSetting } from './AgentDefaultSetting' import { AgentDetectionCatalog } from './AgentDetectionCatalog' +import { getSettingsHostScopeEnvironmentId, type SettingsHostScope } from './settings-host-scope' export { buildAgentAvailabilitySettingsUpdate, @@ -59,9 +60,14 @@ export { AgentAvailabilityControl } -type AgentsPaneProps = { +type AgentSettingProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void | Promise +} + +type AgentsPaneProps = AgentSettingProps & { + /** Host whose installed agents are detected; agent defaults stay desktop settings. */ + hostScope: SettingsHostScope wslSupportedPlatform?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -146,12 +152,13 @@ export function AgentPermissionsSetting({ export function AgentsPane({ settings, updateSettings, + hostScope, wslSupportedPlatform, wslAvailable, wslDistros, wslCapabilitiesLoading }: AgentsPaneProps): React.JSX.Element { - const activeServerEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null + const activeServerEnvironmentId = getSettingsHostScopeEnvironmentId(hostScope) const agentDetectionTarget = useMemo( () => activeServerEnvironmentId @@ -295,7 +302,7 @@ export function AgentsPane({ ) } -export function AgentStatusHooksSetting({ settings, updateSettings }: AgentsPaneProps) { +export function AgentStatusHooksSetting({ settings, updateSettings }: AgentSettingProps) { const enabled = settings.agentStatusHooksEnabled !== false return (
@@ -310,7 +317,7 @@ export function AgentStatusHooksSetting({ settings, updateSettings }: AgentsPane ) } -export function AgentWorkspaceTrustSetting({ settings, updateSettings }: AgentsPaneProps) { +export function AgentWorkspaceTrustSetting({ settings, updateSettings }: AgentSettingProps) { const enabled = settings.agentWorkspaceTrustEnabled !== false return (
@@ -325,7 +332,7 @@ export function AgentWorkspaceTrustSetting({ settings, updateSettings }: AgentsP ) } -export function AgentGeneratedTabTitlesSetting({ settings, updateSettings }: AgentsPaneProps) { +export function AgentGeneratedTabTitlesSetting({ settings, updateSettings }: AgentSettingProps) { const enabled = settings.tabAutoGenerateTitle === true return (
diff --git a/src/renderer/src/components/settings/SettingsHostScopePicker.tsx b/src/renderer/src/components/settings/SettingsHostScopePicker.tsx new file mode 100644 index 000000000000..d6338facb74c --- /dev/null +++ b/src/renderer/src/components/settings/SettingsHostScopePicker.tsx @@ -0,0 +1,106 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { + getSettingsHostScopeEnvironmentId, + type SettingsHostChoice, + type SettingsHostScope +} from './settings-host-scope' + +const LOCAL_VALUE = 'local' +const ENVIRONMENT_VALUE_PREFIX = 'environment:' + +type SettingsHostScopePickerProps = { + scope: SettingsHostScope + environments: readonly PublicKnownRuntimeEnvironment[] + onChoose: (choice: SettingsHostChoice) => void +} + +export function shouldShowSettingsHostScopePicker( + scope: SettingsHostScope, + environments: readonly PublicKnownRuntimeEnvironment[] +): boolean { + return environments.length > 0 || scope.target.kind === 'environment' +} + +export function SettingsHostScopePicker({ + scope, + environments, + onChoose +}: SettingsHostScopePickerProps): React.JSX.Element { + const scopeEnvironmentId = getSettingsHostScopeEnvironmentId(scope) + const value = scopeEnvironmentId + ? `${ENVIRONMENT_VALUE_PREFIX}${scopeEnvironmentId}` + : LOCAL_VALUE + return ( +
+ + {translate('auto.components.settings.SettingsHostScopePicker.label', 'Host')} + + + + {translate( + 'auto.components.settings.SettingsHostScopePicker.description', + 'Accounts, detected agents and terminal options below are for this host.' + )} + +
+ ) +} + +/** Shown in place of a host-owned pane whose chosen server is no longer saved. */ +export function SettingsHostScopeUnavailableNotice(): React.JSX.Element { + return ( +

+ {translate( + 'auto.components.settings.SettingsHostScopePicker.unavailableNotice', + 'This server is no longer saved, so nothing can be shown for it. Choose another host at the top of Settings.' + )} +

+ ) +} diff --git a/src/renderer/src/components/settings/accounts-pane-account-actions.ts b/src/renderer/src/components/settings/accounts-pane-account-actions.ts index 9ceff1fb36ce..9cc98c29d1f1 100644 --- a/src/renderer/src/components/settings/accounts-pane-account-actions.ts +++ b/src/renderer/src/components/settings/accounts-pane-account-actions.ts @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog' import type { ClaudeRateLimitAccountsState, @@ -32,6 +33,8 @@ import { getClaudeAccountLabel } from './accounts-pane-runtime' type CodexActionContext = { settings: GlobalSettings + /** Host whose roster the action writes. */ + accountOwner: RuntimeClientTarget accountRuntime: LocalAccountRuntime isRemoteAccountScope: boolean codexAccounts: CodexRateLimitAccountsState @@ -48,6 +51,7 @@ export function createCodexAccountActionRunner( context: CodexActionContext ): CodexAccountActionRunner { const { + accountOwner, accountRuntime, codexAccounts, fetchSettings, @@ -118,6 +122,7 @@ export function createCodexAccountActionRunner( nextAccountId: nextActiveAccountId ?? null, // Why: the mutation wrote this row's slot only, so panes on any other // lane still launch under the account they already had. + owner: accountOwner, target: addedAccount ? getProviderAccountRuntime(addedAccount) : actionRuntime, // Why: clearing a distro-less WSL row nulls every distro slot at once. clearsEveryWslDistro: action === 'select:system' diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx index dfa13023815f..a3c8f97bf45a 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx @@ -45,6 +45,7 @@ function render( const model: AccountsPaneSectionModel = { // Unused by the Claude section; present only so the model is the real type. updateSettings: vi.fn(), + accountOwner: { kind: 'local' }, searchQuery: '', recordFeatureInteraction: vi.fn(), wslSupportedPlatform: false, diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index 2e40f0198eb4..69ae293d79d2 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -32,6 +32,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re runClaudeAccountAction, setRemoveClaudeTarget, settings, + accountOwner, systemClaudeActive, updateSettings, visibleClaudeAccounts, @@ -153,7 +154,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re type="button" onClick={() => void runClaudeAccountAction('select:system', () => - selectClaudeProviderAccount(settings, { + selectClaudeProviderAccount(accountOwner, { accountId: null, runtime: accountRuntime.runtime, wslDistro: accountRuntime.wslDistro @@ -271,7 +272,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re void runClaudeAccountAction( `select:${account.id}`, () => - selectClaudeProviderAccount(settings, { + selectClaudeProviderAccount(accountOwner, { accountId: account.id, ...accountRuntimeView }), diff --git a/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx b/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx index 182b8de5aeef..d5f3615e16d5 100644 --- a/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx +++ b/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx @@ -29,7 +29,7 @@ export function renderCodexAccountRow( isRemoteAccountScope, runCodexAccountAction, setRemoveCodexTarget, - settings + accountOwner } = model const isActive = providerAccountIsActiveInView( account, @@ -73,7 +73,7 @@ export function renderCodexAccountRow( void runCodexAccountAction( `select:${account.id}`, () => - selectCodexProviderAccount(settings, { + selectCodexProviderAccount(accountOwner, { accountId: account.id, ...accountRuntimeView }), diff --git a/src/renderer/src/components/settings/accounts-pane-codex-section.tsx b/src/renderer/src/components/settings/accounts-pane-codex-section.tsx index 8e30f5630986..faccc1732ae0 100644 --- a/src/renderer/src/components/settings/accounts-pane-codex-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-codex-section.tsx @@ -25,7 +25,7 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea isRemoteAccountScope, remoteAccountScopeNotice, runCodexAccountAction, - settings, + accountOwner, systemCodexActive, systemCodexIdentity, systemCodexMissingSignIn, @@ -205,7 +205,7 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea type="button" onClick={() => void runCodexAccountAction('select:system', () => - selectCodexProviderAccount(settings, { + selectCodexProviderAccount(accountOwner, { accountId: null, runtime: accountRuntime.runtime, wslDistro: accountRuntime.wslDistro diff --git a/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx b/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx index 1de050b79d1b..984071b1b46c 100644 --- a/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx +++ b/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx @@ -24,7 +24,7 @@ export function renderAccountsRemovalDialogs( runCodexAccountAction, setRemoveClaudeTarget, setRemoveCodexTarget, - settings + accountOwner } = model return ( <> @@ -61,7 +61,7 @@ export function renderAccountsRemovalDialogs( setRemoveCodexTarget(null) void runCodexAccountAction( `remove:${target.id}`, - () => removeCodexProviderAccount(settings, target.id), + () => removeCodexProviderAccount(accountOwner, target.id), target.runtime ) }} @@ -104,7 +104,7 @@ export function renderAccountsRemovalDialogs( setRemoveClaudeTarget(null) void runClaudeAccountAction( `remove:${target.id}`, - () => removeClaudeProviderAccount(settings, target.id), + () => removeClaudeProviderAccount(accountOwner, target.id), target.runtime ) }} diff --git a/src/renderer/src/components/settings/accounts-pane-types.ts b/src/renderer/src/components/settings/accounts-pane-types.ts index 69df08404732..2f5def9b661a 100644 --- a/src/renderer/src/components/settings/accounts-pane-types.ts +++ b/src/renderer/src/components/settings/accounts-pane-types.ts @@ -12,10 +12,13 @@ import type { import type { CodexConfigSyncStatus } from '../../../../shared/codex-config-sync-types' import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog' import type { ProviderAccountRuntimeView } from './provider-account-visibility' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { SettingsHostScope } from './settings-host-scope' export type AccountsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + hostScope: SettingsHostScope wslSupportedPlatform?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -62,6 +65,8 @@ export type ClaudeAccountActionRunner = ( export type AccountsPaneSectionModel = { settings: GlobalSettings + /** Host whose accounts the pane lists and mutates. */ + accountOwner: RuntimeClientTarget updateSettings: (updates: Partial) => void searchQuery: string recordFeatureInteraction: (featureId: FeatureInteractionId) => void diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts index 73d19828404b..3ce78fb1b5ab 100644 --- a/src/renderer/src/components/settings/provider-account-scope.test.ts +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -41,7 +41,7 @@ describe('getProviderAccountScope', () => { describe('getRemoteAccountsPaneScope', () => { const LOCAL_ACCOUNTS_KEPT = - 'Accounts managed on this desktop are unchanged. Switch the default runtime back to Local desktop to view them.' + 'Accounts managed on this desktop are unchanged. Choose this computer in the Host menu at the top of Settings to view them.' it('names the owning server once the saved-server list resolves', () => { expect(getRemoteAccountsPaneScope(' build-box ')).toEqual({ diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts index f6745b3ed9c8..61fe161aa36b 100644 --- a/src/renderer/src/components/settings/provider-account-scope.ts +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -30,8 +30,8 @@ export function getRemoteAccountsPaneScope(serverName: string | null): ProviderA 'Remote server' ), description: translate( - 'auto.components.settings.AccountsPane.remoteScopeLocalAccountsKept', - 'Accounts managed on this desktop are unchanged. Switch the default runtime back to Local desktop to view them.' + 'auto.components.settings.AccountsPane.remoteScopeLocalAccountsKeptHostPicker', + 'Accounts managed on this desktop are unchanged. Choose this computer in the Host menu at the top of Settings to view them.' ) } } diff --git a/src/renderer/src/components/settings/settings-capability-section-renderers.tsx b/src/renderer/src/components/settings/settings-capability-section-renderers.tsx index dedd78d61a28..fe24afc4058f 100644 --- a/src/renderer/src/components/settings/settings-capability-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-capability-section-renderers.tsx @@ -5,6 +5,7 @@ import { LinearAgentSkillPane } from './LinearAgentSkillPane' import { OrchestrationPane } from './OrchestrationPane' import { VoicePane } from './VoicePane' import { SettingsSection } from './SettingsSection' +import { SettingsHostScopeUnavailableNotice } from './SettingsHostScopePicker' import { translate } from '@/i18n/i18n' import type { SettingsRenderContext } from './settings-render-context' @@ -24,6 +25,7 @@ export function renderAgentsSettingsSection(context: SettingsRenderContext): Rea - {view.isSectionMounted('accounts') ? ( + {!view.isSectionMounted('accounts') ? null : model.settingsHostScope.available ? ( - ) : null} + ) : ( + + )} ) } diff --git a/src/renderer/src/components/settings/settings-host-scope.test.ts b/src/renderer/src/components/settings/settings-host-scope.test.ts new file mode 100644 index 000000000000..c6b6aab40089 --- /dev/null +++ b/src/renderer/src/components/settings/settings-host-scope.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { resolveSettingsHostScope } from './settings-host-scope' + +const saved = ['env-a', 'env-b'] + +describe('resolveSettingsHostScope', () => { + it('starts on the default host setting until the user picks one', () => { + expect( + resolveSettingsHostScope({ + choice: null, + defaultEnvironmentId: ' env-a ', + savedEnvironmentIds: saved, + catalogHydrated: true + }) + ).toEqual({ target: { kind: 'environment', environmentId: 'env-a' }, available: true }) + }) + + it('keeps the picked host when the default host setting changes', () => { + expect( + resolveSettingsHostScope({ + choice: { kind: 'local' }, + defaultEnvironmentId: 'env-a', + savedEnvironmentIds: saved, + catalogHydrated: true + }) + ).toEqual({ target: { kind: 'local' }, available: true }) + expect( + resolveSettingsHostScope({ + choice: { kind: 'environment', environmentId: 'env-b' }, + defaultEnvironmentId: null, + savedEnvironmentIds: saved, + catalogHydrated: true + }) + ).toEqual({ target: { kind: 'environment', environmentId: 'env-b' }, available: true }) + }) + + it('marks a removed server unavailable instead of falling back to this computer', () => { + expect( + resolveSettingsHostScope({ + choice: { kind: 'environment', environmentId: 'env-gone' }, + defaultEnvironmentId: null, + savedEnvironmentIds: saved, + catalogHydrated: true + }) + ).toEqual({ target: { kind: 'environment', environmentId: 'env-gone' }, available: false }) + }) + + it('does not call a server unavailable before the saved-server list loads', () => { + expect( + resolveSettingsHostScope({ + choice: { kind: 'environment', environmentId: 'env-a' }, + defaultEnvironmentId: null, + savedEnvironmentIds: [], + catalogHydrated: false + }).available + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/settings-host-scope.ts b/src/renderer/src/components/settings/settings-host-scope.ts new file mode 100644 index 000000000000..81e6f5665c74 --- /dev/null +++ b/src/renderer/src/components/settings/settings-host-scope.ts @@ -0,0 +1,43 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +/** Host whose accounts, agents and terminal capabilities Settings shows; chosen in the page. */ +export type SettingsHostScope = { + target: RuntimeClientTarget + /** False when the chosen server is no longer saved: panes say so instead of showing another host. */ + available: boolean +} + +export type SettingsHostChoice = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +export const LOCAL_SETTINGS_HOST_SCOPE: SettingsHostScope = { + target: { kind: 'local' }, + available: true +} + +export function resolveSettingsHostScope(args: { + /** Null until the user picks a host in the page. */ + choice: SettingsHostChoice | null + /** The "default host for new projects" setting; only the initial default, never a later override. */ + defaultEnvironmentId: string | null | undefined + savedEnvironmentIds: readonly string[] + catalogHydrated: boolean +}): SettingsHostScope { + const environmentId = + args.choice === null + ? args.defaultEnvironmentId?.trim() || null + : args.choice.kind === 'environment' + ? args.choice.environmentId + : null + if (!environmentId) { + return LOCAL_SETTINGS_HOST_SCOPE + } + return { + target: { kind: 'environment', environmentId }, + // Why: an unhydrated catalog is not evidence the server is gone. + available: !args.catalogHydrated || args.savedEnvironmentIds.includes(environmentId) + } +} + +export function getSettingsHostScopeEnvironmentId(scope: SettingsHostScope): string | null { + return scope.target.kind === 'environment' ? scope.target.environmentId : null +} diff --git a/src/renderer/src/components/settings/settings-page-renderer.tsx b/src/renderer/src/components/settings/settings-page-renderer.tsx index 809cad1b78cd..8a2d2d98e93f 100644 --- a/src/renderer/src/components/settings/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/settings-page-renderer.tsx @@ -54,6 +54,10 @@ import { renderPluginsSettingsSection } from './settings-advanced-section-renderers' import { renderProjectSettingsSections } from './settings-project-section-renderer' +import { + SettingsHostScopePicker, + shouldShowSettingsHostScopePicker +} from './SettingsHostScopePicker' export function renderSettingsLoading( interactions: SettingsInteractionController @@ -91,6 +95,14 @@ export function renderSettingsPage(context: SettingsRenderContext): React.JSX.El />
+ {!model.isWebClient && + shouldShowSettingsHostScopePicker(model.settingsHostScope, model.runtimeEnvironments) ? ( + + ) : null}
s.settings) @@ -58,6 +59,8 @@ export function useSettingsStoreModel() { const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) const modelStates = useAppStore((s) => s.modelStates) const refreshModelStates = useAppStore((s) => s.refreshModelStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const projectHostSetupProjection = useProjectHostSetupProjection() // Why: same inputs as nav metadata so both lists agree — the source of truth for the pane list. @@ -144,6 +147,27 @@ export function useSettingsStoreModel() { const confirm = useConfirmationDialog() // Why: session-only (deliberately not persisted) unlock — Option-click the Experimental page title reveals the hidden group. const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false) + const [settingsHostChoice, setSettingsHostChoice] = useState(null) + const defaultRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId + // Why: keyed on ids so status-only catalog updates keep the scope (and its loads) stable. + const savedEnvironmentIdsKey = runtimeEnvironments.map((environment) => environment.id).join('\n') + // Why: the web client's only host is its server, so it never offers a choice. + const settingsHostScope = useMemo( + () => + resolveSettingsHostScope({ + choice: isWebClient ? null : settingsHostChoice, + defaultEnvironmentId: defaultRuntimeEnvironmentId, + savedEnvironmentIds: savedEnvironmentIdsKey ? savedEnvironmentIdsKey.split('\n') : [], + catalogHydrated: runtimeEnvironmentCatalogHydrated + }), + [ + defaultRuntimeEnvironmentId, + isWebClient, + runtimeEnvironmentCatalogHydrated, + savedEnvironmentIdsKey, + settingsHostChoice + ] + ) return { settings, @@ -220,7 +244,10 @@ export function useSettingsStoreModel() { setSourceControlAiPromptDiscardSignal, confirm, hiddenExperimentalUnlocked, - setHiddenExperimentalUnlocked + setHiddenExperimentalUnlocked, + runtimeEnvironments, + settingsHostScope, + setSettingsHostChoice } } diff --git a/src/renderer/src/components/settings/use-settings-terminal-model.ts b/src/renderer/src/components/settings/use-settings-terminal-model.ts index 2e138b70fa7b..6847bac2091e 100644 --- a/src/renderer/src/components/settings/use-settings-terminal-model.ts +++ b/src/renderer/src/components/settings/use-settings-terminal-model.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react' import type { Repo } from '../../../../shared/repo-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isWindowsTerminalCapabilityHost, useLocalWindowsTerminalCapabilities, @@ -11,34 +10,36 @@ import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import { getSettingsEntryHostSelection, getSettingsProjectHostRepo } from './settings-project-list' import type { SettingsStoreModel } from './use-settings-store-model' import type { SettingsNavigationModel } from './use-settings-navigation-model' +import { getSettingsHostScopeEnvironmentId } from './settings-host-scope' export function useSettingsTerminalModel( model: SettingsStoreModel, navigation: SettingsNavigationModel ) { - const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( - model.settings?.activeRuntimeEnvironmentId - ) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(model.settings), [model.settings]) + const scopeEnvironmentId = getSettingsHostScopeEnvironmentId(model.settingsHostScope) + const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(scopeEnvironmentId) + const runtimeTarget = model.settingsHostScope.target const capabilityLoadTarget = useMemo( () => (model.isWebClient ? { kind: 'local' as const } : runtimeTarget), [model.isWebClient, runtimeTarget] ) - const hasActiveRuntimeEnvironment = Boolean(model.settings?.activeRuntimeEnvironmentId?.trim()) + const hasActiveRuntimeEnvironment = scopeEnvironmentId !== null const needsRepoWindowsRuntimeCapabilities = [...navigation.neededSectionIds].some((sectionId) => sectionId.startsWith('repo-') ) const needsLocalWindowsRuntimeCapabilities = (model.isWindows || model.isWebClient) && (navigation.neededSectionIds.has('agents') || navigation.neededSectionIds.has('general')) + // Why: a server that is no longer saved has no capabilities to load; never probe another host. const shouldLoadWindowsTerminalCapabilities = - hasActiveRuntimeEnvironment || - ((model.isWindows || model.isWebClient) && - (navigation.neededSectionIds.has('terminal') || - navigation.neededSectionIds.has('accounts') || - needsRepoWindowsRuntimeCapabilities || - (runtimeTarget.kind === 'local' && needsLocalWindowsRuntimeCapabilities))) - // Why: terminal, account, and repository settings describe the active execution host. + model.settingsHostScope.available && + (hasActiveRuntimeEnvironment || + ((model.isWindows || model.isWebClient) && + (navigation.neededSectionIds.has('terminal') || + navigation.neededSectionIds.has('accounts') || + needsRepoWindowsRuntimeCapabilities || + (runtimeTarget.kind === 'local' && needsLocalWindowsRuntimeCapabilities)))) + // Why: terminal, account, and repository settings describe the host chosen in the page. const windowsTerminalCapabilities = useWindowsTerminalCapabilities( shouldLoadWindowsTerminalCapabilities, true, diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index 07fed72e4057..af3603359a8e 100644 --- a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -107,7 +107,11 @@ export function ClaudeSwitcherMenu({ const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null // Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch. const loadAccounts = useCallback(async () => { - const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) + const snapshot = await fetchProviderAccountsSnapshot( + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } + ) // Why: a failed Claude half is a substituted empty roster; keep prior state. if (snapshot.failedProviders?.includes('claude')) { console.error('Claude account list failed; keeping previous status bar state.') @@ -164,7 +168,7 @@ export function ClaudeSwitcherMenu({ } setIsSwitching(true) try { - const next = await selectClaudeProviderAccount(settings, { + const next = await selectClaudeProviderAccount(runtimeTarget, { accountId, runtime: target.runtime, wslDistro: target.wslDistro diff --git a/src/renderer/src/components/status-bar/codex-sign-in-action.ts b/src/renderer/src/components/status-bar/codex-sign-in-action.ts index c52746660d1c..a3dfa9a3f5a6 100644 --- a/src/renderer/src/components/status-bar/codex-sign-in-action.ts +++ b/src/renderer/src/components/status-bar/codex-sign-in-action.ts @@ -71,6 +71,8 @@ export async function signInCodexAccount( nextAccountLabel: resolveCodexRestartPromptAccountLabel(next.accounts, nextActiveAccountId), previousAccountId: previousActiveAccountId ?? null, nextAccountId: nextActiveAccountId ?? null, + // Why: reauthenticate is local IPC, so only this computer's panes changed. + owner: { kind: 'local' }, target }) if (mountedRef.current) { diff --git a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx index 86912a2e50ea..37d6046002ca 100644 --- a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx +++ b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx @@ -95,7 +95,7 @@ vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => null + getActiveRuntimeTarget: () => ({ kind: 'local' }) })) vi.mock('@/lib/windows-terminal-capabilities', () => ({ @@ -229,11 +229,14 @@ describe('status bar Codex sign-in action', () => { fireEvent.click(screen.getByText(`same@example.com (${detail}account-2)`)) await waitFor(() => - expect(selectCodexProviderAccount).toHaveBeenCalledWith(storeSettings, { - accountId: 'account-2', - runtime: 'host', - wslDistro: null - }) + expect(selectCodexProviderAccount).toHaveBeenCalledWith( + { kind: 'local' }, + { + accountId: 'account-2', + runtime: 'host', + wslDistro: null + } + ) ) expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts index 3de6be62ccae..60f4addb66ba 100644 --- a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +++ b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts @@ -87,7 +87,11 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null // Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch. const loadAccounts = useCallback(async () => { - const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) + const snapshot = await fetchProviderAccountsSnapshot( + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } + ) // Why: a failed Codex half is a substituted empty roster; keep prior state. if (snapshot.failedProviders?.includes('codex')) { console.error('Codex account list failed; keeping previous status bar state.') @@ -127,7 +131,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await selectCodexProviderAccount(settings, { + const next = await selectCodexProviderAccount(runtimeTarget, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -157,6 +161,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { nextAccountId: nextActiveAccountId ?? null, // Why: the mutation wrote this row's slot only, so panes on any other // lane still launch under the account they already had. + owner: runtimeTarget, target, // Why: clearing a distro-less WSL row nulls every distro slot at once. clearsEveryWslDistro: accountId === null diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 14662c5928c9..27ca218c0872 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1093,7 +1093,8 @@ "b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.", "b43e761fe5": "MiniMax cookie update failed.", "b8c2905c2b": "Could not load Codex accounts.", - "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in." + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "remoteScopeLocalAccountsKept": "Accounts managed on this desktop are unchanged. Switch the default runtime back to Local desktop to view them." }, "AdvancedNetworkSettingsSection": { "d93c7cd531": "Configure app-level network routing.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index da4f05761e4d..622478e339f4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6918,7 +6918,8 @@ "placeholder": "Leave blank to use the key saved by /connect or OPENCODE_API_KEY", "help": "Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts." } - } + }, + "remoteScopeLocalAccountsKeptHostPicker": "Accounts managed on this desktop are unchanged. Choose this computer in the Host menu at the top of Settings to view them." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -12695,6 +12696,12 @@ "offCount": "{{count}} off", "machines": "Machines", "machinesDescription": "Choose which machines can show notifications on this computer. A paired server’s switch also covers work reached through it." + }, + "SettingsHostScopePicker": { + "label": "Host", + "unavailable": "{{value0}} (unavailable)", + "description": "Accounts, detected agents and terminal options below are for this host.", + "unavailableNotice": "This server is no longer saved, so nothing can be shown for it. Choose another host at the top of Settings." } }, "right": { diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts index c6729d291ae8..8ffae69187b7 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -387,14 +387,14 @@ describe('resolveCodexPaneSelectionLane', () => { describe('getCodexAccountSwitchLaneMatcher', () => { it('scopes a local switch to the runtime slot it wrote', () => { const hostSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'host' } }) expect(hostSwitch('host')).toBe(true) expect(hostSwitch('wsl:Ubuntu')).toBe(false) const ubuntuSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) expect(ubuntuSwitch('wsl:Ubuntu')).toBe(true) @@ -406,7 +406,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // matching only `wsl:__default__` would leave those panes stranded, unnoticed. it('claims every WSL distro when the change cleared them all', () => { const wslDefaultSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: null }, clearsEveryWslDistro: true }) @@ -425,7 +425,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // would card — and mute — every sibling distro's healthy Codex pane. it('keeps a distro-less WSL selection off sibling distro panes', () => { const wslDefaultSelect = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: null } }) expect(wslDefaultSelect('wsl:__default__')).toBe(true) @@ -438,7 +438,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // that slot alone. Without the null-distro condition it would mute them all. it('keeps a cleared concrete-distro row off the other distros', () => { const ubuntuClear = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, clearsEveryWslDistro: true }) @@ -449,7 +449,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { it('scopes a switch made against a runtime environment to that machine', () => { const environmentSwitch = getCodexAccountSwitchLaneMatcher({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + owner: { kind: 'environment', environmentId: 'env-1' }, target: { runtime: 'host' } }) expect(environmentSwitch('env:env-1')).toBe(true) @@ -459,7 +459,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { it('never lets a local host switch claim a remote or SSH pane', () => { const hostSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'host' } }) const state = laneState() diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index 56ea2108074d..27bee15e5392 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-client-target' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { getCodexSelectionLaneKey, @@ -14,7 +14,6 @@ import { } from '../../../shared/local-windows-terminal-runtime' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { resolveTerminalStartupCwd } from '../../../shared/terminal-startup-cwd' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -25,8 +24,6 @@ import { hasCachedWindowsTerminalCapabilities } from './windows-terminal-capabilities' -type RuntimeEnvironmentSettings = Pick - /** Everything the pane lane needs: the workspace path plus the project runtime inputs. */ type CodexPaneLaneState = Pick< AppState, @@ -72,7 +69,8 @@ export function isForeignMachineCodexPtyId(ptyId: string): boolean { /** Matches the panes a Codex account mutation could have re-pointed. */ export function getCodexAccountSwitchLaneMatcher(args: { - settings: RuntimeEnvironmentSettings | null | undefined + /** Host whose roster the mutation wrote. */ + owner: RuntimeClientTarget target?: CodexAccountSelectionTarget | null /** * True only when the mutation cleared every WSL distro slot at once, which @@ -82,8 +80,8 @@ export function getCodexAccountSwitchLaneMatcher(args: { */ clearsEveryWslDistro?: boolean }): (laneKey: string) => boolean { - const runtimeTarget = getActiveRuntimeTarget(args.settings) - // Why: with an environment active the mutation is RPC'd to that machine's + const runtimeTarget = args.owner + // Why: a server-owned mutation is RPC'd to that machine's // roster and local GlobalSettings are never touched, so the local host/WSL // panes are exactly the ones the switch cannot have affected. if (runtimeTarget.kind === 'environment') { diff --git a/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts index 74c15995e43a..8260bceccdb8 100644 --- a/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts +++ b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// Why: these cases model a switch made on the focused host, as the status bar does. +const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart } from './codex-session-restart' const ACCOUNT_A = 'account-a@example.com' @@ -65,6 +69,7 @@ describe('Codex account switch recheck against main', () => { useAppStore.getState().queueCodexPaneRestarts(['pty-1']) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_C, previousAccountId: 'account-b', @@ -80,6 +85,7 @@ describe('Codex account switch recheck against main', () => { listStalePanes.mockRejectedValue(new Error('ipc failed')) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', diff --git a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts index c913cfe060db..1fd132600283 100644 --- a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts +++ b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// Why: these cases model a switch made on the focused host, as the status bar does. +const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart, markRestoredStaleCodexSessionsForRestart @@ -99,6 +103,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex-aarch64-ap') await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -114,6 +119,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -125,6 +131,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue(new Error('daemon busy')) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -138,6 +145,7 @@ describe('spurious shell readings on Codex-launched panes', () => { ).confirmForegroundProcess = undefined await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -149,6 +157,7 @@ describe('spurious shell readings on Codex-launched panes', () => { seedPane() await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -164,6 +173,7 @@ describe('spurious shell readings on Codex-launched panes', () => { }) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -183,6 +193,7 @@ describe('spurious shell readings on Codex-launched panes', () => { }) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index d858f3140587..67a418acffea 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -1,5 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// Why: these cases model a switch made on the focused host, as the status bar does. +const markForFocusedHost = ( + args: Omit[0], 'owner'> +): Promise => + markLiveCodexSessionsForRestart({ + owner: getActiveRuntimeTarget(useAppStore.getState().settings), + ...args + }) import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../shared/tui-agent' import { @@ -120,7 +130,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -170,7 +180,7 @@ describe('markLiveCodexSessionsForRestart', () => { return { foregroundProcess, hasChildProcesses: false } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -193,7 +203,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -209,7 +219,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: true }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -227,7 +237,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -245,7 +255,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: true }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -262,7 +272,7 @@ describe('markLiveCodexSessionsForRestart', () => { return { foregroundProcess: 'codex', hasChildProcesses: true } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -281,7 +291,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -298,7 +308,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -315,13 +325,13 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) useAppStore.getState().queueCodexPaneRestarts(['pty-1']) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_A }) @@ -336,12 +346,12 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) useAppStore.getState().queueCodexPaneRestarts(['pty-1']) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: 'account-c@example.com' }) @@ -362,12 +372,12 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_C }) @@ -412,7 +422,7 @@ describe('markLiveCodexSessionsForRestart', () => { _meta: { runtimeId: 'remote-runtime' } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -532,7 +542,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a live remote Codex pane alone on a host switch, and never inspects it', async () => { seedPanes([{ ptyId: 'remote:env-1@@term-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -545,7 +555,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a live SSH-connection Codex pane alone on a host switch', async () => { seedPanes([{ ptyId: 'ssh:my-box@@pty-7' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -558,7 +568,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('still marks the local host pane while sparing the remote one beside it', async () => { seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -571,10 +581,27 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('marks the local pane for a switch made on this computer while a server is focused', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await markLiveCodexSessionsForRestart({ + owner: { kind: 'local' }, + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: the notice follows the host that was written, never the focused one. + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + it('still marks a local host pane when the switch names no target at all', async () => { seedPanes([{ ptyId: 'pty-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -590,7 +617,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a WSL Codex pane alone on a host switch even when its foreground is codex', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -603,7 +630,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('marks that same WSL pane when its own distro is the lane that changed', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'wsl', wslDistro: 'Ubuntu' } @@ -617,7 +644,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('keeps one distro switch off another distro pane', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'wsl', wslDistro: 'Debian' } @@ -630,7 +657,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -662,7 +689,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { 'pty-1': 'wsl:Ubuntu' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -687,7 +714,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', @@ -709,7 +736,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { 'pty-1': 'host' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: 'System default', nextAccountLabel: ACCOUNT_A, previousAccountId: null, @@ -737,7 +764,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', @@ -784,7 +811,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue(recorded()) } - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -803,7 +830,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { { ptyId: 'ssh:my-box@@pty-7' } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 7fff41a088ec..ab6a83ce0cf0 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -23,6 +23,7 @@ import { resolveCodexPaneSelectionLane } from './codex-pane-selection-lane' import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { TuiAgent } from '../../../shared/tui-agent' // Why: prompt integrations such as Starship can outlast the daemon's 300ms @@ -202,6 +203,8 @@ export async function markLiveCodexSessionsForRestart(args: { previousAccountId?: string | null nextAccountId?: string | null target?: CodexAccountSelectionTarget | null + /** Host whose roster the change wrote; never inferred from focus. */ + owner: RuntimeClientTarget /** Set when the change cleared the selection rather than pointing it somewhere. */ clearsEveryWslDistro?: boolean }): Promise { @@ -209,7 +212,7 @@ export async function markLiveCodexSessionsForRestart(args: { const scans = await scanCodexPanes(state, { ptyIdFilter: null, isLaneInScope: getCodexAccountSwitchLaneMatcher({ - settings: state.settings, + owner: args.owner, target: args.target, clearsEveryWslDistro: args.clearsEveryWslDistro }) diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index decdaf86e8a0..388a41baf0c6 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -18,8 +18,8 @@ import { } from './runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' -const LOCAL = { activeRuntimeEnvironmentId: null } -const REMOTE = { activeRuntimeEnvironmentId: 'env-1' } +const LOCAL = { kind: 'local' } as const +const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const function emptyClaudeState(): ClaudeRateLimitAccountsState { return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } @@ -312,8 +312,8 @@ describe('fetchProviderAccountsSnapshot', () => { }) it('isolates in-flight snapshots by remote account owner', async () => { - const first = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-1' }) - const second = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-2' }) + const first = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'env-1' }) + const second = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'env-2' }) await flushMicrotasks() expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) @@ -343,7 +343,7 @@ describe('fetchProviderAccountsSnapshot', () => { ) const local = fetchProviderAccountsSnapshot(LOCAL) - const remote = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'local' }) + const remote = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'local' }) await flushMicrotasks() expect(remote).not.toBe(local) diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index 30beafcf794f..6db5a4cc603b 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -1,11 +1,10 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../../shared/managed-account-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError, type RuntimeClientTarget } from './runtime-rpc-client' // Mirrors OrcaRuntime.getAccountsSnapshot() / the accounts.subscribe payload. export type ProviderAccountsSnapshot = { @@ -35,21 +34,12 @@ const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 const pendingProviderAccountsSnapshots = new Map>() -export function getProviderAccountsOwnerKey( - settings: Pick | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) +export function getProviderAccountsOwnerKey(target: RuntimeClientTarget): string { // Why: environment ids are user-controlled strings; prefix the target kind // so a remote id such as “local” cannot share the desktop's pending read. return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -export function hasRemoteProviderAccountOwner( - settings: Pick | null | undefined -): boolean { - return getActiveRuntimeTarget(settings).kind === 'environment' -} - export type ProviderAccountsWatcher = { close: () => void } @@ -75,13 +65,12 @@ function providerAccountsLoadError(provider: 'Claude' | 'Codex', cause: unknown) // deliberately avoided: it blocks behind provider usage refreshes on the // server and can hang for minutes behind broken auth. export function watchProviderAccounts( - settings: Pick | null | undefined, + target: RuntimeClientTarget, handlers: { onSnapshot: (snapshot: ProviderAccountsSnapshot) => void onError: (error: unknown) => void } ): ProviderAccountsWatcher { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { let closed = false void Promise.allSettled([ @@ -205,16 +194,16 @@ export function watchProviderAccounts( // One-shot convenience over watchProviderAccounts for surfaces that only need // the current snapshot (status-bar switcher menus). export function fetchProviderAccountsSnapshot( - settings: Pick | null | undefined + target: RuntimeClientTarget ): Promise { - const ownerKey = getProviderAccountsOwnerKey(settings) + const ownerKey = getProviderAccountsOwnerKey(target) const pending = pendingProviderAccountsSnapshots.get(ownerKey) if (pending) { return pending } const request = new Promise((resolve, reject) => { - const watcher = watchProviderAccounts(settings, { + const watcher = watchProviderAccounts(target, { onSnapshot: (snapshot) => { watcher.close() resolve(snapshot) @@ -238,10 +227,9 @@ export function fetchProviderAccountsSnapshot( } export async function selectClaudeProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, selection: ProviderAccountSelection ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -254,10 +242,9 @@ export async function selectClaudeProviderAccount( } export async function selectCodexProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, selection: ProviderAccountSelection ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -270,10 +257,9 @@ export async function selectCodexProviderAccount( } export async function removeClaudeProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, accountId: string ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -286,10 +272,9 @@ export async function removeClaudeProviderAccount( } export async function removeCodexProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, accountId: string ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, From 5b676777baf6a491bd7c9c3778ee477c57bf979b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 08:57:47 -0700 Subject: [PATCH 45/54] fix(web): the paired server is the web client's only host (#27266) * fix(web): the paired server is the web client's only host A paired web client is a browser tab with no PTYs, files, browser or second window of its own, yet owners it read as "local" still routed to it: setup terminals and server-local rows hit the local PTY stub, the dashboard pop-out was a silent no-op, Open in Orca Browser threw an uncaught error, and the macOS traffic-light inset was reserved. Route a transport-less owner through the paired server at the shared owner resolvers, open the in-window dashboard, refuse document previews with a reason, and reserve no traffic-light inset in the web client. Co-Authored-By: Claude * test(web): type the owner fixtures instead of casting Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- .../src/app-shell/TitlebarLeftControls.tsx | 4 +- .../src/app-shell/app-window-chrome.test.ts | 27 +++++ .../src/app-shell/app-window-chrome.ts | 6 +- .../dashboard/AgentDashboardSettingsMenu.tsx | 99 +++++++++-------- .../agent-dashboard-open-mode.test.ts | 39 +++++++ .../dashboard/agent-dashboard-open-mode.ts | 16 +++ .../AgentDashboardExperimentalSetting.tsx | 3 +- .../sidebar/AgentDashboardSidebarEntry.tsx | 3 +- .../ipc-events/agent-dashboard-command.ts | 17 +-- src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/doc-preview-web-client.ts | 20 ++++ src/renderer/src/lib/file-preview.test.ts | 35 ++++++ src/renderer/src/lib/file-preview.ts | 10 +- .../src/lib/folder-workspace-runtime-owner.ts | 3 +- .../src/lib/paired-web-client-host.test.ts | 101 ++++++++++++++++++ .../src/lib/paired-web-client-host.ts | 37 +++++++ .../src/lib/single-runtime-legacy-owner.ts | 6 ++ .../src/lib/worktree-operation-route.ts | 20 +++- .../src/lib/worktree-runtime-owner.ts | 8 +- 19 files changed, 389 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/app-shell/app-window-chrome.test.ts create mode 100644 src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts create mode 100644 src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts create mode 100644 src/renderer/src/lib/doc-preview-web-client.ts create mode 100644 src/renderer/src/lib/paired-web-client-host.test.ts create mode 100644 src/renderer/src/lib/paired-web-client-host.ts diff --git a/src/renderer/src/app-shell/TitlebarLeftControls.tsx b/src/renderer/src/app-shell/TitlebarLeftControls.tsx index 352e3d121fba..7d496d9844bb 100644 --- a/src/renderer/src/app-shell/TitlebarLeftControls.tsx +++ b/src/renderer/src/app-shell/TitlebarLeftControls.tsx @@ -15,7 +15,7 @@ import { } from '@/store/slices/worktree-nav-history' import { useShortcutLabel } from '../hooks/useShortcutLabel' import { useAppStore } from '../store' -import { hasCustomTitleBar, isMac } from './app-window-chrome' +import { hasCustomTitleBar, hasMacTrafficLights } from './app-window-chrome' import type { AppChromeLayout } from './use-app-chrome-layout' /** @@ -42,7 +42,7 @@ export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): R }`} >
- {isMac && !layout.isFullScreen ? ( + {hasMacTrafficLights && !layout.isFullScreen ? (
) : hasCustomTitleBar ? ( /* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */ diff --git a/src/renderer/src/app-shell/app-window-chrome.test.ts b/src/renderer/src/app-shell/app-window-chrome.test.ts new file mode 100644 index 000000000000..3c5c23b91167 --- /dev/null +++ b/src/renderer/src/app-shell/app-window-chrome.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AppWindowChrome from './app-window-chrome' + +async function loadChromeOnMac(): Promise { + vi.resetModules() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + return import('./app-window-chrome') +} + +describe('macOS traffic-light inset', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('reserves the traffic-light inset in the macOS desktop window', async () => { + const chrome = await loadChromeOnMac() + expect(chrome.hasMacTrafficLights).toBe(true) + expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('80px') + }) + + // #19299: a browser tab has no native window controls to make room for. + it('reserves nothing in a paired web client on a Mac browser', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const chrome = await loadChromeOnMac() + expect(chrome.isMac).toBe(true) + expect(chrome.hasMacTrafficLights).toBe(false) + expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('0px') + }) +}) diff --git a/src/renderer/src/app-shell/app-window-chrome.ts b/src/renderer/src/app-shell/app-window-chrome.ts index 8305650c4d03..2a34e7c8df2c 100644 --- a/src/renderer/src/app-shell/app-window-chrome.ts +++ b/src/renderer/src/app-shell/app-window-chrome.ts @@ -18,5 +18,7 @@ export const WINDOW_CONTROLS_WIDTH = hasCustomTitleBar ? '138px' : '0px' export const WINDOW_CONTROLS_HEIGHT = hasCustomTitleBar ? '36px' : '0px' // Why: macOS paints traffic lights on the window's top-left edge. Windows and Linux paint their -// controls on the right, so only macOS needs a surface to keep the left edge uncovered. -export const MAC_TRAFFIC_LIGHTS_WIDTH = isMac ? '80px' : '0px' +// controls on the right, and a paired web client's browser tab has none, so only a macOS desktop +// window needs a surface to keep the left edge uncovered. +export const hasMacTrafficLights = isMac && !isPairedWebClientWindow() +export const MAC_TRAFFIC_LIGHTS_WIDTH = hasMacTrafficLights ? '80px' : '0px' diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx index eb462a2d3a91..987cc1faf479 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx @@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { SettingsSegmentedControl, SettingsSwitch } from '../settings/SettingsFormControls' import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' +import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode' type AgentDashboardSettingsMenuProps = { /** Called after the mode switches to pop-out so the host can hand the board @@ -62,51 +63,55 @@ export function AgentDashboardSettingsMenu({ -
- - - {translate( - 'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel', - 'Open as' - )} - - - {translate( - 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', - 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' - )} - - -
-
- -
- + {canOpenAgentDashboardPopout() ? ( + <> +
+ + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel', + 'Open as' + )} + + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', + 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' + )} + + +
+
+ +
+ + + ) : null}
@@ -122,7 +127,9 @@ export function AgentDashboardSettingsMenu({ { - void updateSettings({ experimentalAgentDashboardShowIdle: !showIdle }) + void updateSettings({ + experimentalAgentDashboardShowIdle: !showIdle + }) }} ariaLabel={translate('dashboardPopout.settings.showIdle', 'Show idle agents')} /> diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts new file mode 100644 index 000000000000..53aba6f971bc --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toggleAgentDashboardFromShortcut } from '../../hooks/ipc-events/agent-dashboard-command' +import { resolveAgentDashboardMode } from './agent-dashboard-open-mode' + +function dashboardState(): Parameters[0] { + return { + activeView: 'terminal', + settings: { + experimentalAgentDashboardPopout: true, + experimentalAgentDashboardMode: 'popout' + }, + agentDashboardDrawerOpen: false, + setSidebarOpen: vi.fn(), + setAgentDashboardDrawerOpen: vi.fn() + } +} + +describe('Agent Dashboard open mode', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('opens the pop-out window on desktop when the setting asks for it', () => { + const state = dashboardState() + const openPopout = vi.fn() + toggleAgentDashboardFromShortcut(state, openPopout) + expect(openPopout).toHaveBeenCalledTimes(1) + expect(state.setAgentDashboardDrawerOpen).not.toHaveBeenCalled() + }) + + // #22218: a browser tab has no second window, so pop-out was a silent no-op. + it('opens the in-window board in a web client even when the setting says pop-out', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = dashboardState() + const openPopout = vi.fn() + expect(resolveAgentDashboardMode(state.settings)).toBe('in-window') + toggleAgentDashboardFromShortcut(state, openPopout) + expect(openPopout).not.toHaveBeenCalled() + expect(state.setAgentDashboardDrawerOpen).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts new file mode 100644 index 000000000000..69606c872592 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts @@ -0,0 +1,16 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' + +/** A browser tab cannot open a second Orca window, so a web client only has the in-window board. */ +export function canOpenAgentDashboardPopout(): boolean { + return !isPairedWebClientWindow() +} + +export function resolveAgentDashboardMode( + settings: Pick | null | undefined +): AgentDashboardMode { + return canOpenAgentDashboardPopout() + ? (settings?.experimentalAgentDashboardMode ?? 'in-window') + : 'in-window' +} diff --git a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx index c22f5a9050a7..cfafcdaab6d7 100644 --- a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx @@ -4,6 +4,7 @@ import { Label } from '../ui/label' import { SearchableSetting } from './SearchableSetting' import { SettingsSegmentedControl, SettingsSwitch } from './SettingsFormControls' import { getExperimentalSearchEntry } from './experimental-search' +import { canOpenAgentDashboardPopout } from '../dashboard/agent-dashboard-open-mode' type AgentDashboardExperimentalSettingProps = { settings: GlobalSettings @@ -55,7 +56,7 @@ export function AgentDashboardExperimentalSetting({ onChange={() => updateSettings({ experimentalAgentDashboardPopout: !enabled })} />
- {enabled ? ( + {enabled && canOpenAgentDashboardPopout() ? (
diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx index 4b997ca2cc07..fb1e2c7025a8 100644 --- a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' +import { resolveAgentDashboardMode } from '@/components/dashboard/agent-dashboard-open-mode' import { translate } from '@/i18n/i18n' const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { @@ -61,7 +62,7 @@ function DashboardBucketCounts({ export default function AgentDashboardSidebarEntry(): React.JSX.Element { const dashboardBucketCounts = useAgentBucketCounts() const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) - const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout') + const openAsPopout = useAppStore((s) => resolveAgentDashboardMode(s.settings) === 'popout') const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts index 32812f74d717..75882470f1b0 100644 --- a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -1,14 +1,17 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../../store/types' +import { resolveAgentDashboardMode } from '../../components/dashboard/agent-dashboard-open-mode' export function toggleAgentDashboardFromShortcut( state: Pick< AppState, - | 'activeView' - | 'settings' - | 'agentDashboardDrawerOpen' - | 'setSidebarOpen' - | 'setAgentDashboardDrawerOpen' - >, + 'activeView' | 'agentDashboardDrawerOpen' | 'setSidebarOpen' | 'setAgentDashboardDrawerOpen' + > & { + settings: Pick< + GlobalSettings, + 'experimentalAgentDashboardPopout' | 'experimentalAgentDashboardMode' + > | null + }, openPopout: () => void ): void { if ( @@ -17,7 +20,7 @@ export function toggleAgentDashboardFromShortcut( ) { return } - if (state.settings.experimentalAgentDashboardMode === 'popout') { + if (resolveAgentDashboardMode(state.settings) === 'popout') { openPopout() return } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 622478e339f4..bdab634f7c2b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1134,7 +1134,8 @@ }, "file": { "preview": { - "pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet." + "pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet.", + "webClientUnavailable": "Document previews aren't available in the web client." } }, "clipboardWriteFailure": { diff --git a/src/renderer/src/lib/doc-preview-web-client.ts b/src/renderer/src/lib/doc-preview-web-client.ts new file mode 100644 index 000000000000..9fc50e02db43 --- /dev/null +++ b/src/renderer/src/lib/doc-preview-web-client.ts @@ -0,0 +1,20 @@ +import { translate } from '@/i18n/i18n' +import { isPairedWebClientWindow } from './desktop-window-chrome' + +/** The document preview is a desktop webview; a paired web client has no window to host it in. */ +export function webClientDocPreviewRefusal(): { + status: 'unsupported' + message: string + reason: 'no-channel' +} | null { + return isPairedWebClientWindow() + ? { + status: 'unsupported', + message: translate( + 'auto.lib.file.preview.webClientUnavailable', + "Document previews aren't available in the web client." + ), + reason: 'no-channel' + } + : null +} diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index c6a58edec220..a945c574bfaf 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -592,3 +592,38 @@ describe('convertBrowserPageToWorkspaceDoc', () => { } ) }) + +// #22618: the doc preview is a desktop webview, so a web client must not create a tab it can +// never materialize; the action hides instead of failing silently. +describe('document previews in a paired web client', () => { + it('hides Open in Orca Browser and refuses with a reason instead of a silent no-op', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + try { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } + + expect( + canShowWorkspaceFileBrowserAction(browserActionState(), 'wt-1', '/repo/report.html') + ).toBe(false) + const plan = openFileInBrowserTab({ + filePath: '/srv/repo/docs/example.html', + worktreeId: 'wt-1' + }) + expect(plan).toMatchObject({ status: 'unsupported', reason: 'no-channel' }) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + + mocks.browserPagesByWorkspace = { 'browser-1': [{ id: 'page-1', worktreeId: 'wt-1' }] } + expect( + convertBrowserPageToWorkspaceDoc('page-1', { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/srv/repo/docs/example.html' + }) + ).toBe('failed') + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index cb36ac814adc..4a1890e55efa 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -1,6 +1,7 @@ import { toast } from 'sonner' import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { webClientDocPreviewRefusal } from '@/lib/doc-preview-web-client' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' import { basename, getRelativePathInsideRoot } from '@/lib/path' @@ -61,7 +62,7 @@ export function getWorkspaceFilePreviewPlan( } } if (connectionId !== null) { - return { status: 'doc-preview' } + return webClientDocPreviewRefusal() ?? { status: 'doc-preview' } } // Why: the doc preview needs no browser at all, so a paired runtime without the // screencast capability still previews documents. @@ -76,7 +77,7 @@ export function getWorkspaceFilePreviewPlan( reason: 'outside-worktree' } } - return { status: 'doc-preview' } + return webClientDocPreviewRefusal() ?? { status: 'doc-preview' } } const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] if (availability.state !== 'enabled') { @@ -252,6 +253,11 @@ export function convertBrowserPageToWorkspaceDoc( docLocation: BrowserPageDocLocation, options?: { leg?: BrowserPageConversionLeg } ): 'activated-existing' | 'opened-in-owning-worktree' | 'converted' | 'failed' { + const webRefusal = webClientDocPreviewRefusal() + if (webRefusal) { + toast.error(webRefusal.message) + return 'failed' + } const state = useAppStore.getState() // Why a history leg skips reuse: Back and Forward both mean "this tab, as it was" — activating // another tab showing the document would leave this one a web page with live provenance, so diff --git a/src/renderer/src/lib/folder-workspace-runtime-owner.ts b/src/renderer/src/lib/folder-workspace-runtime-owner.ts index fe185274ca9d..4ee299282cc1 100644 --- a/src/renderer/src/lib/folder-workspace-runtime-owner.ts +++ b/src/renderer/src/lib/folder-workspace-runtime-owner.ts @@ -11,6 +11,7 @@ import { getSingleFocusedRuntimeEnvironmentId, type SingleRuntimeLegacyOwnerState } from './single-runtime-legacy-owner' +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' type RuntimeExecutionHost = Extract @@ -106,7 +107,7 @@ export function getRuntimeEnvironmentIdForFolderWorkspace( folderWorkspace?.connectionId?.trim() || projectGroup?.connectionId?.trim() ) { - return null + return getPairedWebClientEnvironmentId(state) } const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId) if (restoredRuntimeHost) { diff --git a/src/renderer/src/lib/paired-web-client-host.test.ts b/src/renderer/src/lib/paired-web-client-host.test.ts new file mode 100644 index 000000000000..08254e9dc7be --- /dev/null +++ b/src/renderer/src/lib/paired-web-client-host.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' +import { resolveWorktreeOperationRoute } from './worktree-operation-route' +import { resolveTerminalHostOwnership } from './terminal-worktree-route' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' + +const ENV = 'web-env' +const ENV_HOST = `runtime:${ENV}` as const +const WT = 'repo-1::/srv/proj' + +function webState(overrides: WorktreeRuntimeOwnerState = {}): WorktreeRuntimeOwnerState { + return { + settings: { activeRuntimeEnvironmentId: null }, + runtimeEnvironments: [{ id: ENV }], + runtimeEnvironmentCatalogHydrated: true, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: ENV_HOST }], + worktreesByRepo: { + 'repo-1': [{ id: WT, repoId: 'repo-1', hostId: ENV_HOST, runtimeOwnerEnvironmentId: ENV }] + }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + ...overrides + } +} + +const UNSTAMPED_LOCAL_ROW: WorktreeRuntimeOwnerState = { + worktreesByRepo: { 'repo-1': [{ id: WT, repoId: 'repo-1', hostId: 'local' }] } +} + +describe('a paired web client routes every owner through its server (#9047)', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('routes a workspace the server calls local to the server, never to the browser', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + for (const state of [ + webState(), + webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'local' }), + webState(UNSTAMPED_LOCAL_ROW) + ]) { + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBe(ENV) + expect(resolveTerminalHostOwnership(state, WT, 'spawn')).toEqual({ + kind: 'runtime', + runtimeEnvironmentId: ENV + }) + expect(resolveWorktreeOperationRoute(state, WT)).toEqual({ + executionHostId: ENV_HOST, + runtimeEnvironmentId: ENV + }) + } + }) + + it('starts setup terminals and local folder workspaces on the server', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect( + resolveTerminalHostOwnership(webState(), 'ephemeral-setup-terminal:panel-1', 'spawn') + ).toEqual({ kind: 'runtime', runtimeEnvironmentId: ENV }) + const folderState = webState({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', executionHostId: 'local', connectionId: null } + ] + }) + expect(getRuntimeEnvironmentIdForWorktree(folderState, folderWorkspaceKey('folder-1'))).toBe( + ENV + ) + }) + + it('keeps an SSH target as the place and the server as the transport', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'ssh:target-1' }) + expect(resolveWorktreeOperationRoute(state, WT)).toEqual({ + executionHostId: 'ssh:target-1', + runtimeEnvironmentId: ENV + }) + }) + + it('still refuses an id two hosts publish instead of guessing one', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = webState({ + worktreesByRepo: { + 'repo-1': [ + { id: WT, repoId: 'repo-1', hostId: 'local' }, + { id: WT, repoId: 'repo-1', hostId: 'ssh:target-1' } + ] + } + }) + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + }) + + it('leaves a desktop window local workspace on this computer', () => { + const state = webState(UNSTAMPED_LOCAL_ROW) + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + expect(resolveTerminalHostOwnership(state, WT, 'spawn').kind).toBe('local-or-ssh') + }) +}) diff --git a/src/renderer/src/lib/paired-web-client-host.ts b/src/renderer/src/lib/paired-web-client-host.ts new file mode 100644 index 000000000000..58e0e79e9ece --- /dev/null +++ b/src/renderer/src/lib/paired-web-client-host.ts @@ -0,0 +1,37 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { isPairedWebClientWindow } from './desktop-window-chrome' + +type HostRouteState = { runtimeEnvironments?: readonly { id: string }[] } + +/** + * The paired server is a web client's only host: a browser tab has no PTYs, files or browser of its + * own, so an owner this client would call "local" can only be reached through that server. + */ +export function getPairedWebClientEnvironmentId(state: HostRouteState): string | null { + if (!isPairedWebClientWindow()) { + return null + } + const environments = state.runtimeEnvironments ?? [] + return environments.length === 1 ? environments[0].id.trim() || null : null +} + +/** A route with no server, on a web client, goes through its server; `local` there is the server's own. */ +export function onPairedWebClientHost< + T extends { executionHostId: ExecutionHostId | null; runtimeEnvironmentId: string | null } +>(state: HostRouteState, route: T): T { + const environmentId = route.runtimeEnvironmentId ? null : getPairedWebClientEnvironmentId(state) + const host = parseExecutionHostId(route.executionHostId) + // Why: an unparseable host is the never-routable sentinel; it stays refused. + if (!environmentId || (route.executionHostId && !host)) { + return route + } + return { + ...route, + executionHostId: host?.kind === 'ssh' ? host.id : toRuntimeExecutionHostId(environmentId), + runtimeEnvironmentId: environmentId + } +} diff --git a/src/renderer/src/lib/single-runtime-legacy-owner.ts b/src/renderer/src/lib/single-runtime-legacy-owner.ts index 00b39751b1b2..381d8a922779 100644 --- a/src/renderer/src/lib/single-runtime-legacy-owner.ts +++ b/src/renderer/src/lib/single-runtime-legacy-owner.ts @@ -1,3 +1,5 @@ +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' + export type SingleRuntimeLegacyOwnerState = { settings?: { activeRuntimeEnvironmentId?: string | null } | null runtimeEnvironments?: readonly { id: string }[] @@ -6,6 +8,10 @@ export type SingleRuntimeLegacyOwnerState = { export function getSingleFocusedRuntimeEnvironmentId( state: SingleRuntimeLegacyOwnerState ): string | null { + const webHost = getPairedWebClientEnvironmentId(state) + if (webHost) { + return webHost + } const focused = state.settings?.activeRuntimeEnvironmentId?.trim() if (!focused) { return null diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 98132ebe54c9..6d89b75adca1 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -14,6 +14,7 @@ import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' +import { onPairedWebClientHost } from './paired-web-client-host' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -188,15 +189,18 @@ function resolveSelectedHostRoute( } // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. if (selectedHost.kind !== 'ssh') { - return { executionHostId: selectedHost.id, runtimeEnvironmentId: null } + return onPairedWebClientHost(state, { + executionHostId: selectedHost.id, + runtimeEnvironmentId: null + }) } const { environmentIds } = collectHubOwnerEnvironmentIds(state, worktreeId, selectedHost.id) const environmentId = environmentIds.values().next().value - return { + return onPairedWebClientHost(state, { executionHostId: selectedHost.id, // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null - } + }) } /** @@ -246,6 +250,16 @@ export function resolveWorktreeOperationRoute( export function resolveWorktreeOperationRouteResult( state: WorktreeOperationRouteState, worktreeId: string +): WorktreeOperationRouteResolution { + const resolution = resolveOwnerRouteResult(state, worktreeId) + return resolution.kind === 'resolved' + ? { kind: 'resolved', route: onPairedWebClientHost(state, resolution.route) } + : resolution +} + +function resolveOwnerRouteResult( + state: WorktreeOperationRouteState, + worktreeId: string ): WorktreeOperationRouteResolution { const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) if (activeRoute) { diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 9942cb0e106e..3c04be33ab1b 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -17,6 +17,7 @@ import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner' +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -89,9 +90,12 @@ export function getRuntimeEnvironmentIdForWorktree( const projectedRuntimeOwner = getProjectedRuntimeOwnerEnvironmentId(owner) const parsedHost = parseExecutionHostId(owner.hostId) const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) + // Why: a row on a web client that names no server is still reached through its only one. + const onlyHost = getPairedWebClientEnvironmentId(state) if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { return ( - projectedRuntimeOwner || (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + projectedRuntimeOwner || + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) ) } if (!hasDetectedOwner) { @@ -105,7 +109,7 @@ export function getRuntimeEnvironmentIdForWorktree( ) { const repoHost = parseExecutionHostId(getRepoExecutionHostId(repoResolution.owner)) if (repoHost) { - return repoHost.kind === 'runtime' ? repoHost.environmentId : null + return repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost } } } From 5c7c4930421aca419feb290c36905c19cdb10e2a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 09:17:11 -0700 Subject: [PATCH 46/54] fix(sources): GitHub Projects boards read from the task page's source host (#27270) * fix(sources): GitHub Projects boards read from the task page's source host The Projects board, its project picker and unmatched-row edits picked their host from the Active Server setting on their own, while the Tasks header named the selected repositories' hosts. The board now takes the same row-less source host that Linear and Jira use, converted through getTaskSourceRuntimeSettings, and the header names that host in Projects mode. fetchProjectViewTable takes the source explicitly instead of reading settings. The ownership table marks the four board-level GitHub Projects reads as scope-picked. * fix(github-projects): an open unmatched-row dialog keeps writing to its row's host The dialog took the board's live source, so a source change while it was open sent its comment and field edits to a different host than the one the row was loaded from. It now routes by the source encoded in its row's cache key, like the row field edits already do. --------- Co-authored-by: m4air --- config/owner-routing-baseline.txt | 1 - .../github-project/ProjectPicker.tsx | 20 ++++++--- .../github-project/ProjectViewToolbar.tsx | 4 +- .../github-project/ProjectViewWrapper.tsx | 35 ++++++++++++--- .../github-project/project-picker-runtime.ts | 15 ++++--- .../github-project/useProjectPickerBrowse.ts | 5 ++- .../github-project/useProjectViewTable.ts | 38 ++++++---------- .../components/task-page-source-context.tsx | 22 +++++++++- ...page-task-source-host-availability.test.ts | 34 +++++++++++++- .../src/components/task-page/Content.tsx | 6 ++- .../src/components/task-page/SourceBar.tsx | 19 +++++++- .../components/task-source-context-summary.ts | 2 +- .../src/store/github/project-actions.ts | 6 +-- .../provider-request-coalescing.test.ts | 8 ++-- src/renderer/src/store/github/slice-types.ts | 3 ++ ...ithub-project-request-coordination.test.ts | 26 +++++------ .../slices/github-project-view-tables.test.ts | 44 ++++++++++--------- ...ithub-provider-request-concurrency.test.ts | 2 +- src/shared/host-operation-ownership.test.ts | 9 ++++ src/shared/host-operation-ownership.ts | 15 ++++++- 20 files changed, 219 insertions(+), 95 deletions(-) diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 9742a9aa35e0..7f4a13042581 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -22,7 +22,6 @@ 4 src/renderer/src/components/github-project/project-picker-runtime.ts 1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx 1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx -2 src/renderer/src/components/github-project/useProjectViewTable.ts 1 src/renderer/src/components/github/github-rate-limit-display.tsx 4 src/renderer/src/components/github/github-work-item-edit-mutations.ts 1 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 838348cbb5bb..7b1100fcc3d7 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -27,7 +27,8 @@ import { parseProjectInput } from './project-picker-input' import { getProjectPickerBrowseHost, listProjectViewsForRuntime, - resolveProjectRefForRuntime + resolveProjectRefForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' import type { ProjectPickerChoice, ResolvedProjectSelection } from './project-picker-selection' import { useProjectPickerBrowse } from './useProjectPickerBrowse' @@ -45,6 +46,7 @@ type Props = { title?: string } | null onSelect: (selection: ResolvedProjectSelection) => void + sourceSettings: ProjectSourceSettings } const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { @@ -54,7 +56,11 @@ const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { activeProject: null } -export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element { +export default function ProjectPicker({ + activeProject, + onSelect, + sourceSettings +}: Props): React.JSX.Element { const settings = useAppStore((state) => state.settings) const updateSettings = useAppStore((state) => state.updateSettings) const mountedRef = useMountedRef() @@ -71,7 +77,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [viewList, setViewList] = useState([]) const [viewLoading, setViewLoading] = useState(false) const browseHost = getProjectPickerBrowseHost(activeProject ?? projectSettings.activeProject) - const browse = useProjectPickerBrowse(settings, browseHost) + const browse = useProjectPickerBrowse(sourceSettings, browseHost) const { loadBrowse } = browse useEffect(() => { @@ -143,7 +149,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setViewPickFor(selection) setViewLoading(true) try { - const result = await listProjectViewsForRuntime(settings, { + const result = await listProjectViewsForRuntime(sourceSettings, { owner: choice.owner, ownerType: choice.ownerType, projectNumber: choice.number, @@ -179,7 +185,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React } } }, - [commitSelection, mountedRef, projectSettings.lastViewByProject, settings] + [commitSelection, mountedRef, projectSettings.lastViewByProject, sourceSettings] ) const handlePaste = useCallback(async () => { @@ -196,7 +202,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteError(null) setPasteBusy(true) try { - const result = await resolveProjectRefForRuntime(settings, input, parsed.host) + const result = await resolveProjectRefForRuntime(sourceSettings, input, parsed.host) if (!mountedRef.current) { return } @@ -218,7 +224,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteBusy(false) } } - }, [handleChooseProject, mountedRef, pasteInput, settings]) + }, [handleChooseProject, mountedRef, pasteInput, sourceSettings]) const filteredBrowse = useMemo( () => diff --git a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx index d03489d73894..c9593082915c 100644 --- a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx +++ b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx @@ -19,7 +19,8 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl loading, viewId, doFetch, - setAppliedQueryByView + setAppliedQueryByView, + sourceSettings } = tableState const selectedViewUrl = table ? `${table.project.url}/views/${table.selectedView.number ?? ''}` @@ -58,6 +59,7 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl : null } onSelect={(selection) => void doFetch(selection, true)} + sourceSettings={sourceSettings} /> {currentProjectViewKey ? ( } +type Props = { + selectedRepoIds: ReadonlySet + /** Row-less source host (shared with Linear and Jira): the board, picker and unmatched rows read from it. */ + sourceHostId: ExecutionHostId +} -export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { - const tableState = useProjectViewTable(selectedRepoIds) +export default function ProjectViewWrapper({ + selectedRepoIds, + sourceHostId +}: Props): React.JSX.Element { + const sourceSettings = useMemo( + () => getTaskSourceRuntimeSettings({ hostId: sourceHostId }), + [sourceHostId] + ) + const tableState = useProjectViewTable(selectedRepoIds, sourceSettings) const rowActions = useProjectRowActions({ table: tableState.table, currentCacheKey: tableState.currentCacheKey, selectedRepoIds }) const addRepo = useAppStore((state) => state.addRepo) + const slugOrigin = rowActions.missingDialogs.slugDialog?.origin ?? null + const slugCacheKey = slugOrigin?.cacheKey ?? null + // Why: an open dialog keeps writing to the host its row was loaded from, even if the board's source moves. + const slugSourceSettings = useMemo( + () => (slugCacheKey ? settingsForProjectViewCacheKey(null, slugCacheKey) : null), + [slugCacheKey] + ) return (
@@ -44,8 +65,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS ) : null} rowActions.setSlugDialog(null)} /> ) if (visibleTable.selectedView.layout === 'ROADMAP_LAYOUT') { diff --git a/src/renderer/src/components/github-project/project-picker-runtime.ts b/src/renderer/src/components/github-project/project-picker-runtime.ts index af91cc378201..b3efed48defc 100644 --- a/src/renderer/src/components/github-project/project-picker-runtime.ts +++ b/src/renderer/src/components/github-project/project-picker-runtime.ts @@ -1,4 +1,5 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { githubProjectHost } from '../../../../shared/github/project-identity' import type { GitHubProjectOwnerType } from '../../../../shared/github/project-types' import type { @@ -7,9 +8,13 @@ import type { ResolveProjectRefResult } from '../../../../shared/github/project-result-types' -type RuntimeSettings = Parameters[0] +/** The board's row-less source host, from `getTaskSourceRuntimeSettings`; never the focused server. */ +export type ProjectSourceSettings = Pick -export function getProjectPickerRuntimeScope(settings: RuntimeSettings, host: string): string { +export function getProjectPickerRuntimeScope( + settings: ProjectSourceSettings, + host: string +): string { const target = getActiveRuntimeTarget(settings) const runtimeScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' return `${runtimeScope}\0${host.toLowerCase()}` @@ -20,7 +25,7 @@ export function getProjectPickerBrowseHost(activeProject: { host?: string } | nu } export async function listAccessibleProjectsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, host: string ): Promise { const target = getActiveRuntimeTarget(settings) @@ -33,7 +38,7 @@ export async function listAccessibleProjectsForRuntime( } export async function listProjectViewsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, args: { owner: string ownerType: GitHubProjectOwnerType @@ -50,7 +55,7 @@ export async function listProjectViewsForRuntime( } export async function resolveProjectRefForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, input: string, host?: string ): Promise { diff --git a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts index b29bd8ef756d..0c49c6370021 100644 --- a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts +++ b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts @@ -9,11 +9,12 @@ import { } from './project-picker-browse-cache' import { getProjectPickerRuntimeScope, - listAccessibleProjectsForRuntime + listAccessibleProjectsForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' export function useProjectPickerBrowse( - settings: Parameters[0], + settings: ProjectSourceSettings, browseHost: string ): { browseProjects: GitHubProjectSummary[] diff --git a/src/renderer/src/components/github-project/useProjectViewTable.ts b/src/renderer/src/components/github-project/useProjectViewTable.ts index 2649dbe19962..5fbd9ee5f088 100644 --- a/src/renderer/src/components/github-project/useProjectViewTable.ts +++ b/src/renderer/src/components/github-project/useProjectViewTable.ts @@ -2,9 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' -import { projectViewCacheKey } from '@/store/github/cache-identity' +import { projectViewCacheKey, projectViewSourceScope } from '@/store/github/cache-identity' import type { GitHubProjectSettings, GitHubProjectTable, @@ -12,14 +11,14 @@ import type { } from '../../../../shared/github/project-types' import type { GetProjectViewTableResult, - GitHubProjectViewError, - ListProjectViewsResult + GitHubProjectViewError } from '../../../../shared/github/project-result-types' import { githubProjectHost, githubProjectIdentityKey } from '../../../../shared/github/project-identity' import type { ResolvedProjectSelection } from './project-picker-selection' +import { listProjectViewsForRuntime, type ProjectSourceSettings } from './project-picker-runtime' import { filterProjectTableRowsBySelectedRepos } from './project-row-filtering' import { getNextVisibleProjectTableCache, @@ -29,21 +28,10 @@ import { } from './project-visible-table-cache' import { useRepoSlugIndex } from '@/lib/repo-slug-index' -type Settings = Parameters[0] - -async function listProjectViewsForRuntime( - settings: Settings, - args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number; host?: string } -): Promise { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'github.project.listViews', args, { - timeoutMs: 30_000 - }) - : window.api.gh.listProjectViews(args) -} - -export function useProjectViewTable(selectedRepoIds: ReadonlySet) { +export function useProjectViewTable( + selectedRepoIds: ReadonlySet, + sourceSettings: ProjectSourceSettings +) { const settings = useAppStore((state) => state.settings) const projectViewCache = useAppStore((state) => state.projectViewCache) const fetchProjectViewTable = useAppStore((state) => state.fetchProjectViewTable) @@ -51,8 +39,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {} const { lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() - const target = getActiveRuntimeTarget(settings) - const sourceScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' + const sourceScope = projectViewSourceScope(sourceSettings) const [loading, setLoading] = useState(false) const [error, setError] = useState<{ error: GitHubProjectViewError; totalCount?: number } | null>( null @@ -71,6 +58,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { setError(null) try { const result: GetProjectViewTableResult = await fetchProjectViewTable( + sourceSettings, { owner: selection.owner, ownerType: selection.ownerType, @@ -93,7 +81,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { } } }, - [fetchProjectViewTable, mountedRef] + [fetchProjectViewTable, mountedRef, sourceSettings] ) const projectIdentity = activeProject ? githubProjectIdentityKey(activeProject) : null @@ -143,7 +131,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return } let cancelled = false - void listProjectViewsForRuntime(settings, { + void listProjectViewsForRuntime(sourceSettings, { owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, @@ -164,7 +152,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return () => { cancelled = true } - }, [activeProject, settings, sourceScope, viewListByProject]) + }, [activeProject, sourceSettings, sourceScope, viewListByProject]) const selectedRepoFingerprint = useMemo( () => getSelectedRepoFingerprint(selectedRepoIds), @@ -247,7 +235,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { ) return { - settings, + sourceSettings, activeProject, sourceScope, viewId, diff --git a/src/renderer/src/components/task-page-source-context.tsx b/src/renderer/src/components/task-page-source-context.tsx index 469f6dd6c73d..260e1dcae632 100644 --- a/src/renderer/src/components/task-page-source-context.tsx +++ b/src/renderer/src/components/task-page-source-context.tsx @@ -12,7 +12,11 @@ import { import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { getRepoExecutionHostId } from '../../../shared/execution-host' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import type { TaskSourceHostAvailability } from './task-source-context-summary' +import { + getAccountBackedTaskSourceSummary, + type TaskSourceContextSummary, + type TaskSourceHostAvailability +} from './task-source-context-summary' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { cn } from '@/lib/utils' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' @@ -134,6 +138,22 @@ export function getTaskSourceHostAvailabilityForHost( status: host.connectionStatus } } +/** Projects boards have no source row: name the shared row-less host, not the selected repos. */ +export function getGitHubProjectSourceSummary(args: { + providerLabel: string + hostId: TaskSourceContext['hostId'] + host: ExecutionHostRegistryEntry | null | undefined + hostLabelById: ReadonlyMap +}): TaskSourceContextSummary { + const availability = getTaskSourceHostAvailabilityForHost(args.host, args.hostId) + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: null, + accountHostId: args.hostId, + hostLabelById: args.hostLabelById, + // Why: boards need no task-source capability, so only host health applies. + hostAvailability: availability && !availability.reason ? [availability] : [] + }) +} export function getTaskPageRepoCacheInput(repo: Repo): { id: string path: string diff --git a/src/renderer/src/components/task-page-task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts index 3fe0261beafb..c3dde4bb742f 100644 --- a/src/renderer/src/components/task-page-task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context' +import { + getGitHubProjectSourceSummary, + getTaskSourceHostAvailabilityForHost +} from './task-page-source-context' // Why: a complete typed entry keeps these cases honest when the registry shape changes. function hostEntry( @@ -80,3 +83,32 @@ describe('getTaskSourceHostAvailabilityForHost', () => { }) }) }) + +describe('getGitHubProjectSourceSummary', () => { + const runtimeHost = (overrides: Partial) => + hostEntry({ id: 'runtime:env-1', kind: 'runtime', label: 'Build server', ...overrides }) + + it('names the board host, which a capability check alone never marks unavailable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ health: 'available' }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toBe('GitHub · Build server · Current account') + }) + + it('says when the board host is unreachable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ + health: 'disconnected', + capabilities: [TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY] + }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toContain('Build server · ') + expect(summary.label).not.toBe('GitHub · Build server · Current account') + }) +}) diff --git a/src/renderer/src/components/task-page/Content.tsx b/src/renderer/src/components/task-page/Content.tsx index ebe40e3ff287..9688056e343f 100644 --- a/src/renderer/src/components/task-page/Content.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -20,6 +20,7 @@ export function TaskPageContent({ dialogWorkItem, dialogRepoPath, dialogSourceContext, + accountBackedTaskSourceHostId, setDialogWorkItem, handleDialogReviewRequestsChange, closeTaskDetailPage, @@ -59,7 +60,10 @@ export function TaskPageContent({ ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? ( // Why: bottom of the joined GitHub list card — flush under the filter diff --git a/src/renderer/src/components/task-page/SourceBar.tsx b/src/renderer/src/components/task-page/SourceBar.tsx index 8bcedea82a52..83812b3ed8bb 100644 --- a/src/renderer/src/components/task-page/SourceBar.tsx +++ b/src/renderer/src/components/task-page/SourceBar.tsx @@ -6,6 +6,7 @@ import { X, ExternalLink } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearScopeSelector } from '@/components/linear-scope-selector' +import { getGitHubProjectSourceSummary } from '../task-page-source-context' import { Select, SelectTrigger, @@ -33,6 +34,10 @@ export function TaskPageSourceBar({ taskSource, taskSourceAvailabilityNoticeByProvider, taskSourceContextSummary, + githubMode, + accountBackedTaskSourceHostId, + hostRegistryById, + hostLabelById, taskSourceManuallyChangedRef, setSelectedJiraIssueKey, setSelectedJiraIssueFallback, @@ -48,6 +53,16 @@ export function TaskPageSourceBar({ handleLinearTeamSelectionChange, handleLinearScopeOpen } = model + const sourceSummary = + taskSource === 'github' && githubMode === 'project' + ? getGitHubProjectSourceSummary({ + providerLabel: + visibleSourceOptions.find((source) => source.id === 'github')?.label ?? 'GitHub', + hostId: accountBackedTaskSourceHostId, + host: hostRegistryById.get(accountBackedTaskSourceHostId), + hostLabelById + }) + : taskSourceContextSummary return (
- {taskSourceContextSummary.label} + {sourceSummary.label}
{taskSource === 'linear' && linearConnected ? ( diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts index af5e2879d0c9..c99f13fe0ef7 100644 --- a/src/renderer/src/components/task-source-context-summary.ts +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -150,7 +150,7 @@ function getRepoBackedTaskSourceSummary(args: { } } -function getAccountBackedTaskSourceSummary( +export function getAccountBackedTaskSourceSummary( providerLabel: string, args: { accountLabel: string | null | undefined diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts index ca1e5b24da04..f6943566b4e9 100644 --- a/src/renderer/src/store/github/project-actions.ts +++ b/src/renderer/src/store/github/project-actions.ts @@ -25,9 +25,9 @@ export const createProjectActions = ( GitHubSlice, 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' > => ({ - fetchProjectViewTable: async (args, options) => { - const target = getActiveRuntimeTarget(get().settings) - const sourceScope = projectViewSourceScope(get().settings) + fetchProjectViewTable: async (sourceSettings, args, options) => { + const target = getActiveRuntimeTarget(sourceSettings) + const sourceScope = projectViewSourceScope(sourceSettings) const requestKey = projectViewRequestKey(args, sourceScope) // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. diff --git a/src/renderer/src/store/github/provider-request-coalescing.test.ts b/src/renderer/src/store/github/provider-request-coalescing.test.ts index f012999ce65d..5367fb1989d5 100644 --- a/src/renderer/src/store/github/provider-request-coalescing.test.ts +++ b/src/renderer/src/store/github/provider-request-coalescing.test.ts @@ -308,8 +308,10 @@ describe('GitHub provider request upgrade coalescing', () => { .mockReturnValueOnce(replacementWeak.promise) .mockReturnValue(forcedResult.promise) - const first = store.getState().fetchProjectViewTable(projectViewRequest) - const forcedFetch = store.getState().fetchProjectViewTable(projectViewRequest, { force: true }) + const first = store.getState().fetchProjectViewTable(null, projectViewRequest) + const forcedFetch = store + .getState() + .fetchProjectViewTable(null, projectViewRequest, { force: true }) // Why: only a non-forced entry can be superseded mid-flight, and it has to appear in the // microtask after the first request clears the key — before the forced waiter re-checks it. const pending = inflightProjectViewRequests.get( @@ -318,7 +320,7 @@ describe('GitHub provider request upgrade coalescing', () => { expect(pending).toBeDefined() let replacementFetch: Promise | undefined void pending?.promise.then(() => { - replacementFetch = store.getState().fetchProjectViewTable(projectViewRequest) + replacementFetch = store.getState().fetchProjectViewTable(null, projectViewRequest) }) const staleError = { type: 'network_error' as const, message: 'first attempt offline' } diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 6a5116804d3d..6943d07ab5d1 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -26,6 +26,7 @@ import type { } from '../../../../shared/github/project-result-types' import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { CacheEntry, FetchOptions, @@ -253,7 +254,9 @@ export type GitHubSlice = { evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void // ── ProjectV2 view cache ───────────────────────────────────────────── projectViewCache: Record> + /** `sourceSettings` names the board's row-less source host; the focused server is never read. */ fetchProjectViewTable: ( + sourceSettings: Pick | null, args: GetProjectViewTableArgs, options?: FetchOptions ) => Promise diff --git a/src/renderer/src/store/slices/github-project-request-coordination.test.ts b/src/renderer/src/store/slices/github-project-request-coordination.test.ts index 40a64f77ce75..8b77d5a02a2e 100644 --- a/src/renderer/src/store/slices/github-project-request-coordination.test.ts +++ b/src/renderer/src/store/slices/github-project-request-coordination.test.ts @@ -50,7 +50,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const table = makeTable() store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) - await expect(store.getState().fetchProjectViewTable(request)).resolves.toEqual({ + await expect(store.getState().fetchProjectViewTable(null, request)).resolves.toEqual({ ok: true, data: table }) @@ -62,8 +62,8 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const pending = Promise.withResolvers() mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) - const first = store.getState().fetchProjectViewTable(request) - const duplicate = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) + const duplicate = store.getState().fetchProjectViewTable(null, request) pending.resolve({ ok: true, data: makeTable() }) await expect(Promise.all([first, duplicate])).resolves.toEqual([ @@ -80,9 +80,9 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { .mockReturnValueOnce(weak.promise) .mockResolvedValue({ ok: true, data: makeTable('forced') }) - const first = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) const forced = Array.from({ length: 20 }, () => - store.getState().fetchProjectViewTable(request, { force: true }) + store.getState().fetchProjectViewTable(null, request, { force: true }) ) weak.resolve({ ok: true, data: makeTable('weak') }) @@ -100,17 +100,17 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { store.setState({ projectViewCache: { [cacheKey]: { data: stale, fetchedAt: 1 } } }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) - await expect(store.getState().fetchProjectViewTable(request, { force: true })).resolves.toEqual( - { - ok: false, - error - } - ) + await expect( + store.getState().fetchProjectViewTable(null, request, { force: true }) + ).resolves.toEqual({ + ok: false, + error + }) expect(store.getState().projectViewCache[cacheKey]).toMatchObject({ data: stale, error }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 @@ -123,7 +123,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 diff --git a/src/renderer/src/store/slices/github-project-view-tables.test.ts b/src/renderer/src/store/slices/github-project-view-tables.test.ts index faa589824c30..43e51bf0dce0 100644 --- a/src/renderer/src/store/slices/github-project-view-tables.test.ts +++ b/src/renderer/src/store/slices/github-project-view-tables.test.ts @@ -23,7 +23,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) - it('routes project table fetches through the active runtime environment', async () => { + it('routes project table fetches through the board source, not the focused server', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } @@ -60,17 +60,20 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - const result = await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + const result = await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-board' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) expect(result.ok).toBe(true) expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', + selector: 'env-board', method: 'github.project.viewTable', params: { owner: 'acme', @@ -119,16 +122,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-1' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) - store.setState({ - settings: { activeRuntimeEnvironmentId: null } - } as Partial) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: true, data: { @@ -156,7 +159,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { } }) - const localResult = await store.getState().fetchProjectViewTable({ + const localResult = await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -207,7 +210,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { .mockResolvedValueOnce({ ok: true, data: makeTable('ghe.example', 'enterprise-project') }) for (const host of ['github.com', 'ghe.example']) { - await store.getState().fetchProjectViewTable({ + await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -483,7 +486,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { try { const store = createTestStore() mockApi.gh.getProjectViewTable.mockImplementation( - async (args: Parameters[0]) => ({ + async (args: Parameters[1]) => ({ ok: true, data: { project: { @@ -514,6 +517,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { for (let i = 0; i <= 500; i++) { vi.setSystemTime(1_000 + i) await store.getState().fetchProjectViewTable( + null, { owner: 'acme', ownerType: 'organization', diff --git a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts index 30717fca0d96..64a51027d590 100644 --- a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts +++ b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts @@ -30,7 +30,7 @@ describe('GitHub provider request concurrency', () => { .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') .catch(() => []) ) - const queuedProject = store.getState().fetchProjectViewTable({ + const queuedProject = store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, diff --git a/src/shared/host-operation-ownership.test.ts b/src/shared/host-operation-ownership.test.ts index 8253b653570f..6e485063e83d 100644 --- a/src/shared/host-operation-ownership.test.ts +++ b/src/shared/host-operation-ownership.test.ts @@ -20,6 +20,15 @@ describe('getHostOperationOwnership', () => { expect(getHostOperationOwnership('jira.getIssueStream')).toBe('scope-picked') }) + it('reads row-less GitHub Projects board methods as scope-picked', () => { + expect(getHostOperationOwnership('github.project.viewTable')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listAccessible')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listViews')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.resolveRef')).toBe('scope-picked') + // Row edits route to the matched repo's owner first, so they stay credential-sourced. + expect(getHostOperationOwnership('github.project.updateItemField')).toBe('credential') + }) + it('keeps client effects client and returns null for unknown names', () => { expect(getHostOperationOwnership('shell.openPath')).toBe('client') expect(getHostOperationOwnership('nope.call')).toBeNull() diff --git a/src/shared/host-operation-ownership.ts b/src/shared/host-operation-ownership.ts index a3d71cda6161..f13b7ec1298c 100644 --- a/src/shared/host-operation-ownership.ts +++ b/src/shared/host-operation-ownership.ts @@ -67,6 +67,17 @@ export const HOST_OPERATION_OWNERSHIP = { worktree: 'host' } as const satisfies Record +/** Methods whose owner differs from their namespace: row-less forge reads use the source picker. */ +const HOST_OPERATION_METHOD_OWNERSHIP = { + 'github.project.listAccessible': 'scope-picked', + 'github.project.listViews': 'scope-picked', + 'github.project.resolveRef': 'scope-picked', + 'github.project.viewTable': 'scope-picked' +} as const satisfies Record + +const OWNERSHIP_BY_METHOD: ReadonlyMap = new Map( + Object.entries(HOST_OPERATION_METHOD_OWNERSHIP) +) const OWNERSHIP_BY_NAMESPACE: ReadonlyMap = new Map( Object.entries(HOST_OPERATION_OWNERSHIP) ) @@ -74,7 +85,9 @@ const STREAM_METHOD = /^(un)?(subscribe|watch)/i /** Null for a namespace nobody classified, so callers fail closed. */ export function getHostOperationOwnership(method: string): HostOperationOwnership | null { - const ownership = OWNERSHIP_BY_NAMESPACE.get(method.slice(0, method.indexOf('.'))) + const ownership = + OWNERSHIP_BY_METHOD.get(method) ?? + OWNERSHIP_BY_NAMESPACE.get(method.slice(0, method.indexOf('.'))) if (!ownership || !method.includes('.')) { return null } From d284965952a7085a3dba7e67a87f45c8771179fd Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:53:13 +0800 Subject: [PATCH 47/54] fix(sync): restore fork automation agentFlags after auto-merge The extraArgs -> agentFlags rename lives on fork-only files that git auto-merged from origin/fork-main. Take the PR15 versions so CLI/RPC/launch agree with the conflict-resolved automation-params types. --- src/cli/handlers/automation-model-flags.ts | 12 +++---- src/cli/index-automation-model.test.ts | 8 ++--- .../headless-automation-agent-launch.ts | 11 ++++++ .../automations/headless-workspace-create.ts | 2 +- .../automation-launch-field-updates.ts | 27 +++++++------- .../runtime-automation-update-value.ts | 3 +- .../automations/automation-draft-model.ts | 9 +++++ .../automations/automation-save-action.ts | 10 ++++++ src/shared/automation-model-launch.test.ts | 14 ++++---- src/shared/automation-model.ts | 36 +++++++++++++------ 10 files changed, 90 insertions(+), 42 deletions(-) diff --git a/src/cli/handlers/automation-model-flags.ts b/src/cli/handlers/automation-model-flags.ts index c03a03fd3b3b..6a4795d8736f 100644 --- a/src/cli/handlers/automation-model-flags.ts +++ b/src/cli/handlers/automation-model-flags.ts @@ -1,6 +1,6 @@ import { normalizeAutomationReasoningEffort, - validateAutomationExtraArgs, + validateAutomationAgentFlags, type AutomationReasoningEffort } from '../../shared/automation-model' import { getOptionalStringFlag } from '../flags' @@ -42,20 +42,20 @@ export function getAgentProfileFlag( return 'minimal' } -export function getExtraArgsFlag(flags: Map): string | null | undefined { - if (!flags.has('extra-args')) { +export function getAgentFlagsFlag(flags: Map): string | null | undefined { + if (!flags.has('agent-flags')) { return undefined } - const raw = getOptionalStringFlag(flags, 'extra-args') ?? '' + const raw = getOptionalStringFlag(flags, 'agent-flags') ?? '' if (!raw) { return null } try { - return validateAutomationExtraArgs(raw) ?? null + return validateAutomationAgentFlags(raw) ?? null } catch (error) { throw new RuntimeClientError( 'invalid_argument', - error instanceof Error ? error.message : 'Invalid extra args' + error instanceof Error ? error.message : 'Invalid agent flags' ) } } diff --git a/src/cli/index-automation-model.test.ts b/src/cli/index-automation-model.test.ts index bc4977382678..09a01d76da1b 100644 --- a/src/cli/index-automation-model.test.ts +++ b/src/cli/index-automation-model.test.ts @@ -99,7 +99,7 @@ describe('orca cli automation model flag', () => { 'deepseek-v4-flash', '--reasoning-effort', 'xhigh', - '--extra-args=--verbose', + '--agent-flags=--verbose', '--workspace', 'current', '--json' @@ -125,7 +125,7 @@ describe('orca cli automation model flag', () => { agentId: 'grok', model: 'deepseek-v4-flash', reasoningEffort: 'xhigh', - extraArgs: '--verbose' + agentFlags: '--verbose' }) ) expect(callsFor('automation.update').at(-1)?.[1]).toEqual( @@ -135,7 +135,7 @@ describe('orca cli automation model flag', () => { ) }) - it('rejects protected model flags in --extra-args', async () => { + it('rejects protected model flags in --agent-flags', async () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), @@ -154,7 +154,7 @@ describe('orca cli automation model flag', () => { 'Run', '--provider', 'codex', - '--extra-args=--model sneak', + '--agent-flags=--model sneak', '--workspace', 'current', '--json' diff --git a/src/main/automations/headless-automation-agent-launch.ts b/src/main/automations/headless-automation-agent-launch.ts index a61fe6033b0b..455c5f9d44ee 100644 --- a/src/main/automations/headless-automation-agent-launch.ts +++ b/src/main/automations/headless-automation-agent-launch.ts @@ -16,6 +16,7 @@ import type { import { executeAgentLaunch } from '../agent-launch/agent-launch-executor' import type { Automation, AutomationRun } from '../../shared/automations-types' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { buildAutomationModelLaunchPreferences } from '../../shared/automation-model' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create' @@ -46,6 +47,15 @@ export async function launchHeadlessAutomationAgent( const agent = automation.agentId // The startup plan trims the prompt for every agent; a blank one launches the agent bare. const text = automation.prompt?.trim() + // Fork: per-automation model/effort/profile and agent flags. The new-per-run create carries + // the same preferences through buildHeadlessAutomationWorktreeCreateArgs. + const launchPreferences = buildAutomationModelLaunchPreferences( + agent, + automation.model, + automation.reasoningEffort, + automation.agentProfile, + automation.agentFlags + ) let workspace: { id: string; displayName: string | null } | undefined let terminal: LaunchedTerminal | undefined const execution: AgentLaunchSurfaceExecution = { @@ -105,6 +115,7 @@ export async function launchHeadlessAutomationAgent( agent, prompt: startupPrompt ?? '', title: run.title, + ...(launchPreferences ? { launchPreferences } : {}), ...(automation.extraAgentArgs ? { extraAgentArgs: automation.extraAgentArgs } : {}) }) terminal = launched diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index f829d74bf9b3..12d249896d72 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -38,7 +38,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({ automation.model, automation.reasoningEffort, automation.agentProfile, - automation.extraArgs + automation.agentFlags ) return { repoSelector: repo.id, diff --git a/src/main/persistence/scheduling-automations/automation-launch-field-updates.ts b/src/main/persistence/scheduling-automations/automation-launch-field-updates.ts index b2a66eb111e6..7659ed08ac1c 100644 --- a/src/main/persistence/scheduling-automations/automation-launch-field-updates.ts +++ b/src/main/persistence/scheduling-automations/automation-launch-field-updates.ts @@ -3,7 +3,7 @@ import { isAutomationReasoningEffortSupported, normalizeAutomationModel, normalizeAutomationReasoningEffort, - validateAutomationExtraArgs + validateAutomationAgentFlags } from '../../../shared/automation-model' import type { Automation, @@ -12,22 +12,22 @@ import type { } from '../../../shared/automations-types' type LaunchPatch = Partial< - Pick + Pick > export function launchFieldsForAutomationCreate( input: Pick< AutomationCreateInput, - 'agentId' | 'model' | 'reasoningEffort' | 'agentProfile' | 'extraArgs' + 'agentId' | 'model' | 'reasoningEffort' | 'agentProfile' | 'agentFlags' > ): LaunchPatch { const model = normalizeAutomationModel(input.model) const reasoningEffort = normalizeAutomationReasoningEffort(input.reasoningEffort) const agentProfile = input.agentProfile === 'minimal' ? 'minimal' : undefined - const extraArgs = - input.extraArgs == null || input.extraArgs === '' + const agentFlags = + input.agentFlags == null || input.agentFlags === '' ? undefined - : validateAutomationExtraArgs(input.extraArgs) + : validateAutomationAgentFlags(input.agentFlags) if (model && !getAgentSessionOptionCatalog(input.agentId)?.modelApply.launchArgs) { throw new Error('The selected agent does not support automation model overrides.') } @@ -45,7 +45,7 @@ export function launchFieldsForAutomationCreate( ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(agentProfile ? { agentProfile } : {}), - ...(extraArgs ? { extraArgs } : {}) + ...(agentFlags ? { agentFlags } : {}) } } @@ -73,12 +73,15 @@ export function launchFieldUpdatesForAutomation( ) ? { agentProfile: definedUpdates.agentProfile === 'minimal' ? 'minimal' : null } : {} - const extraArgsUpdate: Pick = Object.hasOwn(definedUpdates, 'extraArgs') + const agentFlagsUpdate: Pick = Object.hasOwn( + definedUpdates, + 'agentFlags' + ) ? { - extraArgs: - definedUpdates.extraArgs == null || definedUpdates.extraArgs === '' + agentFlags: + definedUpdates.agentFlags == null || definedUpdates.agentFlags === '' ? null - : (validateAutomationExtraArgs(definedUpdates.extraArgs) ?? null) + : (validateAutomationAgentFlags(definedUpdates.agentFlags) ?? null) } : {} const effectiveModel = Object.hasOwn(definedUpdates, 'model') @@ -104,6 +107,6 @@ export function launchFieldUpdatesForAutomation( ...modelUpdate, ...reasoningEffortUpdate, ...agentProfileUpdate, - ...extraArgsUpdate + ...agentFlagsUpdate } } diff --git a/src/main/runtime/runtime-automation-update-value.ts b/src/main/runtime/runtime-automation-update-value.ts index d651a690c499..e048cf513633 100644 --- a/src/main/runtime/runtime-automation-update-value.ts +++ b/src/main/runtime/runtime-automation-update-value.ts @@ -16,7 +16,8 @@ const RUNTIME_AUTOMATION_PATCH_KEYS = [ 'model', 'reasoningEffort', 'agentProfile', - 'extraArgs', + 'agentFlags', + 'extraAgentArgs', 'runContext', 'sourceContext', 'baseBranch', diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index af2ada997b18..8c3a1650cc27 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -79,3 +79,12 @@ export function draftExtraAgentArgsNeedFreshSession( draft.reuseSession ) } + +/** Fork: agent flags follow the same fresh-session rule as upstream's extras. */ +export function draftAgentFlagsNeedFreshSession( + draft: Pick +): boolean { + return ( + Boolean(draft.agentFlags.trim()) && draft.workspaceMode === 'existing' && draft.reuseSession + ) +} diff --git a/src/renderer/src/components/automations/automation-save-action.ts b/src/renderer/src/components/automations/automation-save-action.ts index 6c5c961ab65a..a538ec875698 100644 --- a/src/renderer/src/components/automations/automation-save-action.ts +++ b/src/renderer/src/components/automations/automation-save-action.ts @@ -7,6 +7,7 @@ import { import { translate } from '@/i18n/i18n' import { acceptsAutomationDraftSchedule } from './automation-schedule-input-gate' import { + draftAgentFlagsNeedFreshSession, draftExtraAgentArgsNeedFreshSession, getDraftExtraAgentArgsError, parseDraftTime @@ -96,6 +97,15 @@ export function createAutomationSaveAction(context: AutomationSaveContext) { toast.error(extrasError) return } + if (draftAgentFlagsNeedFreshSession(draft)) { + toast.error( + translate( + 'auto.components.automations.AutomationEditorSettingsSidebar.agentFlagsNeedFreshSession', + 'Agent flags require a fresh session for every run. Turn off session reuse or clear the agent flags.' + ) + ) + return + } } setIsSaving(true) try { diff --git a/src/shared/automation-model-launch.test.ts b/src/shared/automation-model-launch.test.ts index 3c31f96b6f5f..9b95b88c0856 100644 --- a/src/shared/automation-model-launch.test.ts +++ b/src/shared/automation-model-launch.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest' import { buildAutomationModelLaunchPreferences, - normalizeAutomationExtraArgs + normalizeAutomationAgentFlags } from './automation-model' import { buildAgentStartupPlan } from './tui-agent-startup' import type { TuiAgent } from './tui-agent' @@ -17,14 +17,14 @@ function launchCommandFor( model: string | undefined, effort?: string, agentProfile?: string, - extraArgs?: string + agentFlags?: string ): string | undefined { const sessionOptions = buildAutomationModelLaunchPreferences( agent, model, effort, agentProfile, - extraArgs + agentFlags ) return buildAgentStartupPlan({ agent, @@ -46,10 +46,10 @@ describe('automation model launch command', () => { }) it('rejects protected model flags in extra args', () => { - expect(normalizeAutomationExtraArgs('--model sneak')).toBeUndefined() - expect(normalizeAutomationExtraArgs('-c model_reasoning_effort=max')).toBeUndefined() - expect(normalizeAutomationExtraArgs('--config model_reasoning_effort=max')).toBeUndefined() - expect(normalizeAutomationExtraArgs('--verbose')).toBe('--verbose') + expect(normalizeAutomationAgentFlags('--model sneak')).toBeUndefined() + expect(normalizeAutomationAgentFlags('-c model_reasoning_effort=max')).toBeUndefined() + expect(normalizeAutomationAgentFlags('--config model_reasoning_effort=max')).toBeUndefined() + expect(normalizeAutomationAgentFlags('--verbose')).toBe('--verbose') }) it('emits -m for a grok run with a model', () => { expect(launchCommandFor('grok', 'deepseek-v4-flash')).toBe( diff --git a/src/shared/automation-model.ts b/src/shared/automation-model.ts index d5e43aab8aaf..fc84c24f2ed5 100644 --- a/src/shared/automation-model.ts +++ b/src/shared/automation-model.ts @@ -21,14 +21,15 @@ export type AutomationLaunchFields = { model?: string | null reasoningEffort?: AutomationReasoningEffort | null agentProfile?: 'minimal' | null - extraArgs?: string | null + /** Fork: free-form provider flags (model/effort flags rejected); upstream's extraAgentArgs is separate. */ + agentFlags?: string | null } export type AutomationStoredSession = { providerSessionId?: string | null } -const FORBIDDEN_AUTOMATION_EXTRA_ARG_FLAGS = new Set([ +const FORBIDDEN_AUTOMATION_AGENT_FLAGS = new Set([ '-m', '--model', '--agent', @@ -37,7 +38,7 @@ const FORBIDDEN_AUTOMATION_EXTRA_ARG_FLAGS = new Set([ '--permission-mode' ]) -export function normalizeAutomationExtraArgs(value: unknown): string | undefined { +export function normalizeAutomationAgentFlags(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined } @@ -53,8 +54,8 @@ export function normalizeAutomationExtraArgs(value: unknown): string | undefined const token = parsed.tokens[index] const next = parsed.tokens[index + 1] if ( - FORBIDDEN_AUTOMATION_EXTRA_ARG_FLAGS.has(token) || - [...FORBIDDEN_AUTOMATION_EXTRA_ARG_FLAGS].some((flag) => token.startsWith(`${flag}=`)) || + FORBIDDEN_AUTOMATION_AGENT_FLAGS.has(token) || + [...FORBIDDEN_AUTOMATION_AGENT_FLAGS].some((flag) => token.startsWith(`${flag}=`)) || ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) || token.startsWith('-cmodel_reasoning_effort=') || token.startsWith('-c=model_reasoning_effort=') || @@ -67,18 +68,18 @@ export function normalizeAutomationExtraArgs(value: unknown): string | undefined return trimmed } -export function validateAutomationExtraArgs(value: unknown): string | undefined { +export function validateAutomationAgentFlags(value: unknown): string | undefined { if (typeof value !== 'string' || !value.trim()) { return undefined } const trimmed = value.trim() const parsed = tokenizeStartupCommand(trimmed, 'posix') if (!parsed.ok) { - throw new Error(`Invalid extra args: ${parsed.error}`) + throw new Error(`Invalid agent flags: ${parsed.error}`) } - if (normalizeAutomationExtraArgs(trimmed) === undefined) { + if (normalizeAutomationAgentFlags(trimmed) === undefined) { throw new Error( - 'Extra args may not override model, agent, effort, reasoning effort, or permission mode.' + 'Agent flags may not override model, agent, effort, reasoning effort, or permission mode.' ) } return trimmed @@ -141,10 +142,10 @@ export function buildAutomationModelLaunchPreferences( model: unknown, effort?: unknown, agentProfile?: unknown, - extraArgs?: unknown + agentFlags?: unknown ): AgentLaunchPreferences | undefined { const modelId = normalizeAutomationModel(model) - const normalizedExtraArgs = normalizeAutomationExtraArgs(extraArgs) + const normalizedExtraArgs = normalizeAutomationAgentFlags(agentFlags) if (!modelId || !getAgentSessionOptionCatalog(agent)?.modelApply.launchArgs) { return normalizedExtraArgs ? { extraArgs: normalizedExtraArgs } : undefined } @@ -159,3 +160,16 @@ export function buildAutomationModelLaunchPreferences( ...(normalizedExtraArgs ? { extraArgs: normalizedExtraArgs } : {}) } } + +export const AGENT_FLAGS_REQUIRE_FRESH_SESSION = + 'Agent flags require a fresh session for every run. Turn off session reuse or clear the agent flags.' + +/** Fork: same rule as upstream's extras — a reused session never sees new launch flags. */ +export function assertAutomationAgentFlags(automation: { + agentFlags?: string | null + reuseSession?: boolean +}): void { + if (automation.reuseSession && automation.agentFlags?.trim()) { + throw new Error(AGENT_FLAGS_REQUIRE_FRESH_SESSION) + } +} From 367decf3b3c41ebe047ac49e873c989a0d3ee661 Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:31 +0800 Subject: [PATCH 48/54] docs(sync): record agentFlags follow-up and light local checks --- SYNC-NOTES-2026-10-11.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/SYNC-NOTES-2026-10-11.md b/SYNC-NOTES-2026-10-11.md index bc0aabba14cc..33efae1098fe 100644 --- a/SYNC-NOTES-2026-10-11.md +++ b/SYNC-NOTES-2026-10-11.md @@ -105,7 +105,21 @@ in `protocol-version.ts`. Fork `automation.agent-flags.v1` lives in - `AutomationEditorSettingsSidebar.test.tsx`: restore fork `model` / `agentFlags` fixture fields. -## Still local-only - -Light checks (typecheck + affected unit tests via `heavy-run`) run after -this merge commit. Push is blocked until PR #15 is `MERGED`. +## Follow-up after the merge commit + +Git auto-merged fork-only `automation-model.ts` and launch/CLI helpers from +`origin/fork-main`, which still used the pre-rename `extraArgs` names. Restored +the PR #15 `agentFlags` versions in `d284965952` so they match the +conflict-resolved `automation-params` types. + +## Light local checks (this box, via `heavy-run`) + +- `tsc --noEmit -p config/tsconfig.tc.cli.json`: clean. +- vitest `--maxWorkers=1` on 12 related files (agentFlags, run-open-target, + editor sidebar, relay-auth-coordinator, relay install retry, desktop + installer, session broker, schedule picker, agent-exec-disposal): 104 + passed. +- Full `pnpm tc` (node+web, 7+6 GiB heaps) and mobile tests not run locally. + Native `node-pty` rebuild skipped (no `c++` on this image); install used + `--ignore-scripts`. +- Push is blocked until PR #15 is `MERGED`. From ed9b1bb77cf1f2eeab911cae510a83dd2030c473 Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:58:58 +0800 Subject: [PATCH 49/54] docs: move 2026-10-11 sync notes out of the repository root The PR root-directory guard rejects new top-level files. Keep the notes under docs/audits/ so the landing-page check can pass. --- SYNC-NOTES-2026-10-11.md => docs/audits/sync-notes-2026-10-11.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename SYNC-NOTES-2026-10-11.md => docs/audits/sync-notes-2026-10-11.md (100%) diff --git a/SYNC-NOTES-2026-10-11.md b/docs/audits/sync-notes-2026-10-11.md similarity index 100% rename from SYNC-NOTES-2026-10-11.md rename to docs/audits/sync-notes-2026-10-11.md From 1ecca91bef64f578f36adfbd0c268e47802a622e Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:58:58 +0800 Subject: [PATCH 50/54] fix(mobile): load other-runtime refusal from a leaf module The 2026-10-11 sync imported protocol-version into the mobile agent loader so the page could read preflight.other-runtime-refusal.v1. That hub is its own importer set: the packaged web bundle grew from 78 scripts and 129 assets (PR 15, already at the Phase C envelope) to 79 and 130, one over the derived ceiling for 16 routes and 50 images. Import the two names from a leaf module, the same split agent.launch used. protocol-version still re-exports them and lists the capability. Leave the sweep as measured: the extra asset was protocol-version entering the loader's import graph. --- .../src/session/mobile-new-tab-agent-loader.test.ts | 2 +- mobile/src/session/mobile-new-tab-agent-loader.ts | 2 +- .../preflight-other-runtime-refusal-capability.ts | 11 +++++++++++ src/shared/protocol-version.ts | 11 +++++------ 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 src/shared/preflight-other-runtime-refusal-capability.ts diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index 2adf94c8b9b9..062e2584b5d0 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' -import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/protocol-version' +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/preflight-other-runtime-refusal-capability' import { loadMobileNewTabAgentOptions, MobileWorkspaceOnOtherRuntimeError diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 6b2bef916d6a..a5d16e782401 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -1,7 +1,7 @@ import { PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_ON_OTHER_RUNTIME -} from '../../../src/shared/protocol-version' +} from '../../../src/shared/preflight-other-runtime-refusal-capability' import { newTabSettingsRead } from '../transport/settings-read-operations' import { type MobileRuntimeRepoSummary, diff --git a/src/shared/preflight-other-runtime-refusal-capability.ts b/src/shared/preflight-other-runtime-refusal-capability.ts new file mode 100644 index 000000000000..763b69bf94bb --- /dev/null +++ b/src/shared/preflight-other-runtime-refusal-capability.ts @@ -0,0 +1,11 @@ +// Split out of protocol-version.ts, which lists the capability in RUNTIME_CAPABILITIES. +// Import these names from here: pulling protocol-version into the mobile agent loader +// splits a web-bundle chunk. + +// Why: an older host answers its own agents for a workspace another runtime owns; one with this +// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. +export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = + 'preflight.other-runtime-refusal.v1' as const + +/** What such a host answers, instead of probing itself, for another runtime's workspace. */ +export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 89891b35b9c3..e907d8c05bf5 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -44,6 +44,11 @@ export { import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' +import { PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY } from './preflight-other-runtime-refusal-capability' +export { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_ON_OTHER_RUNTIME +} from './preflight-other-runtime-refusal-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, @@ -323,12 +328,6 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = // must not read that answer as the workspace's (a WSL project on a Windows host differs). export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = 'preflight.workspace-scoped.v1' as const -// Why: an older host answers its own agents for a workspace another runtime owns; one with this -// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. -export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = - 'preflight.other-runtime-refusal.v1' as const -/** What such a host answers, instead of probing itself, for another runtime's workspace. */ -export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' // Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act // only on the endpoint itself, never on a workspace's SSH host. export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY = From 9be625981157ef1480b0f9ca7529e200a066927a Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:03:46 +0800 Subject: [PATCH 51/54] test(mobile): re-measure the web app script sweep at 79 chunks The 2026-10-11 sync left the packaged page at 79 scripts and 130 assets for 16 routes and 50 images. PR 15 sat on the envelope (78 / 129). The extra importer set is past the margin of 4 on the last 74-script sweep, so the last row is re-measured here. Set the swept tree's last row to the 79 this head emits. Derived ceilings move with it: 16-route chunk envelope 83, shell crossing with 42 images stays at 30 routes (256 at 29, 266 at 30). MOBILE_WEB_BUNDLE_MAX_ASSETS stays 256. --- config/scripts/build-mobile-web-app-bundle.test.mjs | 4 ++-- config/scripts/verify-mobile-web-app-bundle.mjs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index d901024bf8bc..8acbeb5760e1 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -618,9 +618,9 @@ describe('the Phase C budget', () => { // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251) + expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(256) expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /261 .*256/ + /266 .*256/ ) }) }) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index 48917d294ce2..9f7b74ec0b06 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -38,7 +38,7 @@ export function mobileWebAppBundleMaxAssets(routeCount, imageCount) { * refused asset on a phone. Splitting barely moves it — the same code is emitted in more files — * so shrinking this still means cutting code. * - * This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and + * This head reads 8,089,292 bytes of the 9,437,184 here, 85.7%, leaving 1,347,892. A reading and * not a pin: nothing asserts it, because the number moves with every build. It is here so the * generation that spends the rest can see it was already this close. */ @@ -52,7 +52,7 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 * `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than * fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten. * The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the - * swept tree reads 74 scripts at 16 routes. + * swept tree reads 79 scripts at 16 routes. * * This table is the fence's only input, so a route added to the tree stales it and the pins beside * the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped. @@ -73,7 +73,7 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./h/[hostId]/source-control/[worktreeId].tsx', 63], ['./h/[hostId]/tasks.tsx', 71], ['./h/[hostId]/web.tsx', 72], - ['./h/_layout.tsx', 74] + ['./h/_layout.tsx', 79] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) @@ -134,7 +134,7 @@ export function mobileWebAppBundleMaxChunks(routeCount) { /** * What the browser must parse before the first route can paint: the entry plus every chunk it * reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one - * chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route + * chunk and measures 1,362,150 bytes split on this head, 1.30 of the 3 MiB — so a route * re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a * slow first open on a phone. * From 25bd447921b7ddd4a930b8c4a2aaaecb297336d2 Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:11:04 +0800 Subject: [PATCH 52/54] fix: split orcad host adapters and keep protocol-version under max-lines Static analysis failed at 301 code lines on two files (cap 300, comments skipped). protocol-version grew when the other-runtime refusal names moved to a leaf; keep only the capability import for RUNTIME_CAPABILITIES. orcad-entry grew by the sync's runtime-source stamp plus session-terminal owner wiring; move the Node app-environment and secret-store adapters to orcad-host-adapters.ts. --- src/main/orcad/orcad-entry.ts | 74 ++---------------- src/main/orcad/orcad-host-adapters.ts | 75 +++++++++++++++++++ .../workspace-agent-detection.test.ts | 4 +- .../preflight/workspace-agent-detection.ts | 2 +- src/shared/protocol-version.ts | 4 - 5 files changed, 83 insertions(+), 76 deletions(-) create mode 100644 src/main/orcad/orcad-host-adapters.ts diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 4c406ac550a8..67f4cebe7ace 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -11,15 +11,11 @@ * Chromium proves available at startup. */ import process from 'node:process' -import { - getAppEnvironment, - setAppEnvironment, - type AppEnvironment -} from '../../shared/app-environment' -import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import { getAppEnvironment } from '../../shared/app-environment' import type { ServeReadiness } from '../server/serve-readiness' -import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' -import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +import { prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-adapters' import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address' import { flushOrcadProfileStoreForShutdown, @@ -40,70 +36,10 @@ import { import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' export { parseArgs } +export { installOrcadHostAdapters } -let runOrcadQuitHandlers = (): void => {} let closeOrcadObservability = (): void => {} -function createNodeAppEnvironment(): AppEnvironment { - const quitHandlers: (() => void)[] = [] - // The main signal handler awaits runtime and browser teardown before process.exit. - // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. - runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] - for (const handler of quitHandlers.splice(0)) { - try { - handler() - } catch (error) { - errors.push(error) - } - } - // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. - if (errors.length > 0) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } - } - return { - getPath: resolveOrcadPath, - getAppPath: () => resolveOrcadInstallRoot(), - getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', - // Why still true: consumers read this as "production build, not a dev checkout" — - // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH - // hydration. Answering false to satisfy a path resolver would relax a security - // posture. Layout questions must ask whether the app root is an asar archive - // instead (see parcel-watcher-entry-path.ts). - isPackaged: () => true, - getCliLauncherPath: getOrcadCliLauncherPath, - onWillQuit: (handler) => quitHandlers.push(handler), - exit: (code = 0) => process.exit(code), - // Why []: there are no Chromium processes on this host to measure. - getAppMetrics: () => [] - } -} - -/** - * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every - * caller fall back to unsealed storage, which is a security posture, not a detail. - * `describeProtectionGap()` gives the reason a client can surface. - */ -function createNodeSecretStore(): SecretStore { - return { - isEncryptionAvailable: () => false, - encryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - decryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - describeProtectionGap: () => - 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' - } -} - -export function installOrcadHostAdapters(): void { - setAppEnvironment(createNodeAppEnvironment()) - setSecretStore(createNodeSecretStore()) -} - export type OrcadOptions = { port?: number json?: boolean diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts new file mode 100644 index 000000000000..fb2f69162367 --- /dev/null +++ b/src/main/orcad/orcad-host-adapters.ts @@ -0,0 +1,75 @@ +import process from 'node:process' +import { + setAppEnvironment, + type AppEnvironment +} from '../../shared/app-environment' +import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths' +import { getOrcadCliLauncherPath } from './orcad-cli-launcher' + +let installedQuitHandlers = (): void => {} + +/** Run every `onWillQuit` handler installed by the Node app environment. */ +export function runOrcadQuitHandlers(): void { + installedQuitHandlers() +} + +function createNodeAppEnvironment(): AppEnvironment { + const quitHandlers: (() => void)[] = [] + // The main signal handler awaits runtime and browser teardown before process.exit. + // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. + installedQuitHandlers = (): void => { + const errors: unknown[] = [] + for (const handler of quitHandlers.splice(0)) { + try { + handler() + } catch (error) { + errors.push(error) + } + } + // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. + if (errors.length > 0) { + throw new AggregateError(errors, 'orcad_quit_handlers_failed') + } + } + return { + getPath: resolveOrcadPath, + getAppPath: () => resolveOrcadInstallRoot(), + getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + // Why still true: consumers read this as "production build, not a dev checkout" — + // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH + // hydration. Answering false to satisfy a path resolver would relax a security + // posture. Layout questions must ask whether the app root is an asar archive + // instead (see parcel-watcher-entry-path.ts). + isPackaged: () => true, + getCliLauncherPath: getOrcadCliLauncherPath, + onWillQuit: (handler) => quitHandlers.push(handler), + exit: (code = 0) => process.exit(code), + // Why []: there are no Chromium processes on this host to measure. + getAppMetrics: () => [] + } +} + +/** + * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every + * caller fall back to unsealed storage, which is a security posture, not a detail. + * `describeProtectionGap()` gives the reason a client can surface. + */ +function createNodeSecretStore(): SecretStore { + return { + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + decryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + describeProtectionGap: () => + 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + } +} + +export function installOrcadHostAdapters(): void { + setAppEnvironment(createNodeAppEnvironment()) + setSecretStore(createNodeSecretStore()) +} diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index 5ec78ffc05dd..4417a271d503 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -12,9 +12,9 @@ import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-sc import { splitWorktreeId } from '../../shared/worktree/id' import { PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, - RUNTIME_CAPABILITIES, WORKSPACE_ON_OTHER_RUNTIME -} from '../../shared/protocol-version' +} from '../../shared/preflight-other-runtime-refusal-capability' +import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' import { resolveWorkspaceAgentDetectionHost, type WorkspaceAgentDetectionStore diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index ca9eee7b257d..7132e05a047c 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -1,4 +1,4 @@ -import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version' +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/preflight-other-runtime-refusal-capability' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e907d8c05bf5..d2f815386730 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -45,10 +45,6 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' import { PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY } from './preflight-other-runtime-refusal-capability' -export { - PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, - WORKSPACE_ON_OTHER_RUNTIME -} from './preflight-other-runtime-refusal-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, From a1b80d551dc283a137d572359d0b9435fbb35ccc Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:27:34 +0800 Subject: [PATCH 53/54] test(e2e): drive the Linux in-app folder picker in folder Git upgrade The sync added folder-repo-git-upgrade, which stubs GTK showOpenDialog and waits for Open as Folder. This fork browses Add project folders in-app on Linux, so that stub never runs and the confirmation never appears. Share the golden-flow in-app browser helper and use it after Browse folder so the upgrade journey can select the temp directory on Linux CI. --- tests/e2e/folder-repo-git-upgrade.spec.ts | 2 ++ tests/e2e/golden-core-flows.spec.ts | 30 +------------------- tests/e2e/helpers/in-app-folder-browser.ts | 33 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 29 deletions(-) create mode 100644 tests/e2e/helpers/in-app-folder-browser.ts diff --git a/tests/e2e/folder-repo-git-upgrade.spec.ts b/tests/e2e/folder-repo-git-upgrade.spec.ts index 2d43e27c180b..aafe658ed790 100644 --- a/tests/e2e/folder-repo-git-upgrade.spec.ts +++ b/tests/e2e/folder-repo-git-upgrade.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' +import { chooseFolderInAppBrowserIfShown } from './helpers/in-app-folder-browser' import { runProcess } from '@orca/process-host' test.use({ seedTestRepo: false }) @@ -29,6 +30,7 @@ test('Add Project opens a folder and preserves its workspace when Git appears', }, folderPath) await openSidebarProjectDialog(orcaPage) await orcaPage.getByRole('button', { name: /Browse folder/i }).click() + await chooseFolderInAppBrowserIfShown(orcaPage, folderPath) const dialog = orcaPage.getByRole('dialog', { name: 'Open as Folder' }) await expect(dialog).toBeVisible() await orcaPage.screenshot({ path: testInfo.outputPath('open-folder.png') }) diff --git a/tests/e2e/golden-core-flows.spec.ts b/tests/e2e/golden-core-flows.spec.ts index 669e3afa49aa..84a7f0490b43 100644 --- a/tests/e2e/golden-core-flows.spec.ts +++ b/tests/e2e/golden-core-flows.spec.ts @@ -2,6 +2,7 @@ import { openSidebarProjectDialog, openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' +import { chooseFolderInAppBrowserIfShown } from './helpers/in-app-folder-browser' import { execFileSync } from 'node:child_process' import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' @@ -209,35 +210,6 @@ async function expectProjectVisible(page: Page, repoPath: string): Promise await expect(page.getByText(repoName, { exact: true }).first()).toBeVisible({ timeout: 15_000 }) } -// Why: this fork browses Add project folders in-app on Linux instead of GTK showOpenDialog, -// so the native-dialog stub never runs there. Enter the path and select it in that browser. -async function chooseFolderInAppBrowserIfShown(page: Page, folderPath: string): Promise { - const browser = page.getByRole('dialog', { name: /Browse host filesystem/i }) - const shown = await browser - .waitFor({ state: 'visible', timeout: 3_000 }) - .then(() => true) - .catch(() => false) - if (!shown) { - return - } - const pathInput = browser.getByPlaceholder(/Type to filter or enter a path/i) - const target = folderPath.endsWith('/') ? folderPath : `${folderPath}/` - await expect - .poll( - async () => { - if ((await browser.textContent())?.includes(`· ${folderPath}`)) { - return true - } - await pathInput.fill(target) - await pathInput.press('Enter') - return false - }, - { timeout: 15_000, message: `in-app folder browser did not open ${folderPath}` } - ) - .toBe(true) - await browser.getByRole('button', { name: 'Select folder', exact: true }).click() -} - async function addProjectFromSidebar( page: Page, electronApp: ElectronApplication, diff --git a/tests/e2e/helpers/in-app-folder-browser.ts b/tests/e2e/helpers/in-app-folder-browser.ts new file mode 100644 index 000000000000..ef9f697fe561 --- /dev/null +++ b/tests/e2e/helpers/in-app-folder-browser.ts @@ -0,0 +1,33 @@ +import { expect, type Page } from '@stablyai/playwright-test' + +// Why: this fork browses Add project folders in-app on Linux instead of GTK showOpenDialog, +// so a native-dialog stub never runs there. Enter the path and select it in that browser. +export async function chooseFolderInAppBrowserIfShown( + page: Page, + folderPath: string +): Promise { + const browser = page.getByRole('dialog', { name: /Browse host filesystem/i }) + const shown = await browser + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true) + .catch(() => false) + if (!shown) { + return + } + const pathInput = browser.getByPlaceholder(/Type to filter or enter a path/i) + const target = folderPath.endsWith('/') ? folderPath : `${folderPath}/` + await expect + .poll( + async () => { + if ((await browser.textContent())?.includes(`· ${folderPath}`)) { + return true + } + await pathInput.fill(target) + await pathInput.press('Enter') + return false + }, + { timeout: 15_000, message: `in-app folder browser did not open ${folderPath}` } + ) + .toBe(true) + await browser.getByRole('button', { name: 'Select folder', exact: true }).click() +} From d9bfeb8c732a81fff2b930183688f06d5e636c45 Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:57:25 +0800 Subject: [PATCH 54/54] fix: classify fork RPCs, DSB icon, and automation store mocks Unit shards failed after the guard started running tests. - pet.speak methods are in the fork RPC catalog; classify the pet namespace so host-operation-ownership covers every method. - AgentIcon now uses the bundled DeepSeek mark for observed dsb sessions, matching agent-catalog. - Automation run-open tests mock tabsByWorktree and getTab, which the synced click path reads. --- ...automation-run-workspace-action.paired-server.test.ts | 1 + .../automations/automation-run-workspace-action.test.ts | 9 +++++++++ src/renderer/src/lib/agent-catalog-icon.tsx | 5 +++-- src/shared/host-operation-ownership.ts | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts index 8f3770ff365a..25777a936a79 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts @@ -86,6 +86,7 @@ function storeState(mirrored: boolean): Record { } : {}, ptyIdsByTabId: mirrored ? { [MIRRORED_TAB]: [MIRRORED_PTY] } : {}, + tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, activeGroupIdByWorktree: {}, diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.test.ts b/src/renderer/src/components/automations/automation-run-workspace-action.test.ts index 3ab07e405899..2a9af5a0edf7 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-action.test.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-action.test.ts @@ -65,6 +65,15 @@ vi.mock('@/store', () => ({ terminalLayoutsByTabId: mocks.terminalLayoutsByTabId, ptyIdsByTabId: mocks.ptyIdsByTabId, tabsByWorktree: mocks.tabsByWorktree, + getTab: (tabId: string) => { + for (const tabs of Object.values(mocks.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (tab) { + return tab + } + } + return null + }, sleepingAgentSessionsByPaneKey: mocks.sleepingAgentSessionsByPaneKey }), setState: (...args: unknown[]) => mocks.setState(...args) diff --git a/src/renderer/src/lib/agent-catalog-icon.tsx b/src/renderer/src/lib/agent-catalog-icon.tsx index cc18491736ee..e653e9920a54 100644 --- a/src/renderer/src/lib/agent-catalog-icon.tsx +++ b/src/renderer/src/lib/agent-catalog-icon.tsx @@ -54,8 +54,9 @@ export function AgentIcon({ return } const catalogEntry = getAgentCatalog().find((entry) => entry.id === agent) - const iconSrc = - catalogEntry?.iconUrl ?? (agent === 'dsb' ? undefined : AGENT_FAVICON_ASSETS[agent]) + // Why: recognition-only DSB shares the bundled DeepSeek mark. + const iconAgent = agent === 'dsb' ? 'dsh' : agent + const iconSrc = catalogEntry?.iconUrl ?? AGENT_FAVICON_ASSETS[iconAgent] if (iconSrc) { return (