From 700b492b1bcf67b11631f952fa084c0a206edda3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:30:55 -0700 Subject: [PATCH 001/141] Fix main typecheck: pin Buffer in cdp-ws-proxy mid-print test (#7263) Co-authored-by: Orca --- src/main/browser/cdp-ws-proxy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 15976e9c729..c5fa3361d96 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -647,10 +647,10 @@ describe('CdpWsProxy', () => { }) it('does not register a PDF stream when the client disconnects mid-print', async () => { - let resolvePrint: (buf: Buffer) => void = () => {} + let resolvePrint: (buf: Buffer) => void = () => {} mock.webContents.printToPDF.mockImplementationOnce( () => - new Promise((resolve) => { + new Promise>((resolve) => { resolvePrint = resolve }) ) From 2c0be62df66d2b9380dd3609e87b171f39e24771 Mon Sep 17 00:00:00 2001 From: mehmet turac Date: Fri, 3 Jul 2026 23:38:20 +0300 Subject: [PATCH 002/141] Fix empty nested import folder fallback (#6719) * fix: allow empty nested import as folder * Track open-as-folder recovery action and localize its strings Completes the empty-nested-import fallback: adds the open_as_folder action to the existing add_repo_nested_import_action funnel (integer counts only, no paths) so adoption of the recovery path is measurable, and lands the five-locale catalog entries the new UI strings require. Co-authored-by: Orca * Pin runtime-kind mock return to the literal union in nested import flow test Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../src/components/sidebar/AddRepoDialog.tsx | 37 +++-- .../sidebar/AddRepoDialogStepContent.test.tsx | 10 ++ .../sidebar/AddRepoDialogStepContent.tsx | 3 + .../sidebar/AddRepoNestedImportStep.test.tsx | 43 +++++ .../sidebar/AddRepoNestedImportStep.tsx | 37 ++++- .../useAddRepoNestedImportFlow.test.ts | 155 ++++++++++++++++++ .../sidebar/useAddRepoNestedImportFlow.ts | 67 +++++++- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.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/shared/nested-repo-telemetry.ts | 7 +- 13 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 9fe63453d67..1c5f27c629b 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -193,22 +193,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onGitRepoReady: completeGitRepoAdd, setAddProjectBusyLabel }) - const { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction } = - useAddRepoNestedImportFlow({ - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedRuntimeKind, - nestedConnectionId, - nestedGroupName, - nestedImportScanId, - activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, - fetchWorktrees, - importNestedRepos, - getNestedRepoRuntimeKind, - onGitRepoReady: completeGitRepoAdd, - setIsAdding - }) + const { + handleImportNestedRepos, + handleOpenNestedRootFolder, + resetNestedImportFlow, + trackNestedBackAction + } = useAddRepoNestedImportFlow({ + nestedAttemptId, + nestedScan, + nestedSelectedPaths, + nestedRuntimeKind, + nestedConnectionId, + nestedGroupName, + nestedImportScanId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + fetchWorktrees, + importNestedRepos, + getNestedRepoRuntimeKind, + onGitRepoReady: completeGitRepoAdd, + setIsAdding + }) const resetState = useCallback(() => { // Why: kill the git clone process if one is running, so backing out @@ -381,6 +385,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onNestedGroupNameChange={setNestedGroupName} onNestedSelectedPathsChange={setNestedSelectedPaths} onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} + onOpenNestedRootFolder={() => void handleOpenNestedRootFolder()} onCreateNameChange={(value) => { setCreateName(value) setCreateError(null) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 26ec195d440..b70c299be36 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -79,6 +79,7 @@ function renderStepContent(overrides: Partial): string { onNestedGroupNameChange: vi.fn(), onNestedSelectedPathsChange: vi.fn(), onImportNestedRepos: vi.fn(), + onOpenNestedRootFolder: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), onPickCreateParent: vi.fn(), @@ -120,6 +121,15 @@ describe('AddRepoDialogStepContent nested imports', () => { expect(html).not.toContain('>Import') }) + it('offers opening the parent folder when nested import selection is empty', () => { + const html = renderStepContent({ nestedSelectedPaths: new Set() }) + + expect(html).toContain('No repositories are selected') + expect(html).toContain('Open as Folder') + expect(html).toMatch(/]*disabled=""[^>]*>No, import separately<\/button>/) + expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as group<\/button>/) + }) + it('offers host browsing for remote create project locations', () => { const html = renderStepContent({ step: 'create', diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 655a21de509..ee05b70b433 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -72,6 +72,7 @@ type AddRepoDialogStepContentProps = { onNestedGroupNameChange: (name: string) => void onNestedSelectedPathsChange: Dispatch>> onImportNestedRepos: (mode: 'group' | 'separate') => void + onOpenNestedRootFolder: () => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void onPickCreateParent: () => void @@ -140,6 +141,7 @@ export function AddRepoDialogStepContent({ onNestedGroupNameChange, onNestedSelectedPathsChange, onImportNestedRepos, + onOpenNestedRootFolder, onCreateNameChange, onCreateParentChange, onPickCreateParent, @@ -237,6 +239,7 @@ export function AddRepoDialogStepContent({ onGroupNameChange={onNestedGroupNameChange} onSelectedPathsChange={onNestedSelectedPathsChange} onImport={onImportNestedRepos} + onOpenAsFolder={onOpenNestedRootFolder} onStopScan={onStopNestedScan} /> ) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index ba9022ca742..ff243a3a5cc 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -41,6 +41,7 @@ function renderStepMarkup( onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={vi.fn()} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} {...overrides} /> @@ -122,6 +123,7 @@ describe('AddRepoNestedImportStep', () => { onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={onImport} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -162,6 +164,7 @@ describe('AddRepoNestedImportStep', () => { onImport(mode) setIsAdding(true) }} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -181,4 +184,44 @@ describe('AddRepoNestedImportStep', () => { expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) + + it('offers opening the parent folder when no repositories are selected', () => { + const onOpenAsFolder = vi.fn() + const host = document.createElement('div') + container = host + document.body.appendChild(host) + root = createRoot(host) + + act(() => { + root?.render( + + + + + + ) + }) + + expect(host.textContent).toContain('No repositories are selected') + expect(findButton(host, 'No, import separately').disabled).toBe(true) + expect(findButton(host, 'Yes, import as group').disabled).toBe(true) + expect(findButton(host, 'Open as Folder').disabled).toBe(false) + + act(() => { + findButton(host, 'Open as Folder').click() + }) + + expect(onOpenAsFolder).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index ac7db3b2958..ef41edbf1a1 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -20,6 +20,7 @@ type AddRepoNestedImportStepProps = { onGroupNameChange: (value: string) => void onSelectedPathsChange: Dispatch>> onImport: (mode: 'group' | 'separate') => void + onOpenAsFolder: () => void onStopScan: () => void } @@ -32,11 +33,16 @@ export function AddRepoNestedImportStep({ onGroupNameChange, onSelectedPathsChange, onImport, + onOpenAsFolder, onStopScan }: AddRepoNestedImportStepProps): React.JSX.Element { const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath const groupNameInputId = useId() - const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null) + const [pendingImportMode, setPendingImportMode] = useState< + 'folder' | 'group' | 'separate' | null + >(null) + const noRepositoriesSelected = selectedPaths.size === 0 + const showOpenAsFolderSpinner = isAdding && pendingImportMode === 'folder' const showSeparateSpinner = isAdding && pendingImportMode === 'separate' const showGroupSpinner = isAdding && pendingImportMode === 'group' @@ -50,6 +56,10 @@ export function AddRepoNestedImportStep({ setPendingImportMode(mode) onImport(mode) } + const handleOpenAsFolder = (): void => { + setPendingImportMode('folder') + onOpenAsFolder() + } const repoCountLabel = scan.repos.length === 1 ? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository') @@ -137,10 +147,31 @@ export function AddRepoNestedImportStep({ placeholder={folderName} /> + {noRepositoriesSelected ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f', + 'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.' + )} +

+ ) : null}
+ {noRepositoriesSelected ? ( + + ) : null} + ) : null} + +
+ + ) : null} ) })} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts index 9715002a997..1153604715e 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts @@ -6,11 +6,16 @@ import type { RepoSourceControlAiOverrides, SourceControlAiSettings } from '../../../../shared/source-control-ai-types' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { createRepoAiDraftState, dropRepoLegacyInstructionForAction, resolveRepoAiDraftState } from './RepositorySourceControlAiSection' +import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows' import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' describe('RepositorySourceControlAiSection draft state', () => { @@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => { }) }) +describe('RepositorySourceControlAiActionRows', () => { + it('renders save controls on dirty repository action recipes', () => { + const actionDirtyById = Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure']) + ) as Record + const source = { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as SourceControlAiSettings + + const markup = renderToStaticMarkup( + React.createElement(RepositorySourceControlAiActionRows, { + repoId: 'repo-1', + repoAi: { + actionOverrides: { + fixCommitFailure: { + agentId: null, + commandInputTemplate: '{basePrompt}\n\nrepo' + } + } + }, + source, + defaultTuiAgent: 'codex', + isSaving: false, + actionDirtyById, + onActionModeChange: () => {}, + onActionAgentChange: () => {}, + onActionTemplateChange: () => {}, + onActionAgentArgsChange: () => {}, + onAppendVariable: () => {}, + onActionDiscard: () => {}, + onActionSave: () => {} + }) + ) + + expect(markup).toContain('Commit failure fixes') + expect(markup).toContain('Unsaved changes') + expect(markup).toContain('Discard') + expect(markup).toContain('Save') + expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure') + }) +}) + describe('dropRepoLegacyInstructionForAction', () => { it('prevents legacy text instructions from remigrating after an action override is cleared', () => { const next = dropRepoLegacyInstructionForAction( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 6f5f771379c..5568912edd2 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -1,16 +1,18 @@ import { useMemo, useState } from 'react' import type React from 'react' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { Repo, TuiAgent } from '../../../../shared/types' import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' import { normalizeRepoSourceControlAiOverrides, - normalizeSourceControlAiSettings, - resolveSourceControlActionRecipe + normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai' import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' -import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { Button } from '../ui/button' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults' import { + buildActionScopedRepoAiSave, createRepoAiDraftState, dropRepoLegacyInstructionForAction, hasOwnActionOverride, normalizeRepoAiDraft, + readCompleteRecipeForDraft, resolveRepoAiDraftState, + serializeActionOverride, serializeRepoAiDraft, + setActionOverride, type RepoAiDraftState } from './repository-source-control-ai-draft' import { ACTION_MODE_INHERIT, DEFAULT_AGENT_VALUE, - completeRepoActionRecipe, readInheritedCommandTemplate } from './repository-source-control-ai-labels' import { getSettingOwnershipSummary } from './setting-ownership' @@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = { type HostedReviewDefaultKey = keyof NonNullable -function readCompleteRecipeForDraft( - current: RepoSourceControlAiOverrides, - settings: GlobalSettings | null, - actionId: SourceControlActionId -): NonNullable< - NonNullable[SourceControlActionId] -> { - const recipe = resolveSourceControlActionRecipe({ - settings, - repo: { sourceControlAi: current }, - actionId - }) - return completeRepoActionRecipe(recipe, actionId) -} - -function setActionOverride( - current: RepoSourceControlAiOverrides, - actionId: SourceControlActionId, - recipe: NonNullable< - NonNullable[SourceControlActionId] - > -): RepoSourceControlAiOverrides { - return dropRepoLegacyInstructionForAction( - { - ...current, - actionOverrides: { - ...current.actionOverrides, - [actionId]: recipe - } - }, - actionId - ) -} - export function RepositorySourceControlAiSection({ repo, updateRepo @@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({ const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi]) const isDirty = resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized + const actionDirtyById = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + serializeActionOverride(repoAi, actionId) !== + serializeActionOverride(persistedRepoAi, actionId) + ]) + ) as Record, + [persistedRepoAi, repoAi] + ) const updateDraftRepoAi = ( update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides @@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({ setSaveError(null) } + const discardActionDraft = (actionId: SourceControlActionId): void => { + updateDraftRepoAi((current) => { + const nextActionOverrides = { ...current.actionOverrides } + if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) { + nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return dropRepoLegacyInstructionForAction( + { ...current, actionOverrides: nextActionOverrides }, + actionId + ) + }) + } + + const saveActionDraft = async (actionId: SourceControlActionId): Promise => { + if (isSaving || !actionDirtyById[actionId]) { + return + } + // Why: a per-action Save persists only that action's override (its Discard is + // action-scoped, so its Save must be too) — never other rows' pending edits. + const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId) + const repoUpdate = toSourceControlAiRepoUpdate(next) + setIsSaving(true) + setSaveError(null) + try { + const result = await updateRepo(repo.id, repoUpdate) + if (!mountedRef.current) { + return + } + if (result === false) { + setSaveError('Failed to save Source Control AI settings.') + } + } catch { + if (mountedRef.current) { + setSaveError('Failed to save Source Control AI settings.') + } + } finally { + if (mountedRef.current) { + setIsSaving(false) + } + } + } + const updateEnablement = (value: boolean | undefined): void => { updateDraftRepoAi((current) => ({ ...current, enabled: value })) } @@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({ onChange={updateCustomCommand} /> void saveActionDraft(actionId)} /> void onTemplateChange: (actionId: SourceControlActionId, value: string) => void onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void @@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({ baseValue, defaultTuiAgent, isSavingTemplate, + repoOverrideNote, onAgentChange, onTemplateChange, onAgentArgsChange, @@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({ /> -
-

- {templateDirty - ? translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', - 'Unsaved changes' - ) - : translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', - 'Saved' - )} -

-
- {templateDirty ? ( - - ) : null} - +

+
+ {templateDirty ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx new file mode 100644 index 00000000000..095cfc4b550 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' +import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderNote( + summary: SourceControlActionRecipeOverrideSummary, + onReviewRepo = vi.fn() +): void { + act(() => { + root.render( + + + + ) + }) +} + +describe('SourceControlActionRepoOverrideNote', () => { + it('renders nothing when no repo overrides the action', () => { + renderNote({ count: 0, overrides: [] }) + expect(container.textContent).toBe('') + }) + + it('shows the singular count when one repo overrides the action', () => { + renderNote({ + count: 1, + overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }] + }) + expect(container.textContent).toContain("Global saves won't change 1 repository") + expect(container.textContent).toContain('Review') + }) + + it('shows the plural count when several repos override the action', () => { + renderNote({ + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }) + expect(container.textContent).toContain("Global saves won't change 2 repositories") + expect(container.textContent).toContain('Review first') + }) + + it('shows a "+N more" note when overrides exceed the visible limit', async () => { + renderNote({ + count: 6, + overrides: Array.from({ length: 6 }, (_, index) => ({ + repoId: `r${index}`, + repoName: `Repo ${index}`, + fields: ['commandTemplate'] + })) + }) + + // The overflow note lives in the tooltip content, only mounted once Radix + // opens the tooltip — drive its pointer-move open path and flush the timer. + const trigger = container.querySelector('[data-slot="tooltip-trigger"]') + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(document.body.textContent).toContain('+1 more') + }) + + it('opens the first repo override from the review action', () => { + const onReviewRepo = vi.fn() + renderNote( + { + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }, + onReviewRepo + ) + + act(() => { + container.querySelector('button')?.click() + }) + + expect(onReviewRepo).toHaveBeenCalledWith('r1') + }) +}) diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx new file mode 100644 index 00000000000..ec389927e42 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -0,0 +1,135 @@ +import type React from 'react' +import { Info } from 'lucide-react' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import type { + SourceControlActionRecipeOverrideField, + SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' +import { translate } from '@/i18n/i18n' + +type SourceControlActionRepoOverrideNoteProps = { + summary: SourceControlActionRecipeOverrideSummary + onReviewRepo: (repoId: string) => void +} + +const MAX_VISIBLE_REPOS = 5 + +function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string { + switch (field) { + case 'agent': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agent', + 'Agent' + ) + case 'agentArgs': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs', + 'CLI arguments' + ) + case 'commandTemplate': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate', + 'Command template' + ) + default: { + // Fail at compile time if the override-field union grows a new variant. + const _exhaustive: never = field + return _exhaustive + } + } +} + +function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string { + if (fields.length === 0) { + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.recipe', + 'Recipe' + ) + } + return fields.map(getRecipeOverrideFieldLabel).join(', ') +} + +export function SourceControlActionRepoOverrideNote({ + summary, + onReviewRepo +}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null { + if (summary.count === 0) { + return null + } + const firstOverride = summary.overrides[0] + if (!firstOverride) { + return null + } + const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS) + const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length) + const noteText = + summary.count === 1 + ? translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.singular', + "Global saves won't change 1 repository with its own recipe." + ) + : translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.plural', + "Global saves won't change {{count}} repositories with their own recipes.", + { count: summary.count } + ) + return ( +
+ + + + + {noteText} + + + +
+

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle', + 'Repository overrides' + )} +

+
    + {visibleOverrides.map((override) => ( +
  • +
    {override.repoName}
    +
    + {getRecipeOverrideFieldSummary(override.fields)} +
    +
  • + ))} +
+ {hiddenOverrideCount > 0 ? ( +

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.more', + '+{{count}} more', + { count: hiddenOverrideCount } + )} +

+ ) : null} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index c54390e2a8d..56906be96d3 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react' import type React from 'react' import { toast } from 'sonner' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import { isFolderRepo } from '../../../../shared/repo-kind' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -13,9 +15,17 @@ import { type SourceControlActionId } from '../../../../shared/source-control-ai-actions' import { Label } from '../ui/label' +import { useAppStore } from '@/store' +import { useRepos } from '@/store/selectors' +import { + summarizeReposOverridingActionRecipe, + type SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' import { SearchableSetting } from './SearchableSetting' +import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets' import { matchesSettingsSearch } from './settings-search' import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state' import { translate } from '@/i18n/i18n' @@ -84,6 +94,22 @@ export function SourceControlAiActionRecipeDefaults({ searchQuery, writeConfig }: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null { + const allRepos = useRepos() + // Recomputes on every keystroke in the sibling recipe textareas otherwise, so + // memoize the filter and the O(actions × repos) override scan on `repos`. + const repos = useMemo(() => allRepos.filter((repo) => !isFolderRepo(repo)), [allRepos]) + const overrideSummaries = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + summarizeReposOverridingActionRecipe({ repos, actionId }) + ]) + ) as Record, + [repos] + ) + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const { actionRecipeDraftState, savingActionTemplateIds, @@ -134,6 +160,18 @@ export function SourceControlAiActionRecipeDefaults({ } } + const openRepoSourceControlAiSettings = ( + repoId: string, + actionId: SourceControlActionId + ): void => { + openSettingsTarget({ + pane: 'repo', + repoId, + sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId) + }) + openSettingsPage() + } + if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) { return null } @@ -158,6 +196,7 @@ export function SourceControlAiActionRecipeDefaults({ {SOURCE_CONTROL_ACTION_IDS.map((actionId) => { const recipe = config.actions?.[actionId] const selectedAgent = recipe?.agentId ?? null + const overrideSummary = overrideSummaries[actionId] return ( openRepoSourceControlAiSettings(repoId, actionId)} + /> + } onAgentChange={(id, value) => void onActionAgentChange(id, value)} onTemplateChange={onActionTemplateChange} onAgentArgsChange={onActionAgentArgsChange} diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts index 2ab4fd8d5d5..32bcd1f4510 100644 --- a/src/renderer/src/components/settings/repository-settings-targets.ts +++ b/src/renderer/src/components/settings/repository-settings-targets.ts @@ -1,3 +1,5 @@ +import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' + export function getRepositoryLocalCommandsSectionId(repoId: string): string { return `repo-${repoId}-local-commands` } @@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string { export function getRepositorySourceControlAiSectionId(repoId: string): string { return `repo-${repoId}-source-control-ai` } + +export function getRepositorySourceControlAiActionRecipeSectionId( + repoId: string, + actionId: SourceControlActionId +): string { + return `repo-${repoId}-source-control-ai-${actionId}` +} diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts new file mode 100644 index 00000000000..1972afde88b --- /dev/null +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft' + +describe('buildActionScopedRepoAiSave', () => { + it('persists only the target action, keeping siblings at their saved values', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'claude', + commandInputTemplate: '{basePrompt}', + agentArgs: '' + }, + fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'codex', + commandInputTemplate: '{basePrompt}\n\nedited', + agentArgs: '' + }, + // A half-finished edit on a different row that must NOT be flushed. + fixCommitFailure: { + agentId: 'gemini', + commandInputTemplate: '{basePrompt}\n\nunsaved', + agentArgs: '' + } + } + } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex') + expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe( + '{basePrompt}\n\nedited' + ) + // Sibling keeps the persisted value, not the draft's pending edit. + expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex') + expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}') + }) + + it('removes the action override when the draft inherits it', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { actionOverrides: {} } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 8eb2875a477..a085821f23f 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -1,8 +1,17 @@ -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../../shared/source-control-ai' import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import type { GlobalSettings } from '../../../../shared/types' +import { completeRepoActionRecipe } from './repository-source-control-ai-labels' import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options' +type RepoActionRecipe = NonNullable< + NonNullable[SourceControlActionId] +> + export type RepoAiDraftState = { repoId: string value: RepoSourceControlAiOverrides @@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction( Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined } } + +export function readCompleteRecipeForDraft( + current: RepoSourceControlAiOverrides, + settings: GlobalSettings | null, + actionId: SourceControlActionId +): RepoActionRecipe { + const recipe = resolveSourceControlActionRecipe({ + settings, + repo: { sourceControlAi: current }, + actionId + }) + return completeRepoActionRecipe(recipe, actionId) +} + +export function setActionOverride( + current: RepoSourceControlAiOverrides, + actionId: SourceControlActionId, + recipe: RepoActionRecipe +): RepoSourceControlAiOverrides { + return dropRepoLegacyInstructionForAction( + { + ...current, + actionOverrides: { + ...current.actionOverrides, + [actionId]: recipe + } + }, + actionId + ) +} + +export function serializeActionOverride( + value: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): string { + return JSON.stringify({ + hasOverride: hasOwnActionOverride(value.actionOverrides, actionId), + recipe: value.actionOverrides?.[actionId] ?? null + }) +} + +/** + * Layer only one action's draft override onto the last-saved repo settings, so a + * per-action save persists that recipe without flushing other rows' edits. + */ +export function buildActionScopedRepoAiSave( + persisted: RepoSourceControlAiOverrides, + draft: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): RepoSourceControlAiOverrides { + const nextActionOverrides = { ...persisted.actionOverrides } + if (hasOwnActionOverride(draft.actionOverrides, actionId)) { + nextActionOverrides[actionId] = draft.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return normalizeRepoAiDraft( + dropRepoLegacyInstructionForAction( + { ...persisted, actionOverrides: nextActionOverrides }, + actionId + ) + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a2d2f906fc..ea81c2c094a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.", "toggleLabel": "Toggle per-workspace environments" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "c7ff8cef11": "Detecting agents...", "b0da3a4d3e": "Launch recipe already saved", "bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.", - "5c75b24735": "Customize what the agent receives before Orca starts it." + "5c75b24735": "Customize what the agent receives before Orca starts it.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Save as global default", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b1a8371f613..97cbdff6f63 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.", "toggleLabel": "Activar o desactivar entornos por espacio de trabajo" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agente", + "agentArgs": "Argumentos de la CLI", + "commandTemplate": "Plantilla del comando", + "more": "+{{count}} más", + "plural": "Los guardados globales no cambiarán {{count}} repositorios con sus propias recetas.", + "recipe": "Receta", + "review": "Revisar", + "reviewFirst": "Revisar primero", + "singular": "Los guardados globales no cambiarán 1 repositorio con su propia receta.", + "tooltipTitle": "Anulaciones del repositorio" } }, "right": { @@ -9207,7 +9219,8 @@ "c7ff8cef11": "Detectando agentes...", "b0da3a4d3e": "La receta de lanzamiento ya está guardada", "bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.", - "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie." + "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie.", + "repoAgentOverrideNote": "Este repositorio anula tu valor predeterminado global ({{global}}) y actualmente ejecuta {{effective}}. Guarda en este repositorio para cambiar lo que se ejecuta aquí." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Guardar como valor predeterminado global", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d9e157b3635..590f6d505cf 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。", "toggleLabel": "ワークスペースごとの環境を切り替える" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "キャンセル", "b0da3a4d3e": "起動レシピはすでに保存されています", "bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。", - "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。" + "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "グローバルデフォルトとして保存", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 14aa226e98d..8ae5f66134f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.", "toggleLabel": "워크스페이스별 환경 전환" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "취소", "b0da3a4d3e": "실행 레시피가 이미 저장되었습니다", "bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.", - "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다." + "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "전역 기본값으로 저장", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 258a5cdd31e..4166c860372 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "显示仓库自带按需环境的设置控件和工作区运行目标。", "toggleLabel": "切换工作区专属环境" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "取消", "b0da3a4d3e": "启动预设已保存", "bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的预设。", - "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。" + "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。", + "repoAgentOverrideNote": "此仓库会覆盖你的全局默认值({{global}}),当前运行 {{effective}}。保存到此仓库以更改这里运行的内容。" }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "保存为全局默认值", diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.test.ts b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts new file mode 100644 index 00000000000..63a053f0186 --- /dev/null +++ b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' +import { + resolveSourceControlLaunchAgentScope, + summarizeReposOverridingActionRecipe +} from './source-control-launch-agent-selection' + +function settingsWithGlobalResolveAgent(agentId: TuiAgent): GlobalSettings { + const base = getDefaultSettings('/tmp') + return { + ...base, + defaultTuiAgent: agentId, + sourceControlAi: { + ...base.sourceControlAi!, + enabled: true, + actions: { + ...base.sourceControlAi!.actions, + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +function repoWithResolveAgent(agentId: TuiAgent): Pick { + return { + sourceControlAi: { + actionOverrides: { + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +describe('resolveSourceControlLaunchAgentScope', () => { + it('flags a repo override that pins a different agent than the global default', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'codex', + globalAgentId: 'claude', + overridesGlobalAgent: true + }) + }) + + it('does not flag a repo override that matches the global agent', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('claude'), + actionId: 'resolveConflicts' + }) + expect(scope.overridesGlobalAgent).toBe(false) + }) + + it('does not flag when there is no repo override', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: null, + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'claude', + globalAgentId: 'claude', + overridesGlobalAgent: false + }) + }) + + it('falls back to the default agent when no global recipe agent is set', () => { + const base = getDefaultSettings('/tmp') + const settings: GlobalSettings = { + ...base, + defaultTuiAgent: 'claude', + sourceControlAi: { ...base.sourceControlAi!, enabled: true } + } + const scope = resolveSourceControlLaunchAgentScope({ + settings, + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope.globalAgentId).toBe('claude') + expect(scope.overridesGlobalAgent).toBe(true) + }) +}) + +function repoOverriding( + id: string, + displayName: string, + agentId: TuiAgent +): Pick { + return { id, displayName, ...repoWithResolveAgent(agentId) } +} + +describe('summarizeReposOverridingActionRecipe', () => { + it('lists repos with any action recipe override', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + repoOverriding('repo-1', 'App', 'claude'), + { + id: 'repo-2', + displayName: 'Web', + sourceControlAi: { + actionOverrides: { + resolveConflicts: { + commandInputTemplate: '{basePrompt}\n\nUse project conflict rules.', + agentArgs: '--model gpt-5.4-mini' + } + } + } + }, + { id: 'repo-3', displayName: 'Docs' } + ], + actionId: 'resolveConflicts' + }) + + expect(summary).toEqual({ + count: 2, + overrides: [ + { repoId: 'repo-1', repoName: 'App', fields: ['agent', 'commandTemplate', 'agentArgs'] }, + { repoId: 'repo-2', repoName: 'Web', fields: ['commandTemplate', 'agentArgs'] } + ] + }) + }) + + it('includes legacy repo instructions as command template overrides', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + { + id: 'repo-1', + displayName: 'App', + sourceControlAi: { + instructionsByOperation: { + commitMessage: 'Use project style.' + } + } + } + ], + actionId: 'commitMessage' + }) + + expect(summary).toEqual({ + count: 1, + overrides: [{ repoId: 'repo-1', repoName: 'App', fields: ['commandTemplate'] }] + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.ts b/src/renderer/src/lib/source-control-launch-agent-selection.ts index 38660a74ce5..e8c00812590 100644 --- a/src/renderer/src/lib/source-control-launch-agent-selection.ts +++ b/src/renderer/src/lib/source-control-launch-agent-selection.ts @@ -1,8 +1,15 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { isCustomAgentId } from '../../../shared/commit-message-agent-spec' -import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../shared/source-control-ai' +import type { + SourceControlActionId, + SourceControlActionRecipe +} from '../../../shared/source-control-ai-actions' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/types' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' export function readSourceControlLaunchRecipeAgentId( recipe: Pick | null | undefined @@ -30,3 +37,122 @@ export function pickSourceControlLaunchAgent(args: { } return getAgentCatalog().find((entry) => enabledAgents.includes(entry.id))?.id ?? null } + +export type SourceControlLaunchAgentScope = { + /** Agent the button would launch, after applying any repo override. */ + effectiveAgentId: TuiAgent | null + /** Agent the button would launch without a repo override (global recipe or default). */ + globalAgentId: TuiAgent | null + /** + * True when this repo pins a different launch agent than the global default — + * the silent-override case we surface instead of auto-launching blindly. + */ + overridesGlobalAgent: boolean +} + +export function resolveSourceControlLaunchAgentScope(input: { + settings: + | Pick + | null + | undefined + repo?: Pick | null + actionId: SourceControlActionId +}): SourceControlLaunchAgentScope { + const effectiveAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: input.repo, + actionId: input.actionId + }) + ) + const globalRecipeAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: null, + actionId: input.actionId + }) + ) + // Why: the note compares against what would run with no override, so fall back + // to the global default agent when no global recipe agent is set. + const defaultTuiAgent = input.settings?.defaultTuiAgent + const globalAgentId = + globalRecipeAgentId ?? (defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null) + const hasRepoAgentOverride = + normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)?.actionOverrides?.[ + input.actionId + ]?.agentId !== undefined + return { + effectiveAgentId, + globalAgentId, + overridesGlobalAgent: + hasRepoAgentOverride && effectiveAgentId !== null && effectiveAgentId !== globalAgentId + } +} + +export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs' + +export type SourceControlActionRecipeOverride = { + repoId: string + repoName: string + fields: SourceControlActionRecipeOverrideField[] +} + +export type SourceControlActionRecipeOverrideSummary = { + count: number + overrides: SourceControlActionRecipeOverride[] +} + +type NormalizedRepoSourceControlAiOverrides = NonNullable< + ReturnType +> + +function hasActionOverride( + overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined, + actionId: SourceControlActionId +): boolean { + return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) +} + +function readRecipeOverrideFields( + recipe: + | NonNullable[SourceControlActionId] + | null + | undefined +): SourceControlActionRecipeOverrideField[] { + const fields: SourceControlActionRecipeOverrideField[] = [] + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) { + fields.push('agent') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) { + fields.push('commandTemplate') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) { + fields.push('agentArgs') + } + return fields +} + +/** + * Which repos own any recipe setting for an action — lets the global recipe + * editor warn that saving globally will not update those repo-specific recipes. + */ +export function summarizeReposOverridingActionRecipe(input: { + repos: readonly Pick[] + actionId: SourceControlActionId +}): SourceControlActionRecipeOverrideSummary { + const overrides: SourceControlActionRecipeOverride[] = [] + for (const repo of input.repos) { + const actionOverrides = normalizeRepoSourceControlAiOverrides( + repo.sourceControlAi + )?.actionOverrides + if (!hasActionOverride(actionOverrides, input.actionId)) { + continue + } + overrides.push({ + repoId: repo.id, + repoName: repo.displayName, + fields: readRecipeOverrideFields(actionOverrides?.[input.actionId]) + }) + } + return { count: overrides.length, overrides } +} From 7e1f9e55f2b8bf9e70a36345adf9e4610700c74c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 5 Jul 2026 00:31:51 -0400 Subject: [PATCH 107/141] fix(worktrees): fall back from stale default base refs (#7398) * fix(worktrees): fall back from stale default base refs (#7312) * docs(worktrees): explain base fallback policy * fix(worktrees): harden stale base fallback edge cases --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/git/repo.test.ts | 68 ++ src/main/git/repo.ts | 74 ++- src/main/git/worktree-base-ref-probe.ts | 13 +- .../ssh-worktree-create-root-registration.ts | 50 ++ src/main/ipc/worktree-remote.ts | 225 +++++-- src/main/ipc/worktrees.test.ts | 583 ++++++++++++++++-- src/main/runtime/orca-runtime.test.ts | 185 +++++- src/main/runtime/orca-runtime.ts | 134 +++- src/main/worktree-create-base-prefetch.ts | 55 +- src/main/worktree-create-base.test.ts | 53 ++ src/main/worktree-create-base.ts | 29 + ...poserState-host-context-boundaries.test.ts | 4 +- src/renderer/src/hooks/useComposerState.ts | 7 +- .../src/runtime/worktree-create-base.test.ts | 32 +- .../src/runtime/worktree-create-base.ts | 13 +- 15 files changed, 1295 insertions(+), 230 deletions(-) create mode 100644 src/main/ipc/ssh-worktree-create-root-registration.ts create mode 100644 src/main/worktree-create-base.test.ts create mode 100644 src/main/worktree-create-base.ts diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 8392b5434a0..d4abbd2501e 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -10,6 +10,7 @@ import { getBranchConflictKind, getRemoteCount, parseAndFilterSearchRefDetails, + resolveDefaultBaseRefViaExec, searchBaseRefDetails, searchBaseRefs } from './repo' @@ -493,6 +494,26 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { expect(result).toBe('origin/main') }) + it('falls through from a stale origin/HEAD target to an existing primary ref', () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/main', sha) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master']) + + const result = getDefaultBaseRef(tmpDir) + + expect(result).toBe('origin/main') + }) + + it('falls through from a stale origin/HEAD primary target to another existing default ref', () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/master', sha) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']) + + const result = getDefaultBaseRef(tmpDir) + + expect(result).toBe('origin/master') + }) + it('does NOT fall through to upstream/main when origin/* is absent', () => { // Why: this is the explicit design decision — the default probe order // is origin-only. upstream-aware defaulting is deferred work. @@ -508,6 +529,53 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { }) }) +describe('resolveDefaultBaseRefViaExec', () => { + it('falls through from a stale origin/HEAD target to the probe list', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/master\n' } + } + if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') { + return { stdout: 'main-sha\n' } + } + throw new Error('missing ref') + } + + await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main') + + expect(calls).toEqual([ + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'] + ]) + }) + + it('verifies origin/HEAD even when it points at origin/main', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') { + return { stdout: 'master-sha\n' } + } + throw new Error('missing ref') + } + + await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master') + + expect(calls).toEqual([ + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'] + ]) + }) +}) + describe('getRemoteCount', () => { let tmpDir: string diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index d743c8ab5e4..68900f810bf 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -467,6 +467,24 @@ function hasGitRef(path: string, ref: string): boolean { } } +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + + // Why: origin/HEAD may survive a default-branch rename while pointing at a + // deleted ref; verify before trusting it over the probe list. + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + /** * Resolve the default base ref for new worktrees. * Prefer the remote primary branch over a potentially stale local branch. @@ -478,16 +496,9 @@ function hasGitRef(path: string, ref: string): boolean { * degrade gracefully for non-creation uses (e.g. hosted URL building). */ export function getDefaultBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - if (ref) { - return ref.replace(/^refs\/remotes\//, '') - } - } catch { - // Fall through to explicit remote branch probes. + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef } // Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the @@ -595,6 +606,28 @@ export async function getRemoteCount(path: string): Promise { /** Callback shape for a git exec function that yields stdout. */ export type GitExec = (argv: string[]) => Promise<{ stdout: string }> +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + /** * Resolve the default base ref given a git exec callback. Prefers * origin/HEAD's symbolic-ref target; falls back to DEFAULT_BASE_REF_PROBES. @@ -609,24 +642,11 @@ export type GitExec = (argv: string[]) => Promise<{ stdout: string }> * from a genuine transport failure. */ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (ref) { - return ref.replace(/^refs\/remotes\//, '') - } - } catch { - // symbolic-ref returns non-zero when origin/HEAD is unset — expected. - // Fall through to probes. + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef } - return resolveDefaultBaseRefFromProbes(async (ref) => { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } - }) + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) } async function getDefaultBaseRefAsync( diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 0d8e132df40..c7144159dfe 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef( options: GitExecOptions = {} ): Promise { try { - await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], { - cwd: repoPath, - ...options - }) - return true + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], + { + cwd: repoPath, + ...options + } + ) + return stdout.trim().length > 0 } catch { return false } diff --git a/src/main/ipc/ssh-worktree-create-root-registration.ts b/src/main/ipc/ssh-worktree-create-root-registration.ts new file mode 100644 index 00000000000..e364239cdbe --- /dev/null +++ b/src/main/ipc/ssh-worktree-create-root-registration.ts @@ -0,0 +1,50 @@ +import { getActiveMultiplexer } from './ssh' + +const SSH_CONNECTION_UNAVAILABLE_MESSAGE = + 'SSH connection is not available. Please reconnect and try again.' + +type SshRootRegistrationMux = { + request: (method: 'session.registerRoot', payload: { rootPath: string }) => Promise + notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void +} + +async function registerSshWorktreeCreateRoots( + mux: SshRootRegistrationMux, + rootPaths: string[] +): Promise { + try { + await Promise.all( + rootPaths.map((rootPath) => mux.request('session.registerRoot', { rootPath })) + ) + } catch (err) { + if (err instanceof Error && err.message.includes('Method not found')) { + for (const rootPath of rootPaths) { + mux.notify('session.registerRoot', { rootPath }) + } + return + } + throw err + } +} + +export async function registerOptionalSshWorktreeCreateRoots( + connectionId: string, + rootPaths: string[] +): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + return + } + await registerSshWorktreeCreateRoots(mux, rootPaths) +} + +export async function registerRequiredSshWorktreeCreateRoots( + connectionId: string, + rootPaths: string[] +): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE) + } + await registerSshWorktreeCreateRoots(mux, rootPaths) +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 8193d6d74c1..e0a2bbf5db1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -30,6 +30,8 @@ import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' import { resolveLocalGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { resolveWorktreeCreateBase } from '../worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' import type { ForgeProviderId } from '../source-control/forge-provider' import { validateGitPushTarget } from '../git/push-target-validation' @@ -53,12 +55,15 @@ import { } from '../hooks' import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { getActiveMultiplexer } from './ssh' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' +import { + registerOptionalSshWorktreeCreateRoots, + registerRequiredSshWorktreeCreateRoots +} from './ssh-worktree-create-root-registration' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -608,6 +613,35 @@ function hasLocalCommitObjectWithOptions( ) } +async function hasLocalWorktreeBaseRefWithOptions( + repoPath: string, + baseRef: string, + gitOptions: { wslDistro?: string } +): Promise { + const refExists = async (qualifiedRef: string) => { + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], + { + cwd: repoPath, + ...gitOptions + } + ) + return stdout.trim().length > 0 + } catch { + return false + } + } + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions) +} + function getLocalGitHubPrForBranch( repoPath: string, branchName: string, @@ -626,6 +660,23 @@ function hasRemoteCommitObject( return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref) } +async function hasRemoteWorktreeBaseRef( + provider: SshGitProvider, + repoPath: string, + baseRef: string +): Promise { + const refExists = (qualifiedRef: string) => + hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasRemoteCommitObject(provider, repoPath, baseRef) +} + // Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is // a symbolic ref (refs/remotes/origin/main), so detect its presence directly so // SSH creates can fall back to an existing local base ref when the refresh @@ -1218,33 +1269,31 @@ async function resolveRemoteTrackingBaseSsh( } } -async function resolveRemoteWorktreeCreateBase( - provider: SshGitProvider, - repo: Repo, - requestedBaseBranch: string | undefined -): Promise { - let baseBranch = requestedBaseBranch || repo.worktreeBaseRef - if (baseBranch) { - return baseBranch - } - try { - const { stdout } = await provider.exec( - ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], - repo.path - ) - baseBranch = stdout.trim() - } catch { - return null - } - return baseBranch || null -} - async function resolveRemoteWorktreeCreateBasePlan( provider: SshGitProvider, repo: Repo, requestedBaseBranch: string | undefined ): Promise { - const baseBranch = await resolveRemoteWorktreeCreateBase(provider, repo, requestedBaseBranch) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + resolveDefaultBaseRefViaExec((argv) => provider.exec(argv, repo.path)), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await resolveRemoteTrackingBaseSsh( + provider, + repo.path, + baseBranchCandidate + ) + if (remoteTrackingBase) { + if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) { + return true + } + return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) + } + return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) + } + }) if (!baseBranch) { return null } @@ -1280,15 +1329,23 @@ export async function prefetchRemoteWorktreeCreateBase( repo: Repo, args: { baseBranch?: string } ): Promise { + // Why: the shared base-plan probes use generic git.exec, and some relays + // require the repo root to be registered before those probes can see refs. + await registerOptionalSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch) if (!basePlan) { return } if (basePlan.remoteTrackingBase) { - await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) - return + if ( + (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || + !(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) + ) { + await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) + return + } } - if (await hasRemoteCommitObject(provider, repo.path, basePlan.baseBranch)) { + if (await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) { // Why: PR/MR resolvers already fetched verified SHA start points. A broad // remote fetch only updates unrelated refs when the commit object exists. return @@ -1471,6 +1528,10 @@ export async function createRemoteWorktree( ? sanitizeWorktreeDisplayName(args.displayName) : undefined + // Why: resolving the create base can probe repo refs through generic git.exec. + // Register the repo root first so relays do not report a valid base as stale. + await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) + // Why: SSH targets cannot use the local `gh` account, and git email/name are // commit author identity rather than hosted-account usernames. const username = await getSshGitUsername(provider, repo.path) @@ -1488,7 +1549,8 @@ export async function createRemoteWorktree( 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' ) } - const { baseBranch, remoteTrackingBase } = basePlan + const { baseBranch } = basePlan + let { remoteTrackingBase } = basePlan let branchName = '' let checkoutExistingBranch = false @@ -1594,25 +1656,20 @@ export async function createRemoteWorktree( } } - const mux = getActiveMultiplexer(repo.connectionId!) - if (!mux) { - throw new Error('SSH connection is not available. Please reconnect and try again.') - } - // Why: register before any generic git.exec probe (base-ref existence, the - // local-base advisory) as well as addWorktree. Fresh/older relays may gate - // generic git.exec on registered roots; probing first would falsely report a - // ref missing (and defeat the offline fallback below) even though create works. - try { - await Promise.all([ - mux.request('session.registerRoot', { rootPath: repo.path }), - mux.request('session.registerRoot', { rootPath: remotePath }) - ]) - } catch (err) { - if (err instanceof Error && err.message.includes('Method not found')) { - mux.notify('session.registerRoot', { rootPath: repo.path }) - mux.notify('session.registerRoot', { rootPath: remotePath }) - } else { - throw err + // Why: addWorktree and setup probes run inside the new worktree path; older + // relays need that root registered before accepting git/fs operations there. + await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) + + if (remoteTrackingBase) { + const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + provider, + repo.path, + remoteTrackingBase.ref + ) + const hasLocalBaseRef = + hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch)) + if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null } } @@ -1633,7 +1690,7 @@ export async function createRemoteWorktree( ) } } - } else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) { + } else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) { // Why: local or otherwise non-remote-tracking bases preserve legacy // best-effort fetch behavior. Verified PR/MR SHA bases already have the // commit object locally, so a broad remote fetch only updates unrelated refs. @@ -1914,15 +1971,40 @@ export async function createLocalWorktree( ? sanitizeWorktreeDisplayName(args.displayName) : undefined - // Why: resolve the base before branch/path selection so remote-tracking bases - // can be refreshed before `git worktree add`. Creating first and repairing - // later races setup scripts, agents, and user edits. - const baseBranch = - args.baseBranch || - repo.worktreeBaseRef || - (await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: args.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)), + isBaseUsable: async (baseBranchCandidate) => { + if (runtime) { + const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + if ( + await runtime.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + ) { + return true + } + return hasLocalWorktreeBaseRefWithOptions( + repo.path, + baseBranchCandidate, + localGitExecOptions + ) + } + } + return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions) + } + }) if (!baseBranch) { - // Why: getDefaultBaseRef may return null when none of origin/HEAD, + // Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD, // origin/main, origin/master, local main, or local master exist. Don't // fall back to a hardcoded 'origin/main' — passing a non-existent ref to // `git worktree add` produces an opaque error. Fail here with a clear @@ -1947,23 +2029,30 @@ export async function createLocalWorktree( ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { - const hasLocalBaseRef = await runtime.hasRemoteTrackingRef( + const hasRemoteTrackingBaseRef = await runtime.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) - emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) - remoteTrackingRefresh = { - base: remoteTrackingBase, - hadLocalBaseRef: hasLocalBaseRef, - promise: runtime.getOrStartRemoteTrackingBaseRefresh( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) + const hasLocalBaseRef = + hasRemoteTrackingBaseRef || + (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)) + if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null + } else { + emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) + remoteTrackingRefresh = { + base: remoteTrackingBase, + hadLocalBaseRef: hasRemoteTrackingBaseRef, + promise: runtime.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + } } } else if ( - !(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) + !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) ) { // Why: when the base branch does not match a configured remote prefix // (e.g. plain `main`, `master`, or any local branch), the legacy path @@ -1976,7 +2065,9 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } else { - if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) { + if ( + !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) + ) { legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], { ...localGitExecOptions, timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f619d078630..e9f9edfcdae 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -493,16 +493,32 @@ describe('registerWorktreeHandlers', () => { }) it('prefetches the local default create base through the runtime refresh cache', async () => { + const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/master' + } const remoteBase = { remote: 'origin', branch: 'main', ref: 'refs/remotes/origin/main', base: 'origin/main' } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + store.getRepo.mockReturnValue(repo) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'origin/main' ? remoteBase : null + ) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/master' + ) expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( '/workspace/repo', 'origin/main' @@ -3668,7 +3684,7 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => { + it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -3679,7 +3695,6 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: 'origin/main' } const provider = { - // Empty rev-parse stdout -> no local remote-tracking base ref to fall back on. exec: vi.fn().mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } @@ -3705,24 +3720,26 @@ describe('registerWorktreeHandlers', () => { await expect( handlers['worktrees:create'](null, { repoId: 'repo-ssh', - name: 'improve-dashboard' + name: 'improve-dashboard', + baseBranch: 'origin/master' }) ).rejects.toThrow( - 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.' ) expect(provider.addWorktree).not.toHaveBeenCalled() + expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled() expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( '/remote/repo', 'origin', - 'main', - 'refs/remotes/origin/main' + 'master', + 'refs/remotes/origin/master' ) }) - it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => { - // Regression: a failed SSH refresh must fall back to an existing local - // remote-tracking base ref instead of blocking creation. + it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected + // primary default instead of blocking creation. const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -3730,20 +3747,25 @@ describe('registerWorktreeHandlers', () => { badgeColor: '#000', addedAt: 0, connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' + worktreeBaseRef: 'origin/master' } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } - // Local remote-tracking base ref resolves -> usable fallback exists. + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: `${'a'.repeat(40)}\n`, stderr: '' } } return { stdout: '', stderr: '' } }), - fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi .fn() @@ -3773,15 +3795,175 @@ describe('registerWorktreeHandlers', () => { name: 'improve-dashboard' }) - // The refresh must be ATTEMPTED (and fail) before falling back — guards - // against a regression that skips the refresh whenever a local ref exists. + expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled() expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( '/remote/repo', 'origin', 'main', 'refs/remotes/origin/main' ) - expect(provider.addWorktree).toHaveBeenCalled() + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'improve-dashboard', + '/remote/improve-dashboard', + { + base: 'origin/main' + } + ) + }) + + it('keeps a usable SSH persisted local branch base after registering the repo root', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'develop' + } + let repoRootRegistered = false + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/local-branch-base', + head: 'develop-sha', + branch: 'refs/heads/local-branch-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + if (payload.rootPath === repo.path) { + repoRootRegistered = true + } + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'local-branch-base' + }) + + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path }) + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled() + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'local-branch-base', + '/remote/local-branch-base', + { + base: 'develop' + } + ) + }) + + it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'team/feature' + } + let repoRootRegistered = false + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/slash-local-base', + head: 'team-feature-sha', + branch: 'refs/heads/slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + if (payload.rootPath === repo.path) { + repoRootRegistered = true + } + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'slash-local-base' + }) + + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( + '/remote/repo', + 'team', + 'feature', + 'refs/remotes/team/feature' + ) + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'slash-local-base', + '/remote/slash-local-base', + { + base: 'team/feature' + } + ) }) it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { @@ -3986,6 +4168,160 @@ describe('registerWorktreeHandlers', () => { expect(provider.addWorktree).toHaveBeenCalledTimes(1) }) + it('registers the SSH repo root before create-base prefetch probes refs', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/master' + } + const registeredRoots = new Set() + const events: string[] = [] + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`) + if (!registeredRoots.has('/remote/repo')) { + throw new Error('root not registered') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + throw new Error('missing stale base') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/prefetched-worktree', + head: 'abc123', + branch: 'refs/heads/prefetched-worktree', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + events.push(`register:${payload.rootPath}`) + registeredRoots.add(payload.rootPath) + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:prefetchCreateBase'](null, { + repoId: 'repo-ssh' + }) + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'prefetched-worktree' + }) + + expect(events[0]).toBe('register:/remote/repo') + expect(events).not.toContain('exec:symbolic-ref:false') + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'prefetched-worktree', + '/remote/prefetched-worktree', + { + base: 'origin/main' + } + ) + }) + + it('does not let SSH prefetch turn a persisted slash-named local branch into a remote-tracking base', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'team/feature' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/slash-local-base', + head: 'team-feature-sha', + branch: 'refs/heads/slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:prefetchCreateBase'](null, { + repoId: 'repo-ssh' + }) + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'slash-local-base' + }) + + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( + '/remote/repo', + 'team', + 'feature', + 'refs/remotes/team/feature' + ) + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'slash-local-base', + '/remote/slash-local-base', + { + base: 'team/feature' + } + ) + }) + it('shares in-flight SSH create-base resolution with create', async () => { const repo = { id: 'repo-ssh', @@ -4241,7 +4577,16 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if ( + args[0] === 'rev-parse' && + (args.includes('refs/remotes/origin/master^{commit}') || + args.includes('refs/heads/origin/master^{commit}')) + ) { + throw new Error('missing ref') + } + return { stdout: 'created-sha\n', stderr: '' } + }) const createPromise = handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -4270,17 +4615,82 @@ describe('registerWorktreeHandlers', () => { ) }) - it('creates from an existing local base ref when the pre-create refresh fails', async () => { - // Regression: a failed refresh must not block creation when a usable local - // remote-tracking base ref already exists. + it('creates from the detected default base when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected + // primary default instead of blocking creation. const remoteBase = { remote: 'origin', branch: 'main', ref: 'refs/remotes/origin/main', base: 'origin/main' } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/master' + }) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'origin/main' ? remoteBase : null + ) runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: true, + errorKind: 'git_error' + }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'created-sha', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if ( + args[0] === 'rev-parse' && + (args.includes('refs/remotes/origin/master^{commit}') || + args.includes('refs/heads/origin/master^{commit}')) + ) { + throw new Error('missing ref') + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(addWorktreeMock).toHaveBeenCalled() + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/master' + ) + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/main' + ) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + '/workspace/repo', + remoteBase + ) + expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') + }) + + it('keeps a usable persisted local branch base when a detected default exists', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'develop' + }) + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null) runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ ok: false, errorKind: 'git_error' @@ -4294,29 +4704,14 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) - - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'improve-dashboard' - })) as CreateWorktreeResult - - expect(addWorktreeMock).toHaveBeenCalled() - expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') - }) - - it('does not create when the pre-create refresh fails and no local base ref exists', async () => { - const remoteBase = { - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main', - base: 'origin/main' - } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) - runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) - runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ - ok: false, - errorKind: 'git_error' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: 'develop-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: 'created-sha\n', stderr: '' } }) await expect( @@ -4324,11 +4719,109 @@ describe('registerWorktreeHandlers', () => { repoId: 'repo-1', name: 'improve-dashboard' }) + ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + 'develop', + false + ) + }) + + it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => { + const remoteBase = { + remote: 'team', + branch: 'feature', + ref: 'refs/remotes/team/feature', + base: 'team/feature' + } + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'team/feature' + }) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'team/feature' ? remoteBase : null + ) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/slash-local-base', + head: 'created-sha', + branch: 'slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'slash-local-base' + }) + ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/slash-local-base', + 'slash-local-base', + 'team/feature', + false + ) + }) + + it('keeps an explicit base strict when the pre-create refresh fails', async () => { + const remoteBase = { + remote: 'origin', + branch: 'master', + ref: 'refs/remotes/origin/master', + base: 'origin/master' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: false, + errorKind: 'git_error' + }) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main' + }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + baseBranch: 'origin/master' + }) ).rejects.toThrow( - 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.' ) expect(addWorktreeMock).not.toHaveBeenCalled() + expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled() }) it('delegates remote-tracking base freshness to the runtime before create', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a8ef74053a3..5bc855ee7c4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -811,6 +811,16 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0- const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111' const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +function isOriginMainBaseRefProbe(args: string[]): boolean { + return ( + args[0] === 'rev-parse' && + args[1] === '--verify' && + (args.includes('origin/main') || + args.includes('refs/remotes/origin/main') || + args.includes('refs/remotes/origin/main^{commit}')) + ) +} + function antigravityReadyScreen(model = 'Gemini 3.5 Flash (High)'): string { return [ 'Antigravity CLI 1.0.3', @@ -2370,9 +2380,8 @@ describe('OrcaRuntimeService', () => { } }) - it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => { - // Regression: an offline/auth failure while refreshing the remote-tracking - // base must not block creation when a usable local base ref already exists. + it('creates a runtime local worktree from the detected default when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected default. const runtime = new OrcaRuntimeService(store) const createdWorktree = { path: '/tmp/workspaces/cli-refresh-fails', @@ -2381,6 +2390,8 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'origin/master' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) computeWorktreePathMock.mockReturnValue(createdWorktree.path) ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) vi.mocked(addWorktree).mockResolvedValueOnce({}) @@ -2392,8 +2403,10 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { return { stdout: '/tmp/repo/.git\n', stderr: '' } } - // Local remote-tracking base ref resolves -> usable fallback exists. - if (args[0] === 'rev-parse' && args[1] === '--verify') { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + throw new Error('missing ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: 'base-sha\n', stderr: '' } } if (args[0] === 'fetch') { @@ -2409,8 +2422,135 @@ describe('OrcaRuntimeService', () => { }) ).resolves.toBeDefined() - expect(addWorktree).toHaveBeenCalled() + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'cli-refresh-fails', + 'origin/main', + false, + false, + { + remoteTrackingBase: { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }, + suggestLocalBaseRefUpdate: true + } + ) + expect(getDefaultBaseRef).toHaveBeenCalled() } finally { + getReposSpy.mockRestore() + gitSpy.mockRestore() + } + }) + + it('creates a runtime local worktree from a usable persisted local branch base', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/local-branch-base', + head: 'develop-sha', + branch: 'local-branch-base', + isBare: false, + isMainWorktree: false + } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'develop' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: 'develop-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'local-branch-base' + }) + ).resolves.toBeDefined() + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'local-branch-base', + 'develop', + false + ) + } finally { + getReposSpy.mockRestore() + gitSpy.mockRestore() + } + }) + + it('creates a runtime local worktree from a slash-named local branch matching a remote prefix', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/slash-local-base', + head: 'team-feature-sha', + branch: 'slash-local-base', + isBare: false, + isMainWorktree: false + } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'team/feature' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'slash-local-base' + }) + ).resolves.toBeDefined() + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'slash-local-base', + 'team/feature', + false + ) + expect(gitSpy).not.toHaveBeenCalledWith( + ['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'], + expect.any(Object) + ) + } finally { + getReposSpy.mockRestore() gitSpy.mockRestore() } }) @@ -3140,6 +3280,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3244,6 +3387,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3397,6 +3543,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3507,6 +3656,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3609,6 +3761,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3765,6 +3920,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -4840,6 +4998,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -22332,6 +22493,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -22425,6 +22589,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -22535,6 +22702,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -23216,6 +23386,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'config') { return { stdout: 'origin\n', stderr: '' } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c53bbd9964..12d6c3e9f37 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -46,6 +46,8 @@ import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { resolveWorktreeCreateBase } from '../worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { @@ -581,7 +583,8 @@ import { getRemoteDrift, getRecentDriftSubjects } from '../git/repo' -import { hasLocalCommitObject } from '../git/commit-object-ref' +import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { resolveLocalGitUsername } from '../git/git-username' import { listWorktrees, @@ -1963,6 +1966,26 @@ type LayoutQueueEntry = { }[] } +async function hasLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: { wslDistro?: string } = {} +): Promise { + const refExists = (qualifiedRef: string) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasCommitObjectViaGitExec( + (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), + baseRef + ) +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -12992,12 +13015,42 @@ export class OrcaRuntimeService { let effectiveSanitizedName = sanitizedName const username = await resolveLocalGitUsername(repo.path) - const baseBranch = - args.baseBranch || - repo.worktreeBaseRef || - (hasLocalWorktreeGitOptions - ? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) - : getDefaultBaseRef(repo.path)) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: args.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + hasLocalWorktreeGitOptions + ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) + : Promise.resolve(getDefaultBaseRef(repo.path)), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + if ( + await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + ) { + return true + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + }) if (!baseBranch) { // Why: getDefaultBaseRef returns null when no suitable ref exists. // Don't fabricate 'origin/main' — passing it to addWorktree would @@ -13128,44 +13181,61 @@ export class OrcaRuntimeService { `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` ) } - const remoteTrackingBase = await this.resolveRemoteTrackingBase( + let remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, baseBranch, ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { - const hadLocalBaseRef = await this.hasRemoteTrackingRef( + const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) - const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) - if (!refreshResult.ok && !hadLocalBaseRef) { - // Why: only block creation when the refresh failed AND there is no - // usable local base ref to fall back on. If a local remote-tracking ref - // already exists, `git worktree add` can create from it — a possibly - // stale but valid base — so a transient offline/auth failure must not - // make the workspace uncreatable. The compare-to-base view reflects any - // drift once the remote is reachable again. - throw new Error( - `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` - ) - } - if ( - !hadLocalBaseRef && - !(await this.hasRemoteTrackingRef( + const hasLocalBaseRef = + hadRemoteTrackingBaseRef || + (await hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + )) + if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null + } else { + const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs - )) - ) { - throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + ) + if (!refreshResult.ok && !hadRemoteTrackingBaseRef) { + // Why: only block creation when the refresh failed AND there is no + // usable local base ref to fall back on. If a local remote-tracking ref + // already exists, `git worktree add` can create from it — a possibly + // stale but valid base — so a transient offline/auth failure must not + // make the workspace uncreatable. The compare-to-base view reflects any + // drift once the remote is reachable again. + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + if ( + !hadRemoteTrackingBaseRef && + !(await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + )) + ) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } } - } else if (!(await hasLocalCommitObject(repo.path, baseBranch))) { + } else if ( + !(await hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + )) + ) { // Why: local bases keep legacy best-effort fetch behavior. Verified PR // SHA bases already have the commit object needed by `git worktree add`. try { diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 24c22837c22..42add57be66 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -1,9 +1,12 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/types' -import { hasLocalCommitObject } from './git/commit-object-ref' +import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' +import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' import { getDefaultBaseRef } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' +import { resolveWorktreeCreateBase } from './worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' type RemoteTrackingBaseForPrefetch = { remote: string @@ -17,6 +20,7 @@ type WorktreeCreateBasePrefetchRuntime = { repoPath: string, baseBranch: string ) => Promise + hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise getOrStartRemoteTrackingBaseRefresh: ( repoPath: string, base: RemoteTrackingBaseForPrefetch @@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = { fetchRemoteWithCache: (repoPath: string, remote: string) => Promise } +async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise { + const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasLocalCommitObject(repoPath, baseRef) +} + async function prefetchLocalWorktreeCreateBase( repo: Repo, baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime ): Promise { - const resolvedBaseBranch = baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + const resolvedBaseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate + ) + if (remoteTrackingBase) { + if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) { + return true + } + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + } + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + } + }) if (!resolvedBaseBranch) { return } - if (await hasLocalCommitObject(repo.path, resolvedBaseBranch)) { - // Why: hosted-review start points can be verified commit SHAs; a broad - // remote fetch cannot make an already-local object fresher. + if ( + isFullGitObjectId(resolvedBaseBranch) && + (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + ) { return } const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch) if (remoteTrackingBase) { - await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + if ( + (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) || + !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + ) { + await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + return + } + } + if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) { + // Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher. return } diff --git a/src/main/worktree-create-base.test.ts b/src/main/worktree-create-base.test.ts new file mode 100644 index 00000000000..168cae3441c --- /dev/null +++ b/src/main/worktree-create-base.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveWorktreeCreateBase } from './worktree-create-base' + +describe('resolveWorktreeCreateBase', () => { + it('falls back from a stale persisted base to the detected default', async () => { + const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') + const isBaseUsable = vi.fn().mockResolvedValue(false) + + await expect( + resolveWorktreeCreateBase({ + repoWorktreeBaseRef: 'origin/master', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/main') + + expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1) + expect(isBaseUsable).toHaveBeenCalledWith('origin/master') + }) + + it('returns an explicit base without probing defaults or usability', async () => { + const resolveDefaultBaseRef = vi.fn() + const isBaseUsable = vi.fn() + + await expect( + resolveWorktreeCreateBase({ + requestedBaseBranch: 'origin/master', + repoWorktreeBaseRef: 'origin/main', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/master') + + expect(resolveDefaultBaseRef).not.toHaveBeenCalled() + expect(isBaseUsable).not.toHaveBeenCalled() + }) + + it('keeps a usable persisted base when it is still valid', async () => { + const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') + const isBaseUsable = vi.fn().mockResolvedValue(true) + + await expect( + resolveWorktreeCreateBase({ + repoWorktreeBaseRef: 'origin/master', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/master') + + expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1) + expect(isBaseUsable).toHaveBeenCalledWith('origin/master') + }) +}) diff --git a/src/main/worktree-create-base.ts b/src/main/worktree-create-base.ts new file mode 100644 index 00000000000..67f0427b392 --- /dev/null +++ b/src/main/worktree-create-base.ts @@ -0,0 +1,29 @@ +type ResolveWorktreeCreateBaseArgs = { + requestedBaseBranch?: string + repoWorktreeBaseRef?: string | null + resolveDefaultBaseRef: () => Promise + isBaseUsable: (baseBranch: string) => Promise +} + +export async function resolveWorktreeCreateBase( + args: ResolveWorktreeCreateBaseArgs +): Promise { + if (args.requestedBaseBranch) { + return args.requestedBaseBranch + } + + const defaultBaseRef = await args.resolveDefaultBaseRef() + const repoWorktreeBaseRef = args.repoWorktreeBaseRef + if (!repoWorktreeBaseRef) { + return defaultBaseRef + } + if (!defaultBaseRef) { + return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : null + } + // Resolving the default already proved matching persisted refs exist. + if (repoWorktreeBaseRef === defaultBaseRef) { + return repoWorktreeBaseRef + } + // Stale persisted refs fall back to the detected default; usable custom refs stay authoritative. + return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : defaultBaseRef +} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index c43a051c8ab..00aa3fe4fea 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -512,8 +512,8 @@ describe('useComposerState host-context boundaries', () => { expect(section).toContain('resolveWorktreeCreateBaseBranch') expect(section).toContain('explicitBaseBranch: smartSubmitBaseBranch') - expect(section).toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') - expect(section).toContain('getRuntimeRepoBaseRefDefault') + expect(section).not.toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') + expect(section).not.toContain('getRuntimeRepoBaseRefDefault') }) it('plans new workspace agent startup from the selected repo runtime', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dc6de85382b..33049241411 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -29,7 +29,6 @@ import { import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { resolveWorktreeCreateBaseBranch } from '@/runtime/worktree-create-base' import { buildTaskSourceContextFromRepo, @@ -3971,11 +3970,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) const submitBaseBranch = selectedRepoIsGit ? await resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: smartSubmitBaseBranch, - repoWorktreeBaseRef: selectedRepo.worktreeBaseRef, - loadDefaultBaseRef: async () => - (await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) - ?.defaultBaseRef + explicitBaseBranch: smartSubmitBaseBranch }) : undefined const createDisplayName = diff --git a/src/renderer/src/runtime/worktree-create-base.test.ts b/src/renderer/src/runtime/worktree-create-base.test.ts index 67f74558b3b..2ece667beef 100644 --- a/src/renderer/src/runtime/worktree-create-base.test.ts +++ b/src/renderer/src/runtime/worktree-create-base.test.ts @@ -1,42 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { resolveWorktreeCreateBaseBranch } from './worktree-create-base' describe('resolveWorktreeCreateBaseBranch', () => { - it('uses an explicit Start-from selection before repo defaults', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('uses an explicit Start-from selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: 'origin/feature', - repoWorktreeBaseRef: 'dev', - loadDefaultBaseRef + explicitBaseBranch: ' origin/feature ' }) ).resolves.toBe('origin/feature') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() }) - it('uses the pinned repo worktree base before resolving the git primary', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('omits repo defaults so backend create owns base selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: ' dev ', - loadDefaultBaseRef + explicitBaseBranch: undefined }) - ).resolves.toBe('dev') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() + ).resolves.toBeUndefined() }) - it('falls back to the git primary when no explicit or pinned base exists', async () => { + it('omits blank explicit selections', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: undefined, - loadDefaultBaseRef: vi.fn().mockResolvedValue('origin/main') + explicitBaseBranch: ' ' }) - ).resolves.toBe('origin/main') + ).resolves.toBeUndefined() }) }) diff --git a/src/renderer/src/runtime/worktree-create-base.ts b/src/renderer/src/runtime/worktree-create-base.ts index cb3f2fc62f8..d182e1c90a3 100644 --- a/src/renderer/src/runtime/worktree-create-base.ts +++ b/src/renderer/src/runtime/worktree-create-base.ts @@ -1,15 +1,6 @@ +// Backend base resolution owns stale-ref fallback, so the renderer only forwards explicit selections. export async function resolveWorktreeCreateBaseBranch(args: { explicitBaseBranch: string | undefined - repoWorktreeBaseRef: string | undefined - loadDefaultBaseRef: () => Promise }): Promise { - if (args.explicitBaseBranch) { - return args.explicitBaseBranch - } - const pinnedBaseRef = args.repoWorktreeBaseRef?.trim() - if (pinnedBaseRef) { - return pinnedBaseRef - } - const defaultBaseRef = (await args.loadDefaultBaseRef())?.trim() - return defaultBaseRef || undefined + return args.explicitBaseBranch?.trim() || undefined } From 863d94167c84cd403d423b86f9e7c9471214aca0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:19:28 -0700 Subject: [PATCH 108/141] Use activeWorkspaceKey to reveal active workspace in sidebar (#7406) Older folder-based workspaces are tracked by `activeWorkspaceKey` rather than the legacy `activeWorktreeId`. Deriving the active sidebar workspace ID from the workspace key enables the "Reveal active workspace" action to work correctly for both types of workspaces. --- .../src/components/sidebar/WorktreeList.tsx | 34 ++- tests/e2e/worktree-scroll-to-current.spec.ts | 264 +++++++++++++++++- 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2320889a3e3..dbf77ecf0f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -268,7 +268,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -405,6 +405,22 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } +// Why: folder workspaces are tracked by the scoped active key, while older +// worktree-only paths still read activeWorktreeId. +function getActiveSidebarWorkspaceId( + activeWorkspaceKey: string | null, + activeWorktreeId: string | null +): string | null { + const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null + if (scope?.type === 'folder') { + return folderWorkspaceKey(scope.folderWorkspaceId) + } + if (scope?.type === 'worktree') { + return scope.worktreeId + } + return activeWorktreeId +} + function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] @@ -5151,7 +5167,11 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const currentSidebarWorktreeId = activeWorktreeId + const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const currentSidebarWorktreeId = useMemo( + () => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId), + [activeWorkspaceKey, activeWorktreeId] + ) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) @@ -6642,31 +6662,31 @@ const WorktreeList = React.memo(function WorktreeList({ }) return } - if (!activeWorktreeId) { + if (!currentSidebarWorktreeId) { return } const activeWorktree = getKnownSidebarWorktreeById( - activeWorktreeId, + currentSidebarWorktreeId, worktreeMap, folderWorkspaces ) if (!activeWorktree || activeWorktree.isArchived) { return } - if (!renderedWorktreeIds.includes(activeWorktreeId)) { + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { // Why: the toolbar action promises to reveal the current workspace; when // sidebar filters hide it, relax those filters before queuing the reveal. clearFilters() } - revealWorktreeInSidebar(activeWorktreeId, { + revealWorktreeInSidebar(currentSidebarWorktreeId, { behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ - activeWorktreeId, clearFilters, + currentSidebarWorktreeId, folderWorkspaces, revealSidebarRow, renderedWorktreeIds, diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 2cbd0df4a08..91ae5e88142 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,11 +1,6 @@ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { worktreeRow } from './worktree-row-locators' - -function worktreeOption(page: Page, worktreeId: string) { - return worktreeRow(page, worktreeId) -} async function prepareSidebarForScrollTest(page: Page): Promise { await page.evaluate(() => { @@ -117,7 +112,9 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) await renderedOptions.last().click() @@ -175,10 +172,31 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRows = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const targetRow = targetRows.first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) - await renderedOptions.last().click() + await orcaPage.evaluate((targetId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const target = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === targetId) + if (!target) { + throw new Error(`Target workspace ${targetId} not found`) + } + store.setState({ + activeRepoId: target.repoId, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null + }) + }, targetId) await expect(targetRow).toHaveAttribute('aria-current', 'page') await orcaPage.evaluate(() => { @@ -190,7 +208,7 @@ test.describe('Reveal active workspace button', () => { }) await expect(renderedOptions).toHaveCount(0) - await expect(orcaPage.getByText('No workspaces found')).toBeVisible() + await expect(targetRows).toHaveCount(0) await revealButton.click() @@ -213,4 +231,232 @@ test.describe('Reveal active workspace button', () => { ) .toEqual([]) }) + + test('reveals the current workspace when it starts outside the virtualized window', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const targetId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] + const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `${repo.id}::/virtual-reveal-${suffix}`, + instanceId: `virtual-reveal-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-reveal-${suffix}`, + displayName: `Virtual reveal ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-reveal-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + const target = syntheticWorktrees.at(-1) + if (!target) { + throw new Error('Expected a synthetic target worktree') + } + + store.setState({ + activeRepoId: repo.id, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null, + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [repo.id]: [...seededWorktrees, ...syntheticWorktrees] + } + }) + return target.id + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const scroller = document.querySelector('[data-worktree-sidebar]') + return scroller?.scrollTop ?? null + }), + { timeout: 10_000, message: 'sidebar scroller did not mount' } + ) + .not.toBeNull() + + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, targetId), + { timeout: 10_000, message: 'target workspace should start outside the sidebar viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + + await revealButton.click() + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() + await expect(targetRow).toBeVisible() + await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + }) + + test('uses the active workspace key when the legacy active worktree id is not set', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const folderWorktreeId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const now = Date.now() + const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `reveal-folder-workspace-${suffix}`, + projectGroupId: 'reveal-folder-group', + name: `Reveal folder workspace ${suffix}`, + folderPath: `/tmp/reveal-folder-workspace-${suffix}`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1_000 - index, + lastActivityAt: now - index, + createdAt: now, + updatedAt: now + } + }) + const targetFolder = folderWorkspaces.at(-1) + if (!targetFolder) { + throw new Error('Expected a target folder workspace') + } + const folderWorktreeId = `folder:${targetFolder.id}` + store.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: folderWorktreeId, + projectGroups: [ + { + id: 'reveal-folder-group', + name: 'Reveal folder group', + parentPath: '/tmp/reveal-folder-group', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: now, + updatedAt: now + } + ], + folderWorkspaces + }) + store.getState().setGroupBy('repo') + return folderWorktreeId + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { timeout: 10_000, message: 'target folder workspace should start outside the viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + await revealButton.click() + + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { + timeout: 10_000, + message: 'Reveal button should scroll to the active folder workspace' + } + ) + .toBe(true) + }) }) From 331a30b4542bf355e3c5ab717239623f8c14d7c0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:50:43 -0700 Subject: [PATCH 109/141] Harden rate-limit PTY working directory and abort background usage probes on stop (#7403) * Harden rate-limit PTY cwd and abort background usage probes on stop Resolve a runaway CPU incident where background Claude rate-limit usage probes could inherit a root working directory (such as `/` or `C:\`) and index the entire disk. - Force hidden Claude and Codex usage PTYs to spawn inside a small, dedicated directory under user data or temp. - Thread AbortSignal through rate-limit fetchers to immediately kill and reap in-flight PTY processes when RateLimitService stops. - Harden daemon and local PTY defaults to reject root-like working directories for automated agent startups. * Consolidate PTY path safety checks to prevent runaway CPU Centralize `isRootLikePath` into a dedicated PTY path safety module to prevent terminal launches and hidden usage probes from executing in filesystem or drive roots, avoiding unbounded file discovery. - Centralize path validation supporting POSIX, Windows, and UNC roots. - Fail loudly if no safe default working directory is available. - Enforce bounded directories for hidden usage PTYs and WSL probes. - Check for aborted signals during rate-limit CLI repairs. * Gate agent startup on the effective PTY cwd after default fallback Co-authored-by: Orca * test(repos): verify origin/HEAD target in SSH base-ref tests Co-authored-by: Orca * Pin HOME to assert exact default PTY cwd in spawn test Pin `process.env.HOME` in the fallback test for LocalPtyProvider to assert the exact resolved candidate rather than just checking that it is non-root-like. This catches potential regressions where an unintended home directory is picked, and uses a try/finally block to safely restore the environment afterwards. --------- Co-authored-by: Orca --- src/main/daemon/pty-subprocess.test.ts | 97 ++++++ src/main/daemon/pty-subprocess.ts | 42 +-- src/main/ipc/repos-remote.test.ts | 12 + src/main/providers/local-pty-provider.test.ts | 47 +++ src/main/providers/local-pty-provider.ts | 29 +- src/main/providers/pty-default-cwd.test.ts | 77 +++++ src/main/providers/pty-default-cwd.ts | 43 +++ src/main/providers/pty-path-safety.ts | 17 + src/main/rate-limits/claude-fetcher.ts | 116 ++++++- src/main/rate-limits/claude-pty.test.ts | 41 +++ src/main/rate-limits/claude-pty.ts | 72 +++- src/main/rate-limits/codex-fetcher.test.ts | 139 ++++++-- src/main/rate-limits/codex-fetcher.ts | 131 ++++++- .../rate-limits/hidden-pty-cleanup.test.ts | 36 +- src/main/rate-limits/hidden-pty-cleanup.ts | 15 + .../rate-limits/hidden-rate-limit-pty-cwd.ts | 37 ++ src/main/rate-limits/service.test.ts | 200 +++++++++-- src/main/rate-limits/service.ts | 321 +++++++++++++----- 18 files changed, 1262 insertions(+), 210 deletions(-) create mode 100644 src/main/providers/pty-default-cwd.test.ts create mode 100644 src/main/providers/pty-default-cwd.ts create mode 100644 src/main/providers/pty-path-safety.ts create mode 100644 src/main/rate-limits/hidden-rate-limit-pty-cwd.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 85366a1377b..fccd92e9d53 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -527,6 +527,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', command: 'codex' }) @@ -1038,6 +1039,97 @@ describe('createPtySubprocess', () => { expect(shellArg.length).toBeGreaterThan(0) }) + it('allows an explicitly requested plain daemon shell at POSIX root', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: '/' }) + ) + }) + + it('rejects daemon automatic agent startup without an explicit cwd', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + command: 'codex' + }) + ).toThrow(/requires a non-root workspace/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects daemon automatic agent startup at POSIX root', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/', + command: 'claude' + }) + ).toThrow(/requires a non-root workspace/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects a missing explicit POSIX cwd before node-pty spawn', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/definitely-missing-orca-cwd' + }) + ).toThrow(/definitely-missing-orca-cwd/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('passes custom env to spawned process', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -1238,6 +1330,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: 'codex', env: { SHELL: '/bin/zsh' } }) @@ -1264,6 +1357,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready', env: { SHELL: '/bin/zsh' } @@ -1291,6 +1385,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: "codex --prefill 'linked issue context'", env: { SHELL: '/bin/zsh' } }) @@ -1560,6 +1655,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', shellOverride: 'powershell.exe', command: "& 'codex' '--no-alt-screen'" }) @@ -1589,6 +1685,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', shellOverride: 'cmd.exe', command: `codex ${'x'.repeat(7000)}` }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6dd0554bb87..35e92157ba4 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -54,6 +54,7 @@ import { import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' +import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -93,20 +94,7 @@ export type PtySubprocessOptions = { * Returns a stable default working directory for daemon-spawned PTYs. */ function getDefaultCwd(): string { - if (process.platform !== 'win32') { - return process.env.HOME || '/' - } - - // Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with - // HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs - // still start in a valid absolute home directory. - if (process.env.USERPROFILE) { - return process.env.USERPROFILE - } - if (process.env.HOMEDRIVE && process.env.HOMEPATH) { - return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` - } - return 'C:\\' + return resolveSafePtyDefaultCwd() } /** @@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string { * Moves the daemon process to a stable cwd after its original cwd disappears. */ function repairDaemonCwd(): string | null { - const candidates = [ - process.env.ORCA_USER_DATA_PATH, - getDefaultCwd(), - process.platform === 'win32' ? 'C:\\' : '/' - ] + const candidates = [process.env.ORCA_USER_DATA_PATH] + try { + candidates.push(getDefaultCwd()) + } catch { + // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. + } + candidates.push(process.platform === 'win32' ? 'C:\\' : '/') for (const candidate of candidates) { if (isExistingDirectory(candidate)) { try { @@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: { validateWorkingDirectory(args.validationCwd) } +/** + * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT. + */ +function preflightPosixPtySpawnEnvironment(validationCwd: string): void { + if (process.platform === 'win32') { + return + } + validateWorkingDirectory(validationCwd) +} + /** * Wraps native PTY spawn failures with shell and cwd context. */ @@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + if (opts.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(opts.cwd, opts.command) + } const requestedCwd = opts.cwd || getDefaultCwd() let spawnCwd = requestedCwd let validationCwd = spawnCwd @@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // runs in a separate forked process with its own code path. ensureNodePtySpawnHelperExecutable() preflightUnixPtySpawnEnvironment() + preflightPosixPtySpawnEnvironment(validationCwd) preflightWindowsPtySpawnEnvironment({ validationCwd, cwdWasExplicit: opts.cwd !== undefined diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 0b29082bf1e..2a9a66df94d 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2441,6 +2441,12 @@ describe('repos:getBaseRefDefault envelope', () => { match: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, + // The origin/HEAD target is verified before it is trusted, so the + // symbolic-ref result must also resolve via rev-parse. + { + match: isRevParseFor('refs/remotes/origin/main'), + respond: () => Promise.resolve({ stdout: '', stderr: '' }) + }, { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) @@ -2471,6 +2477,12 @@ describe('repos:getBaseRefDefault envelope', () => { match: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, + // The origin/HEAD target is verified before it is trusted, so the + // symbolic-ref result must also resolve via rev-parse. + { + match: isRevParseFor('refs/remotes/origin/main'), + respond: () => Promise.resolve({ stdout: '', stderr: '' }) + }, { match: isRemoteList, respond: () => Promise.reject(new Error('relay exec failed')) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index d37faa0ac57..3fc96c897bf 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -82,6 +82,7 @@ vi.mock('../wsl', () => ({ })) import { LocalPtyProvider } from './local-pty-provider' +import { isRootLikePath } from './pty-path-safety' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' describe('LocalPtyProvider', () => { @@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => { ) }) + it('allows an explicitly requested plain shell at POSIX root', async () => { + await provider.spawn({ cols: 80, rows: 24, cwd: '/' }) + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: '/' }) + ) + }) + + it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => { + spawnMock.mockClear() + const origHome = process.env.HOME + // Pin HOME so we assert the exact resolved candidate, not just non-root-ness — + // catches regressions where resolveSafePtyDefaultCwd picks an unintended home. + process.env.HOME = '/home/testuser' + + try { + // Why: an omitted cwd resolves to a guaranteed-safe default home (the + // guard only rejects root-like paths), so the agent must still launch. + await expect( + provider.spawn({ cols: 80, rows: 24, command: 'codex' }) + ).resolves.toBeDefined() + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].cwd).toBe('/home/testuser') + expect(isRootLikePath(spawnCall[2].cwd)).toBe(false) + } finally { + if (origHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = origHome + } + } + }) + + it('rejects automatic agent startup at POSIX root', async () => { + spawnMock.mockClear() + + await expect( + provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' }) + ).rejects.toThrow(/requires a non-root workspace/) + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('invokes onSpawned callback', async () => { const onSpawned = vi.fn() provider.configure({ onSpawned }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7e8588a6b64..0d2eb19f1a6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -88,20 +89,7 @@ const exitListeners = new Set() * Returns a stable default cwd for locally spawned PTYs. */ function getDefaultCwd(): string { - if (process.platform !== 'win32') { - return process.env.HOME || '/' - } - - // Why: USERPROFILE is not guaranteed in all Windows launch contexts. - // Falling back to bare HOMEPATH yields a drive-relative path, so combine - // HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home. - if (process.env.USERPROFILE) { - return process.env.USERPROFILE - } - if (process.env.HOMEDRIVE && process.env.HOMEPATH) { - return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` - } - return 'C:\\' + return resolveSafePtyDefaultCwd() } /** @@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider { } const id = allocatePtyId(reattachId ?? undefined) + const startupAgentRecognition = args.command + ? recognizeAgentProcessFromCommandLine(args.command) + : null + const defaultCwd = getDefaultCwd() const cwd = args.cwd || defaultCwd + // Why: gate on the effective cwd (post default-cwd fallback), not the raw + // args.cwd — an omitted cwd resolves to a safe default and must not be + // rejected as if it were a root-like path. + if (args.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(cwd, args.command) + } const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null const worktreeWslContext = process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined @@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider { finalEnv.ORCA_OMP_STATUS_EXTENSION || finalEnv.ORCA_CODEX_HOME || finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR - const isCodexStartupCommand = - recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex' + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' let shellLaunch: ReturnType | null = null if (args.command && isCodexStartupCommand) { const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ diff --git a/src/main/providers/pty-default-cwd.test.ts b/src/main/providers/pty-default-cwd.test.ts new file mode 100644 index 00000000000..2bb0ef3a79a --- /dev/null +++ b/src/main/providers/pty-default-cwd.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn() +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +import { + assertSafeAgentStartupCwd, + isSafeImplicitPtyCwd, + resolveSafePtyDefaultCwd +} from './pty-default-cwd' + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +describe('pty default cwd safety', () => { + const originalPlatform = process.platform + + beforeEach(() => { + setPlatform(originalPlatform) + homedirMock.mockReset() + homedirMock.mockReturnValue('/home/orca') + }) + + it('rejects root-like implicit cwd values', () => { + expect(isSafeImplicitPtyCwd(undefined)).toBe(false) + expect(isSafeImplicitPtyCwd('')).toBe(false) + expect(isSafeImplicitPtyCwd('/')).toBe(false) + expect(isSafeImplicitPtyCwd('C:\\')).toBe(false) + expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false) + }) + + it('uses a non-root POSIX home and skips root env values', () => { + setPlatform('linux') + + expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice') + expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca') + }) + + it('throws instead of falling back to POSIX root', () => { + setPlatform('darwin') + homedirMock.mockReturnValue('/') + + expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow( + /No safe default working directory/ + ) + }) + + it('uses Windows home candidates without accepting drive roots', () => { + setPlatform('win32') + homedirMock.mockReturnValue('C:\\Users\\os-home') + + expect( + resolveSafePtyDefaultCwd({ + USERPROFILE: 'C:\\', + HOMEDRIVE: 'D:', + HOMEPATH: '\\Users\\alice' + }) + ).toBe('D:\\Users\\alice') + }) + + it('requires automatic agent startup commands to provide a non-root cwd', () => { + expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow( + /requires a non-root workspace/ + ) + expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/) + expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow() + }) +}) diff --git a/src/main/providers/pty-default-cwd.ts b/src/main/providers/pty-default-cwd.ts new file mode 100644 index 00000000000..e9a7b7a5a3b --- /dev/null +++ b/src/main/providers/pty-default-cwd.ts @@ -0,0 +1,43 @@ +import { homedir } from 'node:os' + +import { isRootLikePath } from './pty-path-safety' + +function homeDrivePath(env: NodeJS.ProcessEnv): string | null { + if (!env.HOMEDRIVE || !env.HOMEPATH) { + return null + } + return `${env.HOMEDRIVE}${env.HOMEPATH}` +} + +export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string { + return !isRootLikePath(path) +} + +export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string { + const candidates = + process.platform === 'win32' + ? [env.USERPROFILE, homeDrivePath(env), homedir()] + : [env.HOME, homedir()] + const selected = candidates.find(isSafeImplicitPtyCwd) + // Why: silently falling back to "/" or a drive root is the exact runaway-CPU + // bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud. + if (!selected) { + // Log the rejected candidates so the rare "no safe home at all" environment + // (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque. + console.error( + '[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.', + { platform: process.platform, candidates } + ) + throw new Error('No safe default working directory is available for terminal launch.') + } + return selected +} + +export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void { + if (isSafeImplicitPtyCwd(cwd)) { + return + } + throw new Error( + `Automatic agent startup command "${command}" requires a non-root workspace working directory.` + ) +} diff --git a/src/main/providers/pty-path-safety.ts b/src/main/providers/pty-path-safety.ts new file mode 100644 index 00000000000..71360e14ce1 --- /dev/null +++ b/src/main/providers/pty-path-safety.ts @@ -0,0 +1,17 @@ +import { posix, win32 } from 'node:path' + +// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a +// security boundary here — an empty, "/", drive-root, or network-share-root cwd +// triggers the unbounded file discovery this guard exists to prevent. Keep the +// detection in one place so a fix to one enforcement path can't skip the other. +export function isRootLikePath(path: string | null | undefined): boolean { + const trimmed = path?.trim() ?? '' + if (!trimmed) { + return true + } + // A filesystem root is its own parent. Check both POSIX and Windows semantics + // so a Windows drive/UNC root is still rejected when running on POSIX (and vice + // versa) — path.dirname alone only understands the host platform's separators. + const normalized = posix.normalize(trimmed) + return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e302c90b6d1..d4dbad55fd8 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = { attemptedSources: UsageRateLimitSource[] } +function abortedClaudeRateLimitResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + function parseResetTimestamp(value: string | number | undefined): number | null { if (typeof value === 'number') { if (!Number.isFinite(value)) { @@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null ) } -async function fetchViaOAuth(token: string): Promise { +async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } await ensureProxyFromEnv() + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + const abortFromExternalSignal = (): void => { + controller.abort() + } + signal?.addEventListener('abort', abortFromExternalSignal, { once: true }) try { // Why: net.fetch uses Chromium's networking stack which respects OS proxy @@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise { } const data = (await res.json()) as OAuthUsageResponse + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } return { provider: 'claude', @@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise { error: null, status: 'ok' } + } catch (err) { + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } + throw err } finally { clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromExternalSignal) } } @@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: { oauthCredentials: OAuthCredentialReadResult attempts: ClaudeUsageAttemptState networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { recordAttempt(input.attempts, 'cli') const limits = await fetchViaPty({ authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) return withClaudeUsageMetadata( limits, @@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: { attempts: ClaudeUsageAttemptState allowUsagePanelSupplement: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { - if (!canSupplementOAuthUsageFromCli(input)) { + if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) { return input.oauthLimits } try { @@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: { authPreparation: input.authPreparation, oauthCredentials: input.oauthCredentials, attempts: input.attempts, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) return mergeClaudeUsageWindows(input.oauthLimits, cliLimits) } catch (err) { @@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: { attempts: ClaudeUsageAttemptState oauthCredentials: OAuthCredentialReadResult }): Promise { + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } let cliResult: ProviderRateLimits | null = null try { cliResult = await fetchClaudeUsageViaCli({ authPreparation: input.options?.authPreparation, oauthCredentials: input.oauthCredentials, attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings + networkProxySettings: input.options?.networkProxySettings, + signal: input.options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err) } + // Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair. + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + const refreshedCredentials = await readOAuthCredentials( resolveOAuthCredentialReadOptions(input.options?.authPreparation) ) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (refreshedCredentials.token) { recordAttempt(input.attempts, 'oauth') try { - const oauthRetry = await fetchViaOAuth(refreshedCredentials.token) + const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult) return withClaudeUsageMetadata( supplemented, @@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = { allowPtyFallback?: boolean allowUsagePanelSupplement?: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal } export type FetchManagedAccountUsageOptions = { allowUsagePanelSupplement?: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal } export async function fetchClaudeRateLimits( options?: FetchClaudeRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const attempts: ClaudeUsageAttemptState = { attemptedSources: [] } const allowCliFallback = options?.allowPtyFallback !== false const plan = resolveClaudeUsageRefreshPlan({ @@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits( const oauthCredentials = await readOAuthCredentials( resolveOAuthCredentialReadOptions(options?.authPreparation) ) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { recordAttempt(attempts, 'oauth') try { - const oauthLimits = await fetchViaOAuth(oauthCredentials.token) + const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const limits = await supplementOAuthUsageFromCli({ oauthLimits, authPreparation: options?.authPreparation, @@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits( attempts, networkProxySettings: options?.networkProxySettings, allowUsagePanelSupplement: - options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation) + options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation), + signal: options?.signal }) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } return withClaudeUsageMetadata( limits, metadataForAttempt({ @@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (ptyError) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) @@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) @@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) @@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: { credentialsJson: string oauthLimits: ProviderRateLimits networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { + if (input.signal?.aborted) { + return null + } const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) if (!authPreparation) { return null @@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: { return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => { const cliLimits = await fetchViaPty({ authPreparation, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) + if (input.signal?.aborted) { + return null + } if ( !canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, { requireMatchingOAuthWindow: input.location.kind === 'keychain' @@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage( account: InactiveClaudeAccountInfo, options: FetchManagedAccountUsageOptions = {} ): Promise { + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const location = resolveManagedCredentialsLocation(account) let credentialsJson = location ? await readManagedCredentialsJson(location) : null + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (!location || !credentialsJson) { return { provider: 'claude', @@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage( let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token if (isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (refreshed) { try { await writeManagedCredentialsJson(location, refreshed) @@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage( // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY // path is used only as a supplement after OAuth succeeds, and it points // directly at the managed account's isolated config so selection is unchanged. - const oauthLimits = await fetchViaOAuth(token) + const oauthLimits = await fetchViaOAuth(token, options.signal) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if ( !canSupplementOAuthUsageFromCli({ oauthLimits, @@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage( location, credentialsJson, oauthLimits, - networkProxySettings: options.networkProxySettings + networkProxySettings: options.networkProxySettings, + signal: options.signal }) return mergeClaudeUsageWindows(oauthLimits, cliLimits) } catch (err) { diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index f6f4d22e6a0..f2fd976821b 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({ })) import { fetchViaPty } from './claude-pty' +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' function makeDisposable() { return { dispose: vi.fn() } @@ -97,6 +98,22 @@ describe('fetchViaPty', () => { await resultPromise }) + it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + + const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) + + term.emitExit() + await resultPromise + }) + it('leaves the spawn env proxy untouched when no proxy is configured', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) @@ -135,6 +152,8 @@ describe('fetchViaPty', () => { const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] expect(spawnFile).toBe('wsl.exe') const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") expect(bashCommand).toContain('exec claude') @@ -142,6 +161,28 @@ describe('fetchViaPty', () => { await resultPromise }) + it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + const controller = new AbortController() + const killMock = term.kill + + const resultPromise = fetchViaPty({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(killMock).toHaveBeenCalledTimes(1) + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + it('clears the startup delay timer when the hidden PTY exits early', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f2000489275..88301b9c9dd 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' +import { + getHiddenRateLimitWslCwdSetupCommands, + resolveHiddenRateLimitPtyCwd +} from './hidden-rate-limit-pty-cwd' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string { return 'Claude usage is unavailable right now.' } +function abortedClaudeUsageResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + export async function fetchViaPty(options?: { authPreparation?: ClaudeRuntimeAuthPreparation networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } return new Promise((resolve) => { let output = '' @@ -251,10 +273,13 @@ export async function fetchViaPty(options?: { // Why: Windows-side env does not cross into the distro without WSLENV, // so export the configured proxy inside the command for the inner claude. [ + // Why: hidden usage probes must not inherit a root-like WSL cwd; + // keep Claude discovery bounded to a tiny temp directory. + ...getHiddenRateLimitWslCwdSetupCommands(), `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`, ...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`), 'exec claude' - ].join('; ') + ].join(' && ') ] : isWin32 ? ['/c', `"${claudeCommand}"`] @@ -264,10 +289,14 @@ export async function fetchViaPty(options?: { name: 'xterm-256color', cols: 120, rows: 40, + // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a + // drive root), which can trigger unbounded file discovery. + cwd: resolveHiddenRateLimitPtyCwd(), env: spawnEnv }) - const termDisposables: { dispose: () => void }[] = [] + const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] let enterInterval: ReturnType | null = null + let timeout: ReturnType | null = null function clearFollowupTimers(): void { if (startupDelayTimer) { @@ -288,7 +317,32 @@ export async function fetchViaPty(options?: { } } - const timeout = setTimeout(() => { + function settleAborted(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + clearFollowupTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve(abortedClaudeUsageResult()) + } + + if (options?.signal) { + if (options.signal.aborted) { + settleAborted() + return + } + options.signal.addEventListener('abort', settleAborted, { once: true }) + termDisposables.push({ + dispose: () => options.signal?.removeEventListener('abort', settleAborted) + }) + } + + timeout = setTimeout(() => { if (!resolved) { resolved = true clearFollowupTimers() @@ -342,7 +396,10 @@ export async function fetchViaPty(options?: { return } resolved = true - clearTimeout(timeout) + if (timeout) { + clearTimeout(timeout) + timeout = null + } clearFollowupTimers() cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) @@ -443,7 +500,10 @@ export async function fetchViaPty(options?: { clearFollowupTimers() if (!resolved) { resolved = true - clearTimeout(timeout) + if (timeout) { + clearTimeout(timeout) + timeout = null + } const clean = stripTerminalControlSequences(output) const { session, weekly, fableWeekly } = parsePtyUsage(clean) resolve({ diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 4c7221e9e96..daaec0ce7fd 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' import { codexAuthExists } from './codex-auth-presence' +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' function makeDisposable() { return { dispose: vi.fn() } @@ -52,6 +53,25 @@ function makeRpcChild() { return child } +function makePtyTerm() { + let dataHandler: ((data: string) => void) | null = null + let exitHandler: (() => void) | null = null + return { + onData: vi.fn((callback: (data: string) => void) => { + dataHandler = callback + return makeDisposable() + }), + onExit: vi.fn((callback: () => void) => { + exitHandler = callback + return makeDisposable() + }), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data), + emitExit: () => exitHandler?.() + } +} + describe('fetchCodexRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => { ) }) + it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + + const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) + + rpcChild.emit('close') + await resultPromise + }) + + it('spawns the PTY fallback in a bounded non-root cwd', async () => { + const term = makePtyTerm() + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue(term) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) + + term.emitExit() + await resultPromise + }) + + it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + const controller = new AbortController() + + const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(rpcChild.kill).toHaveBeenCalledTimes(1) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + + it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => { + const term = makePtyTerm() + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue(term) + const controller = new AbortController() + const killMock = term.kill + + const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(killMock).toHaveBeenCalledTimes(1) + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + it('falls back to the PTY status reader when RPC exits before returning usage', async () => { const rpcChild = makeRpcChild() const ptyHandlers: { onData?: (data: string) => void } = {} @@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => { await vi.advanceTimersByTimeAsync(1) await resultPromise - expect(childSpawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-lc', - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'" - ], + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('wsl.exe') + const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') + expect(bashCommand).toContain( + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + ) + expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(spawnOptions).toEqual( expect.objectContaining({ + cwd: expect.stringContaining('rate-limit-pty-cwd'), env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) }) ) @@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => { rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) - expect(ptySpawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-lc', - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex " - ], + const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] + expect(spawnFile).toBe('wsl.exe') + const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') + expect(bashCommand).toContain( + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + ) + expect(bashCommand).toContain('exec codex ') + expect(spawnOptions).toEqual( expect.objectContaining({ + cwd: expect.stringContaining('rate-limit-pty-cwd'), env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) }) ) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 439c5438d2f..8ffbd9c8d95 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + getHiddenRateLimitWslCwdSetupCommands, + resolveHiddenRateLimitPtyCwd +} from './hidden-rate-limit-pty-cwd' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null allowPtyFallback?: boolean + signal?: AbortSignal } // --------------------------------------------------------------------------- @@ -117,9 +122,10 @@ function buildWslCodexCommand( return null } const script = [ + ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, `exec codex ${args.map(shellQuote).join(' ')}` - ].join('; ') + ].join(' && ') return { command: 'wsl.exe', args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] @@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string { return status?.toLowerCase() ?? 'unknown' } +function abortedCodexRateLimitResult(): ProviderRateLimits { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + function getNextAvailableCreditExpiry( credits: RateLimitResetCredits['credits'] | undefined ): number | null { @@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits( async function getCodexBackendAuthHeaders( options?: FetchCodexRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return null + } const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json') const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile const accessToken = auth.tokens?.access_token @@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders( async function fetchBackendRateLimitResetCredits( options?: FetchCodexRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return null + } const auth = await getCodexBackendAuthHeaders(options) if (!auth) { return null } + if (options?.signal?.aborted) { + return null + } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. - const response = await fetch( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', - auth - ) + const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { + ...auth, + signal: options?.signal + }) if (!response.ok) { return null } @@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits( options?: FetchCodexRateLimitsOptions ): Promise { if ( + options?.signal?.aborted || limits.provider !== 'codex' || (limits.rateLimitResetCredits?.nextExpiresAt !== undefined && limits.rateLimitResetCredits.nextExpiresAt !== null) @@ -373,6 +400,9 @@ function mapRpcWindow( // --------------------------------------------------------------------------- async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } return new Promise((resolve) => { let buffer = '' let stderr = '' @@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { settle( { @@ -632,7 +676,13 @@ function parsePtyStatus(output: string): { } async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null const codexCommand = wslCodex ? 'codex' : resolveCodexCommand() @@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise | null = null + let timeout: ReturnType | null = null const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', cols: 120, rows: 40, + cwd: resolveHiddenRateLimitPtyCwd(), env: { ...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env), TERM: 'xterm-256color', ...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {}) } }) - const termDisposables: { dispose: () => void }[] = [] + const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - const timeout = setTimeout(() => { + function settleAborted(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve(abortedCodexRateLimitResult()) + } + + if (options?.signal) { + if (options.signal.aborted) { + settleAborted() + return + } + options.signal.addEventListener('abort', settleAborted, { once: true }) + termDisposables.push({ + dispose: () => options.signal?.removeEventListener('abort', settleAborted) + }) + } + + timeout = setTimeout(() => { if (!resolved) { resolved = true if (settleTimer) { @@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } // Why: never spawn the `codex` binary unless the user has signed in. Without // auth the RPC/PTY paths can only error, and spawning them shows up as an // unexpected background Codex process for users who don't use Codex. @@ -790,8 +879,12 @@ export async function fetchCodexRateLimits( // Path A: try RPC first try { const rpcResult = await fetchViaRpc(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') { - return await withBackendRateLimitResetCredits(rpcResult, options) + const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits } if (isCodexAuthError(rpcResult.error)) { return rpcResult @@ -802,6 +895,9 @@ export async function fetchCodexRateLimits( // Why: app-server can fail independently of the interactive CLI. Keep the // status-bar useful by trying the older /status PTY reader on RPC errors. } catch { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } if (options?.allowPtyFallback === false) { return { provider: 'codex', @@ -817,8 +913,19 @@ export async function fetchCodexRateLimits( // Path B: PTY fallback try { - return await withBackendRateLimitResetCredits(await fetchViaPty(options), options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + const ptyResult = await fetchViaPty(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits } catch (err) { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const message = err instanceof Error ? err.message : 'Unknown error' const isNotInstalled = message.includes('ENOENT') return { diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index 094931a97b0..43e8453fa0f 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { + cleanupHiddenRateLimitPty, + getActiveHiddenRateLimitPtyCount, + registerHiddenRateLimitPty +} from './hidden-pty-cleanup' function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => { expect(term.kill).toHaveBeenCalledTimes(1) expect(term.destroy).toHaveBeenCalledTimes(1) }) + + it('removes registered hidden PTYs when cleanup kills them', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: true }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + + it('removes registered hidden PTYs after natural exit cleanup', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: false }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 6d85421203c..068b937ee84 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -7,6 +7,21 @@ type Disposable = { dispose: () => void } +const activeHiddenRateLimitPtys = new Set() + +export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { + activeHiddenRateLimitPtys.add(term) + return { + dispose: () => { + activeHiddenRateLimitPtys.delete(term) + } + } +} + +export function getActiveHiddenRateLimitPtyCount(): number { + return activeHiddenRateLimitPtys.size +} + export function cleanupHiddenRateLimitPty( term: HiddenPty, disposables: Disposable[], diff --git a/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts new file mode 100644 index 00000000000..bb78f041bd5 --- /dev/null +++ b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts @@ -0,0 +1,37 @@ +import { mkdirSync, realpathSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { isRootLikePath } from '../providers/pty-path-safety' + +const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd' +const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd' + +// Why: the hidden usage PTY must run in a bounded, never-root directory so +// Claude's discovery cannot walk a whole filesystem — reject a root-like user +// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md). +function resolveUserDataRoot(userDataPath?: string | null): string { + const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim() + if (root && !isRootLikePath(root)) { + return root + } + return join(tmpdir(), 'orca-rate-limit-pty') +} + +export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string { + const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR) + mkdirSync(cwd, { recursive: true }) + const realCwd = realpathSync(cwd) + if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) { + throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`) + } + return realCwd +} + +export function getHiddenRateLimitWslCwdSetupCommands(): string[] { + return [ + `orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`, + 'mkdir -p "$orca_rate_limit_cwd"', + 'cd "$orca_rate_limit_cwd"' + ] +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 5af9abca04f..1b810859ce7 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -333,6 +333,110 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + it('aborts the active fetch cycle and clears queued refreshes on stop', async () => { + const service = new RateLimitService() + const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {} + + vi.mocked(fetchClaudeRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.claude = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('claude', 'aborted')), + { once: true } + ) + }) + ) + vi.mocked(fetchCodexRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.codex = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('codex', 'aborted')), + { once: true } + ) + }) + ) + + const activeFetch = serviceInternals(service).fetchAll() + await Promise.resolve() + await Promise.resolve() + + const queuedRefresh = service.refresh() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.claude?.aborted).toBe(true) + expect(capturedSignals.codex?.aborted).toBe(true) + + await queuedRefresh + await activeFetch + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + }) + + it('aborts inactive Claude preview fetches on stop', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const capturedSignals: { claude?: AbortSignal } = {} + service.setInactiveClaudeAccountsResolver(() => [account]) + vi.mocked(fetchManagedAccountUsage).mockImplementation( + (_account, options) => + new Promise((resolve) => { + capturedSignals.claude = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('claude', 'aborted')), + { once: true } + ) + }) + ) + + const previewFetch = service.fetchInactiveClaudeAccountsOnOpen() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.claude?.aborted).toBe(true) + + await previewFetch + + expect(service.getState().inactiveClaudeAccounts).toEqual([]) + }) + + it('aborts inactive Codex preview fetches on stop', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + const capturedSignals: { codex?: AbortSignal } = {} + service.setInactiveCodexAccountsResolver(() => [account]) + vi.mocked(fetchCodexRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.codex = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('codex', 'aborted')), + { once: true } + ) + }) + ) + + const previewFetch = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.codex?.aborted).toBe(true) + + await previewFetch + + expect(service.getState().inactiveCodexAccounts).toEqual([]) + }) + it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ @@ -351,11 +455,14 @@ describe('RateLimitService', () => { await service.refresh() expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: undefined, + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) @@ -453,16 +560,19 @@ describe('RateLimitService', () => { await service.refresh() expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/jin/.claude', - stripAuthEnv: true - }), - allowPtyFallback: true, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + stripAuthEnv: true + }), + allowPtyFallback: true, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) @@ -483,11 +593,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ provenance: 'system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { @@ -498,11 +611,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: undefined, + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { @@ -523,11 +639,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not cache host Codex usage under an outgoing WSL account', async () => { @@ -604,10 +723,13 @@ describe('RateLimitService', () => { await service.fetchInactiveCodexAccountsOnOpen() - expect(fetchCodexRateLimits).toHaveBeenCalledWith({ - codexHomePath: wslCodexHome, - allowPtyFallback: false - }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + codexHomePath: wslCodexHome, + allowPtyFallback: false, + signal: expect.any(AbortSignal) + }) + ) expect(service.getState().inactiveCodexAccounts).toEqual([ { accountId: 'account-1', @@ -629,9 +751,13 @@ describe('RateLimitService', () => { await service.fetchInactiveClaudeAccountsOnOpen() - expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, { - allowUsagePanelSupplement: true - }) + expect(fetchManagedAccountUsage).toHaveBeenCalledWith( + account, + expect.objectContaining({ + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not start overlapping inactive Codex preview fetches', async () => { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index e3befa61404..840f4e4a788 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -103,6 +103,7 @@ export class RateLimitService { private fullFetchQueued = false private codexOnlyFetchQueued = false private claudeOnlyFetchQueued = false + private activeFetchAbortControllers = new Set() private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 @@ -221,6 +222,11 @@ export class RateLimitService { } stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() this.stopTimer() this.clearDeferredStartupRefresh() this.detachWindowListeners?.() @@ -369,30 +375,18 @@ export class RateLimitService { return } const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal for (const account of accounts) { this.inactiveClaudeFetching.add(account.id) } this.pushToRenderer() - for (const account of accounts) { - if ( - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() - }) + try { + for (const account of accounts) { if ( + signal.aborted || fetchGeneration !== this.inactiveClaudeAccountsGeneration || !this.isCurrentInactiveClaudeAccount(account.id) ) { @@ -403,24 +397,46 @@ export class RateLimitService { this.pushToRenderer() continue } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one Keychain rejection or - // network error from aborting the remaining accounts in the batch. - if ( - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one Keychain rejection or + // network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - if (fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) } } @@ -439,36 +455,18 @@ export class RateLimitService { // Why: account switching can make a previewed account active while its // RPC-only usage fetch is still in flight; stale results must be ignored. const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal for (const account of accounts) { this.inactiveCodexFetching.add(account.id) } this.pushToRenderer() - for (const account of accounts) { - if ( - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - // Why: fetchCodexRateLimits already accepts codexHomePath, so we can - // point it at the managed account's home directory directly without - // materializing credentials into the shared runtime location. - // Why: opening the account switcher should never start hidden PTYs for - // every inactive account. On Windows that fallback can crash inside - // ConPTY; RPC-only is enough for this non-critical preview surface. - const fresh = await fetchCodexRateLimits({ - codexHomePath: account.managedHomePath, - allowPtyFallback: false - }) + try { + for (const account of accounts) { if ( + signal.aborted || fetchGeneration !== this.inactiveCodexAccountsGeneration || !this.isCurrentInactiveCodexAccount(account.id) ) { @@ -479,23 +477,51 @@ export class RateLimitService { this.pushToRenderer() continue } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) + try { + // Why: fetchCodexRateLimits already accepts codexHomePath, so we can + // point it at the managed account's home directory directly without + // materializing credentials into the shared runtime location. + // Why: opening the account switcher should never start hidden PTYs for + // every inactive account. On Windows that fallback can crash inside + // ConPTY; RPC-only is enough for this non-critical preview surface. + const fresh = await fetchCodexRateLimits({ + codexHomePath: account.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - if (fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) } } @@ -644,8 +670,13 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchAllCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false shouldContinue = true @@ -653,11 +684,21 @@ export class RateLimitService { } if (this.codexOnlyFetchQueued) { this.codexOnlyFetchQueued = false - await this.runFetchCodexOnlyCycle() + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } } if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false - await this.runFetchClaudeOnlyCycle() + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) + if (claudeSignal.aborted) { + break + } } } } finally { @@ -679,11 +720,21 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchCodexOnlyCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false - await this.runFetchAllCycle() + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) + if (fullSignal.aborted) { + break + } continue } if (this.codexOnlyFetchQueued) { @@ -692,7 +743,12 @@ export class RateLimitService { } if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false - await this.runFetchClaudeOnlyCycle() + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) + if (claudeSignal.aborted) { + break + } } } } finally { @@ -714,11 +770,21 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchClaudeOnlyCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false - await this.runFetchAllCycle() + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) + if (fullSignal.aborted) { + break + } continue } if (this.claudeOnlyFetchQueued) { @@ -727,7 +793,12 @@ export class RateLimitService { } if (this.codexOnlyFetchQueued) { this.codexOnlyFetchQueued = false - await this.runFetchCodexOnlyCycle() + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } } } } finally { @@ -769,6 +840,49 @@ export class RateLimitService { } } + private beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + private finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + private async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + private abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + private clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + } + + private resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + private isSameCodexTarget( left: NormalizedCodexAccountSelectionTarget, right: NormalizedCodexAccountSelectionTarget @@ -850,9 +964,15 @@ export class RateLimitService { return { ...current, status: 'fetching' } } - private async runFetchAllCycle(): Promise { + private async runFetchAllCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const claudeTarget = this.claudeFetchTarget const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const codexTarget = this.codexFetchTarget @@ -898,18 +1018,24 @@ export class RateLimitService { authPreparation: claudeAuthPreparation, allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() + networkProxySettings: this.networkProxySettingsResolver?.(), + signal }), missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback() + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), fetchKimiRateLimits() ]) + if (signal.aborted) { + return + } + const claude = claudeResult.status === 'fulfilled' ? claudeResult.value @@ -979,6 +1105,9 @@ export class RateLimitService { const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = @@ -1013,7 +1142,10 @@ export class RateLimitService { this.lastFetchAt = Date.now() } - private async runFetchCodexOnlyCycle(): Promise { + private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const codexTarget = this.codexFetchTarget const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) @@ -1033,7 +1165,8 @@ export class RateLimitService { ? Promise.resolve(missingWslCodexHome) : fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback() + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal }) ).catch( (err): ProviderRateLimits => ({ @@ -1046,6 +1179,10 @@ export class RateLimitService { }) ) + if (signal.aborted) { + return + } + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = @@ -1059,9 +1196,15 @@ export class RateLimitService { this.lastFetchAt = Date.now() } - private async runFetchClaudeOnlyCycle(): Promise { + private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const claudeTarget = this.claudeFetchTarget const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const previousState = this.state @@ -1075,7 +1218,8 @@ export class RateLimitService { authPreparation: claudeAuthPreparation, allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() + networkProxySettings: this.networkProxySettingsResolver?.(), + signal }).catch( (err): ProviderRateLimits => ({ provider: 'claude', @@ -1087,7 +1231,14 @@ export class RateLimitService { }) ) + if (signal.aborted) { + return + } + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const shouldApplyClaude = claudeGeneration === this.claudeFetchGeneration && From e94c83d164bc448b415e5cc165f9efdd723c28e7 Mon Sep 17 00:00:00 2001 From: Avichal Dwivedi <150160561+AvichalDwivedi2205@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:26:55 +0530 Subject: [PATCH 110/141] fix(ai-vault): make SSH session history host-aware (#7367) * fix(ai-vault): scan sessions by execution host * fix(ai-vault): route history resume by host * test(e2e): cover SSH AI Vault history * Generalize remote session scanning for all AI Vault agents Replace the Codex-only remote SSH session history scanner with a unified scanner supporting all registered agents. This ensures remote transcripts for Claude, Gemini, Devin, Droid, and others are scanned and listed alongside local history. - Propagate host metadata (host ID and platform) to scanned sessions - Scope remote actions by host, disabling local OS path actions on remote session logs - Resolve ambiguous project/worktree matching for overlapping paths by verifying matching host setup IDs - Update tests and E2E specs to validate multi-agent remote scanning --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../remote-session-scanner-codex-index.ts | 51 +++ .../remote-session-scanner-sources.ts | 221 +++++++++++ .../ai-vault/remote-session-scanner-types.ts | 34 ++ .../ai-vault/remote-session-scanner.test.ts | 344 ++++++++++++++++++ src/main/ai-vault/remote-session-scanner.ts | 296 +++++++++++++++ .../ai-vault/session-scanner-accumulator.ts | 15 +- .../ai-vault/session-scanner-agent-parser.ts | 7 +- .../ai-vault/session-scanner-codex-parser.ts | 67 +++- .../ai-vault/session-scanner-devin-parser.ts | 19 +- .../ai-vault/session-scanner-droid-parser.ts | 120 ++++++ .../ai-vault/session-scanner-graph-parsers.ts | 112 ++---- .../session-scanner-primary-parsers.ts | 101 ++++- .../session-scanner-secondary-parsers.ts | 93 ++++- src/main/ai-vault/session-scanner-types.ts | 2 + src/main/ai-vault/session-scanner.ts | 36 +- src/main/ipc/ai-vault.test.ts | 163 +++++++++ src/main/ipc/ai-vault.ts | 174 ++++++++- src/main/ipc/ssh.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 24 ++ .../components/right-sidebar/AiVaultPanel.tsx | 17 +- .../right-sidebar/AiVaultPanelControls.tsx | 62 +++- .../right-sidebar/AiVaultPanelHeader.tsx | 14 +- .../AiVaultSessionActionMenuItems.tsx | 108 ++++++ .../right-sidebar/AiVaultSessionDetails.tsx | 32 +- .../right-sidebar/AiVaultSessionRow.tsx | 111 +----- .../AiVaultSessionVirtualList.tsx | 11 +- .../SessionRowTrailingActions.tsx | 6 +- .../right-sidebar/ai-vault-host-scope.test.ts | 120 ++++++ .../right-sidebar/ai-vault-host-scope.ts | 64 ++++ .../ai-vault-original-pane.test.ts | 1 + .../ai-vault-session-display.test.ts | 1 + .../ai-vault-session-filters.test.ts | 1 + .../ai-vault-session-launch-actions.ts | 26 +- .../ai-vault-session-path-actions.test.ts | 14 + .../ai-vault-session-path-actions.ts | 13 + .../ai-vault-session-projects.test.ts | 30 +- .../ai-vault-session-projects.ts | 57 ++- .../ai-vault-session-refresh.test.ts | 80 +++- .../right-sidebar/ai-vault-session-refresh.ts | 156 ++++---- .../right-sidebar/ai-vault-session-resume.ts | 21 +- .../ai-vault-session-worktree.test.ts | 65 +++- .../ai-vault-session-worktree.ts | 33 +- .../tab-group/AiVaultSessionDropLayer.tsx | 13 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../src/lib/ai-vault-resume-command.test.ts | 107 ++++++ .../src/lib/ai-vault-resume-command.ts | 23 +- .../src/lib/ai-vault-resume-target.test.ts | 74 ++++ .../src/lib/ai-vault-resume-target.ts | 82 +++++ src/renderer/src/lib/ai-vault-session-drag.ts | 16 +- src/shared/ai-vault-types.ts | 5 + .../e2e/ssh-ai-vault-session-history.spec.ts | 293 +++++++++++++++ 55 files changed, 3197 insertions(+), 403 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-codex-index.ts create mode 100644 src/main/ai-vault/remote-session-scanner-sources.ts create mode 100644 src/main/ai-vault/remote-session-scanner-types.ts create mode 100644 src/main/ai-vault/remote-session-scanner.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner.ts create mode 100644 src/main/ai-vault/session-scanner-droid-parser.ts create mode 100644 src/main/ipc/ai-vault.test.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts create mode 100644 tests/e2e/ssh-ai-vault-session-history.spec.ts diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts new file mode 100644 index 00000000000..d6a305ba4cf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -0,0 +1,51 @@ +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +export async function remoteCodexIndexTitles(args: { + provider: IFilesystemProvider + codexHome: string + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +}): Promise> { + const cached = args.titleCaches.get(args.codexHome) + if (cached) { + return cached + } + const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) + args.titleCaches.set(args.codexHome, pending) + return pending +} + +async function readRemoteCodexIndexTitles( + provider: IFilesystemProvider, + codexHome: string, + hostPlatform: RemoteHostPlatform +): Promise> { + const titleBySessionId = new Map() + try { + const { content, isBinary } = await provider.readFile( + joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) + ) + if (isBinary) { + return titleBySessionId + } + for (const line of content.split(/\r?\n/)) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex indexes are opportunistic; raw transcripts remain sufficient. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts new file mode 100644 index 00000000000..f232b0adf81 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -0,0 +1,221 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseCodexSessionContent } from './session-scanner-codex-parser' +import { parseDevinSessionContent } from './session-scanner-devin-parser' +import { parseDroidSessionContent } from './session-scanner-droid-parser' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + parseClaudeSessionContent, + parseGeminiSessionContent +} from './session-scanner-primary-parsers' +import { + parseCopilotSessionContent, + parseCursorSessionContent, + parseHermesSessionContent +} from './session-scanner-secondary-parsers' +import type { FileWithMtime } from './session-scanner-types' +import { normalizePiSessionsDir } from './session-scanner-values' +import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import type { + RemoteParserOptions, + RemoteScannerContext, + RemoteSessionSource +} from './remote-session-scanner-types' + +type RemoteContentParser = ( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +) => Promise | AiVaultSession | null + +export function remoteSessionSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + ...remoteCodexSources(remoteHome, hostPlatform), + jsonlSource( + 'claude', + remoteHome, + hostPlatform, + ['.claude', 'projects'], + parseClaudeSessionContent + ), + source( + 'gemini', + remoteHome, + hostPlatform, + ['.gemini', 'tmp'], + ['.json', '.jsonl'], + parseGeminiSessionContent + ), + jsonlSource( + 'copilot', + remoteHome, + hostPlatform, + ['.copilot', 'session-state'], + parseCopilotSessionContent + ), + jsonlSource( + 'cursor', + remoteHome, + hostPlatform, + ['.cursor', 'projects'], + parseCursorSessionContent, + (path) => remotePathSegments(path).includes('agent-transcripts') + ), + source( + 'hermes', + remoteHome, + hostPlatform, + ['.hermes', 'sessions'], + ['.json'], + parseHermesSessionContent + ), + source( + 'devin', + remoteHome, + hostPlatform, + ['.local', 'share', 'devin', 'cli', 'transcripts'], + ['.json'], + parseDevinSessionContent + ), + jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'sessions'], + parseDroidSessionContent + ), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'projects'], + parseDroidSessionContent + ), + ...remoteOpenClawSources(remoteHome, hostPlatform) + ] +} + +function source( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + extensions: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return { + agent, + rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), + extensions, + filePredicate, + parse: (file, content, context) => + Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) + } +} + +function jsonlSource( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate) +} + +function remoteCodexSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + joinRemotePath(hostPlatform, remoteHome, '.codex'), + joinRemotePath( + hostPlatform, + remoteHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + ].map((codexHome) => ({ + agent: 'codex', + rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + extensions: ['.jsonl'], + parse: (file, content, context) => + parseCodexSessionContent({ + file, + content, + platform: context.hostPlatform.os, + codexHome, + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os, + readIndexedTitle: async (sessionId) => + ( + await remoteCodexIndexTitles({ + provider: context.provider, + codexHome, + hostPlatform, + titleCaches: context.titleCaches + }) + ).get(sessionId) ?? null + }) + })) +} + +function remoteOpenClawSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return ['.openclaw', '.clawdbot'].map((rootName) => + jsonlSource( + 'openclaw', + remoteHome, + hostPlatform, + [rootName, 'agents'], + openClawParser, + (path) => remotePathSegments(path).includes('sessions') + ) + ) +} + +function parserOptions(context: RemoteScannerContext): RemoteParserOptions { + return { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + } +} + +function piParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('pi', file, content, platform, options) +} + +function openClawParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('openclaw', file, content, platform, options) +} + +function remotePathSegments(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean) +} + +function remotePiSessionsSegments(): string[] { + return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts new file mode 100644 index 00000000000..d5a4b2f1acf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -0,0 +1,34 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { FileWithMtime } from './session-scanner-types' + +export type RemoteScannerContext = { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +} + +export type RemoteParserOptions = { + executionHostId: ExecutionHostId + executionHostPlatform: NodeJS.Platform +} + +export type RemoteSessionSource = { + agent: AiVaultAgent + rootDir: string + extensions: readonly string[] + filePredicate?: (path: string) => boolean + parse: ( + file: FileWithMtime, + content: string, + context: RemoteScannerContext + ) => Promise +} + +export type RemoteSessionCandidate = { + source: RemoteSessionSource + file: FileWithMtime +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts new file mode 100644 index 00000000000..16173f7b736 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from 'vitest' +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' + +class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('scanRemoteAiVaultSessions', () => { + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/session_index.jsonl', + jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]), + 1 + ) + provider.addFile( + '/home/ada/.codex/sessions/2026/07/04/default.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T01:00:00.000Z', + type: 'session_meta', + payload: { id: 'default-session', cwd: '/home/ada/repo' } + }, + { + timestamp: '2026-07-04T01:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Fallback default title' }] + } + } + ]), + 10 + ) + provider.addFile( + '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T02:00:00.000Z', + type: 'session_meta', + payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' } + }, + { + timestamp: '2026-07-04T02:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed remote title' }] + } + } + ]), + 20 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.title)).toEqual([ + 'Managed remote title', + 'Indexed remote title' + ]) + expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2) + expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true) + expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true) + expect( + result.sessions.find((session) => session.sessionId === 'default-session') + ).toMatchObject({ + codexHome: '/home/ada/.codex', + resumeCommand: + "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'" + }) + expect( + result.sessions.find((session) => session.sessionId === 'runtime-session') + ).toMatchObject({ + codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home', + resumeCommand: + "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'" + }) + }) + + it('parses non-Codex transcripts through the same remote scanner', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.claude/projects/repo/claude-session.jsonl', + jsonLines([ + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:00.000Z', + type: 'user', + message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] } + }, + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:01.000Z', + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Sure.' } + } + ]), + 40 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'claude', + sessionId: 'claude-session', + title: 'Summarize the remote branch', + model: 'claude-opus-4', + filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl' + }) + }) + + it('builds resume commands with the remote host platform', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + 'C:/Users/Ada/.codex/sessions/win.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T03:00:00.000Z', + type: 'session_meta', + payload: { id: 'win-session', cwd: 'C:/repo/app' } + }, + { + timestamp: '2026-07-04T03:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Windows remote title' }] + } + } + ]), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]?.executionHostPlatform).toBe('win32') + expect(result.sessions[0]?.resumeCommand).toBe( + 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""' + ) + }) + + it('continues past skipped candidates to fill the remote scan limit', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/worker.jsonl', + codexTranscript({ + sessionId: 'worker-session', + title: 'Internal worker', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T04:00:00.000Z', + threadSource: 'agent' + }), + 40 + ) + provider.addFile( + '/home/ada/.codex/sessions/user.jsonl', + codexTranscript({ + sessionId: 'user-session', + title: 'Visible user session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T03:00:00.000Z' + }), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) + }) + + it('keeps scoped remote sessions even when they are older than the recency cap', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/other.jsonl', + codexTranscript({ + sessionId: 'other-session', + title: 'Other workspace', + cwd: '/home/ada/other', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 50 + ) + provider.addFile( + '/home/ada/.codex/sessions/scoped.jsonl', + codexTranscript({ + sessionId: 'scoped-session', + title: 'Scoped workspace', + cwd: '/home/ada/repo/app', + timestamp: '2026-07-04T01:00:00.000Z' + }), + 10 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-session', + 'scoped-session' + ]) + }) +}) + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string + threadSource?: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { + id: args.sessionId, + cwd: args.cwd, + ...(args.threadSource ? { thread_source: args.threadSource } : {}) + } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts new file mode 100644 index 00000000000..1b7d66cb7b0 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -0,0 +1,296 @@ +import { extname } from 'node:path' +import type { + AiVaultAgent, + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { sessionSortTime } from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import { remoteSessionSources } from './remote-session-scanner-sources' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const DEFAULT_REMOTE_SCAN_LIMIT = 1000 +const REMOTE_SCAN_CONCURRENCY = 8 +const REMOTE_SCOPE_PARSE_LIMIT = 2000 + +export async function scanRemoteAiVaultSessions(args: { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + limit?: number + scopePaths?: readonly string[] +}): Promise { + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT + const issues: AiVaultScanIssue[] = [] + const context: RemoteScannerContext = { + provider: args.provider, + executionHostId: args.executionHostId, + hostPlatform: args.hostPlatform, + titleCaches: new Map() + } + const candidates = ( + await mapRemoteScanConcurrently( + remoteSessionSources(args.remoteHome, args.hostPlatform), + (source) => discoverRemoteSourceCandidates({ source, context, issues }) + ) + ) + .flat() + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + + const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) + const cappedSessions = parsed.sessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) + const parsedScopeSessions = parsed.sessions.filter((session) => + isRemoteSessionInScope(session, scopePaths) + ) + const extraScopeSessions = await scanRemoteInScopeSessions({ + candidates, + context, + issues, + scopePaths, + alreadyParsedFilePaths: parsed.parsedFilePaths + }) + + return { + sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]), + issues, + scannedAt: new Date().toISOString() + } +} + +async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const paths = await walkRemoteSessionFiles( + args.source, + args.context.provider, + args.context.hostPlatform + ) + const files = await mapRemoteScanConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ source: args.source, file })) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + provider: IFilesystemProvider, + hostPlatform: RemoteHostPlatform, + dirPath = source.rootDir +): Promise { + let entries + try { + entries = await provider.readDir(dirPath) + } catch { + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) + if (entry.isDirectory && !entry.isSymlink) { + files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function parseRemoteSessionCandidates(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + limit: number +}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> { + const sessions: AiVaultSession[] = [] + const parsedFilePaths = new Set() + let index = 0 + + while (index < args.candidates.length) { + if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + break + } + + const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + for (const candidate of batch) { + parsedFilePaths.add(candidate.file.path) + } + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push(...results.filter(isAiVaultSession)) + index += batch.length + } + + return { sessions, parsedFilePaths } +} + +async function scanRemoteInScopeSessions(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + scopePaths: readonly string[] + alreadyParsedFilePaths: ReadonlySet +}): Promise { + if (args.scopePaths.length === 0) { + return [] + } + + const candidates = args.candidates + .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)) + .slice(0, REMOTE_SCOPE_PARSE_LIMIT) + const sessions: AiVaultSession[] = [] + + for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push( + ...results.filter( + (session): session is AiVaultSession => + isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) + ) + ) + } + + return sessions +} + +async function parseRemoteSessionCandidate( + candidate: RemoteSessionCandidate, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + try { + const read = await context.provider.readFile(candidate.file.path) + if (read.isBinary) { + return null + } + return candidate.source.parse(candidate.file, read.content, context) + } catch (err) { + issues.push({ + executionHostId: context.executionHostId, + agent: candidate.source.agent, + path: candidate.file.path, + message: errorMessage(err) + }) + return null + } +} + +function mergeRemoteSessions( + cappedSessions: AiVaultSession[], + scopeSessions: AiVaultSession[] +): AiVaultSession[] { + if (scopeSessions.length === 0) { + return cappedSessions + } + const byId = new Map() + for (const session of cappedSessions) { + byId.set(session.id, session) + } + for (const session of scopeSessions) { + byId.set(session.id, session) + } + return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) +} + +function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { + const cwd = session.cwd + return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) +} + +function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { + return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + return null + } +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +function canStopParsingRemoteSessions( + sessions: AiVaultSession[], + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const visibleCutoff = sessions + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + + // Transcript mtimes bound the remaining candidate order; once the visible + // cutoff is newer, older files cannot enter the unscoped top-N result. + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} + +function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { + return Boolean(session) +} + +async function mapRemoteScanConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index a6e8cc293dd..85b33a962d6 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -6,6 +6,7 @@ import { type AiVaultSession, type AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { extractPreviewContentText, @@ -43,7 +44,11 @@ export function createAccumulator(args: { export function finalizeSession( accumulator: SessionAccumulator, platform: NodeJS.Platform, - options: { codexHome?: string | null } = {} + options: { + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + } = {} ): AiVaultSession | null { const sessionId = accumulator.sessionId.trim() if (!sessionId) { @@ -54,8 +59,14 @@ export function finalizeSession( accumulator.fallbackTitle || `${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}` + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + return { - id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + executionHostId, + ...(options.executionHostPlatform + ? { executionHostPlatform: options.executionHostPlatform } + : {}), agent: accumulator.agent, sessionId, title, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index adfb2aa86db..1114c1b8bb2 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,11 +1,8 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' -import { - parseDroidSessionFile, - parseMessageGraphSessionFile, - parseRovoSessionFile -} from './session-scanner-graph-parsers' +import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index a42e0af7962..b18721a8296 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import { addPreviewContent, createAccumulator, @@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) - for await (const line of lines) { + return parseCodexSessionLines({ + file, + lines, + platform, + codexHome, + executionHostId, + titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) + }) +} + +export async function parseCodexSessionContent(args: { + file: FileWithMtime + content: string + platform?: NodeJS.Platform + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + readIndexedTitle?: (sessionId: string) => Promise +}): Promise { + return parseCodexSessionLines({ + file: args.file, + lines: args.content.split(/\r?\n/), + platform: args.platform ?? process.platform, + codexHome: args.codexHome ?? null, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform, + titleReader: args.readIndexedTitle + }) +} + +async function parseCodexSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + codexHome: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + titleReader?: (sessionId: string) => Promise +}): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -70,7 +111,7 @@ export async function parseCodexSessionFile( } const indexedTitle = extractCodexSessionMetadataTitle(payload) ?? - (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + (await args.titleReader?.(accumulator.sessionId)) if (indexedTitle) { accumulator.title = indexedTitle } @@ -158,7 +199,11 @@ export async function parseCodexSessionFile( } } - return finalizeSession(accumulator, platform, { codexHome }) + return finalizeSession(accumulator, args.platform, { + codexHome: args.codexHome, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform + }) } function addCodexUsage( diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4750dd788ef..4ad6fa45f78 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' import { addPreviewContent, @@ -17,11 +18,25 @@ import { numberValue } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export function parseDevinSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -69,7 +84,7 @@ export async function parseDevinSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } function extractDevinStepText(step: Record): string | null { diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts new file mode 100644 index 00000000000..d99795bbbd3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractMessageText, + extractPreviewContentText, + extractString, + normalizeTitleText, + parseJsonObject, + tokenTotal +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseDroidSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseDroidSessionLines({ file, lines, platform }) +} + +export async function parseDroidSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseDroidSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseDroidSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'droid', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, record.timestamp) + if (record.type === 'session_start') { + accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + continue + } + if (record.type === 'system') { + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.model = extractString(record.model) ?? accumulator.model + } + const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) + if (streamSessionId) { + accumulator.sessionId = streamSessionId + } + if (record.type === 'message') { + consumeDroidMessage(accumulator, record) + } else if (record.type === 'completion') { + accumulator.messageCount++ + accumulator.totalTokens += tokenTotal(record.usage) + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(record.finalText), + timestamp: record.timestamp + }) + } + } + return finalizeSession(accumulator, args.platform, args.options) +} + +function consumeDroidMessage( + accumulator: SessionAccumulator, + record: Record +): void { + const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) + if (role !== 'user' && role !== 'assistant') { + return + } + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= + normalizeTitleText(extractString(record.text) ?? '') || + extractMessageText(asRecord(record.message)) + } + addPreviewMessage(accumulator, { + role, + text: + extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content), + timestamp: record.timestamp + }) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3013925a548..a52c83b3213 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -17,15 +18,18 @@ import { asRecord, extractContentText, extractMessageText, - extractPreviewContentText, extractString, firstString, - normalizeTitleText, parseJsonObject, readJsonObjectIfExists, tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent, - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseMessageGraphSessionLines({ agent, file, lines, platform }) +} - for await (const line of lines) { +export async function parseMessageGraphSessionContent( + agent: 'openclaw' | 'pi', + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseMessageGraphSessionLines({ + agent, + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseMessageGraphSessionLines(args: { + agent: 'openclaw' | 'pi' + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: args.agent, + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile( } } - return finalizeSession(accumulator, platform) -} - -export async function parseDroidSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const accumulator = createAccumulator({ - agent: 'droid', - file, - sessionId: sessionIdFromFileName(file.path) - }) - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, record.timestamp) - if (record.type === 'session_start') { - accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - continue - } - if (record.type === 'system') { - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - accumulator.model = extractString(record.model) ?? accumulator.model - } - const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) - if (streamSessionId) { - accumulator.sessionId = streamSessionId - } - if (record.type === 'message') { - const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= - normalizeTitleText(extractString(record.text) ?? '') || - extractMessageText(asRecord(record.message)) - } - addPreviewMessage(accumulator, { - role, - text: - extractString(record.text) ?? - extractPreviewContentText(asRecord(record.message)?.content), - timestamp: record.timestamp - }) - } - } else if (record.type === 'completion') { - accumulator.messageCount++ - accumulator.totalTokens += tokenTotal(record.usage) - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(record.finalText), - timestamp: record.timestamp - }) - } - } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 363bfe636a8..28a37602a6c 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -23,24 +24,51 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'claude', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let metaTitle: string | null = null - let generatedTitle: string | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseClaudeSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseClaudeSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseClaudeSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseClaudeSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'claude', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let metaTitle: string | null = null + let generatedTitle: string | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -92,7 +120,7 @@ export async function parseClaudeSessionFile( } accumulator.fallbackTitle = generatedTitle ?? metaTitle - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseGeminiSessionFile( @@ -103,7 +131,33 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseGeminiSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) + } + return parseGeminiJsonSessionContent(file, content, platform, options) +} + +function parseGeminiJsonSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -117,24 +171,33 @@ export async function parseGeminiSessionFile( for (const message of arrayValue(record.messages)) { consumeGeminiMessage(accumulator, asRecord(message)) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } export async function parseGeminiJsonlSessionFile( file: FileWithMtime, platform: NodeJS.Platform ): Promise { - const accumulator = createAccumulator({ - agent: 'gemini', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseGeminiJsonlSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +async function parseGeminiJsonlSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'gemini', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile( consumeGeminiMessage(accumulator, record) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export function consumeGeminiMessage( diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index edc03cf5815..1edcf6292f0 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -29,21 +30,49 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseCopilotSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'copilot', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCopilotSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCopilotSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCopilotSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCopilotSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'copilot', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -94,24 +123,47 @@ export async function parseCopilotSessionFile( } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseCursorSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'cursor', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCursorSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCursorSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCursorSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCursorSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'cursor', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -132,7 +184,7 @@ export async function parseCursorSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseOpenCodeSessionFile( @@ -207,7 +259,16 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseHermesSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -234,5 +295,5 @@ export async function parseHermesSessionFile( if (accumulator.messageCount === 0) { accumulator.messageCount = numberValue(record.message_count) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b6c6ae96412..5d2208b7b60 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -4,6 +4,7 @@ import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' export type AiVaultScanOptions = { claudeProjectsDir?: string @@ -33,6 +34,7 @@ export type AiVaultScanOptions = { // the recency cap (see discoverInScopeClaudeFiles). scopePaths?: readonly string[] platform?: NodeJS.Platform + executionHostId?: ExecutionHostId } export type FileWithMtime = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 181bf1466ea..59f92dafc7a 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -3,6 +3,7 @@ import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' @@ -41,6 +42,7 @@ export async function scanAiVaultSessions( const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) @@ -63,6 +65,7 @@ export async function scanAiVaultSessions( candidates, limit, platform, + executionHostId, issues }) @@ -75,12 +78,13 @@ export async function scanAiVaultSessions( scopePaths: options.scopePaths ?? [], alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), platform, + executionHostId, issues }) return { sessions: mergeSessions(cappedSessions, scopeSessions), - issues, + issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() } } @@ -110,6 +114,7 @@ async function scanInScopeSessions(args: { scopePaths: readonly string[] alreadyParsedFilePaths: ReadonlySet platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { if (args.scopePaths.length === 0) { @@ -136,6 +141,7 @@ async function scanInScopeSessions(args: { candidates, limit: candidates.length, platform: args.platform, + executionHostId: args.executionHostId, issues: args.issues }) } @@ -144,6 +150,7 @@ async function parseSessionCandidates(args: { candidates: SessionFileCandidate[] limit: number platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { const sessions: AiVaultSession[] = [] @@ -159,7 +166,9 @@ async function parseSessionCandidates(args: { const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining) const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( - batch.map((candidate) => parseSessionCandidate(candidate, args.platform)) + batch.map((candidate) => + parseSessionCandidate(candidate, args.platform, args.executionHostId) + ) ) for (const result of results) { @@ -179,15 +188,20 @@ async function parseSessionCandidates(args: { async function parseSessionCandidate( candidate: SessionFileCandidate, - platform: NodeJS.Platform + platform: NodeJS.Platform, + executionHostId: ExecutionHostId ): Promise { try { const session = await parseAgentSessionFile(candidate, platform) - return { session, issue: null } + return { + session: session ? withSessionExecutionHost(session, executionHostId) : null, + issue: null + } } catch (err) { return { session: null, issue: { + executionHostId, agent: candidate.agent, path: candidate.file.path, message: errorMessage(err) @@ -196,6 +210,20 @@ async function parseSessionCandidate( } } +function withSessionExecutionHost( + session: AiVaultSession, + executionHostId: ExecutionHostId +): AiVaultSession { + if (session.executionHostId === executionHostId) { + return session + } + return { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } +} + function canStopParsingSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts new file mode 100644 index 00000000000..9cc8060eee2 --- /dev/null +++ b/src/main/ipc/ai-vault.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn(), + scanRemoteAiVaultSessions: vi.fn(), + getAiVaultWslHomeDirs: vi.fn(), + getSshFilesystemProvider: vi.fn(), + getActiveSshAiVaultHostInfo: vi.fn(), + getActiveSshAiVaultHostInfos: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { on: vi.fn() }, + ipcMain: { handle: vi.fn() } +})) + +vi.mock('../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) + +vi.mock('../ai-vault/remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions +})) + +vi.mock('../wsl', () => ({ + getWslHomeAsync: mocks.getAiVaultWslHomeDirs, + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) + +vi.mock('./ssh', () => ({ + getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos +})) + +const { _internals } = await import('./ai-vault') + +const provider = {} as IFilesystemProvider + +beforeEach(() => { + vi.clearAllMocks() + _internals.resetAiVaultCacheForTests() + mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')])) + mocks.scanRemoteAiVaultSessions.mockResolvedValue( + result([session('ssh:dev-box', 'remote-session')]) + ) + mocks.getSshFilesystemProvider.mockReturnValue(provider) + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) +}) + +describe('listAiVaultSessions host routing', () => { + it('routes local scope to the local scanner', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + scopePaths: ['/repo'], + executionHostId: 'local' + }) + ) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + + it('routes SSH scope to only that SSH target', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo'] + }) + + expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled() + expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + scopePaths: ['/home/ada/repo'] + }) + ) + }) + + it('merges local plus connected SSH targets for all hosts', async () => { + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) + }) + + it('returns a scan issue for a disconnected SSH target', async () => { + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) + mocks.getSshFilesystemProvider.mockReturnValue(undefined) + + const result = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:disconnected' + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toMatchObject([ + { + executionHostId: 'ssh:disconnected', + agent: 'codex', + path: 'disconnected' + } + ]) + }) + + it('keeps host scope in the cache key', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) + +function hostInfo(targetId: string) { + return { + targetId, + executionHostId: `ssh:${targetId}` as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + } +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: new Date().toISOString() } +} + +function session( + executionHostId: AiVaultSession['executionHostId'], + sessionId: string +): AiVaultSession { + return { + id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`, + executionHostId, + agent: 'codex', + sessionId, + title: sessionId, + cwd: '/repo', + branch: null, + model: null, + filePath: `/tmp/${sessionId}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: + sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z', + modifiedAt: '2026-07-04T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + resumeCommand: `codex resume ${sessionId}` + } +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f96aa0b6d61..4551379afde 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,8 +1,26 @@ import { app, ipcMain } from 'electron' import { join } from 'node:path' +import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { scanAiVaultSessions } from '../ai-vault/session-scanner' +import { sessionSortTime } from '../ai-vault/session-scanner-accumulator' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { + AiVaultListArgs, + AiVaultListResult, + AiVaultScanIssue +} from '../../shared/ai-vault-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostScope, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostScope +} from '../../shared/execution-host' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' +import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' const AI_VAULT_CACHE_TTL_MS = 15_000 @@ -22,10 +40,14 @@ let inflightKey: string | null = null let handlerOptions: AiVaultHandlerOptions = {} async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + const executionHostScope = normalizeExecutionHostScope( + args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID + ) // Scope paths change the result set, so they must be part of the cache key. const key = JSON.stringify({ limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [] + scopePaths: args?.scopePaths ?? [], + executionHostScope }) const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL @@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? - [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs() - }))() + inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope) .then((result) => { cachedList = { key, @@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - inflightKey = null - inflightList = null + // Only clear tracking if it still refers to this request: a concurrent + // different-scope scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } }) return inflightList } +async function scanAiVaultSessionsByHostScope( + args: AiVaultListArgs | undefined, + executionHostScope: ExecutionHostScope +): Promise { + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scanLocalAiVaultSessions(args) + } + + if (executionHostScope === 'all') { + return mergeAiVaultListResults( + await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ) + ]), + args?.limit + ) + } + + const parsed = parseExecutionHostId(executionHostScope) + if (parsed?.kind === 'ssh') { + return scanSshAiVaultSessions(parsed.targetId, args) + } + + return { + sessions: [], + issues: [ + { + executionHostId: executionHostScope, + agent: 'codex', + path: executionHostScope, + message: 'Agent Session History is not available for this execution host.' + } + ], + scannedAt: new Date().toISOString() + } +} + +async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { + const additionalCodexSessionsDirs = + handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? + [] + return scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + executionHostId: LOCAL_EXECUTION_HOST_ID + }) +} + +async function scanSshAiVaultSessions( + targetId: string, + args?: AiVaultListArgs +): Promise { + const executionHostId = toSshExecutionHostId(targetId) + const hostInfo = getActiveSshAiVaultHostInfo(targetId) + const provider = getSshFilesystemProvider(targetId) + if (!hostInfo || !provider) { + return sshScanIssueResult({ + executionHostId, + targetId, + message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + }) + } + return scanRemoteAiVaultSessions({ + provider, + executionHostId: hostInfo.executionHostId, + remoteHome: hostInfo.remoteHome, + hostPlatform: hostInfo.hostPlatform, + limit: args?.limit, + scopePaths: args?.scopePaths + }) +} + +function sshScanIssueResult(args: { + executionHostId: `ssh:${string}` + targetId: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId: args.executionHostId, + agent: 'codex', + path: args.targetId, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} + +function mergeAiVaultListResults( + results: readonly AiVaultListResult[], + rawLimit: number | undefined +): AiVaultListResult { + const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { + byId.set(session.id, session) + } + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() + } +} + export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => @@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } +function resetAiVaultCacheForTests(): void { + cachedList = null + inflightList = null + inflightKey = null + handlerOptions = {} +} + +export const _internals = { + listAiVaultSessions, + resetAiVaultCacheForTests +} + async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 15f95306c78..ff676112218 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -5,7 +5,7 @@ import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { SshRelaySession } from '../ssh/ssh-relay-session' +import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, @@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise // scattered Maps/Sets that previously tracked this state independently. const activeSessions = new Map() +export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { + if (isRuntimeOwnedSshTargetId(targetId)) { + return null + } + return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null +} + +export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { + return [...activeSessions.values()].flatMap((session) => { + if (isRuntimeOwnedSshTargetId(session.targetId)) { + return [] + } + const info = session.getAiVaultHostInfo() + return info ? [info] : [] + }) +} + async function detachActiveSshSession(targetId: string): Promise { await teardownActiveSshSession(targetId, (session) => session.detach()) } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 58bf3fbf798..85761621d44 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -62,10 +62,12 @@ import { import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' type RemoteCliBridgeEnv = { + remoteHome: string binDir: string relayDir: string nodePath: string @@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = { deliveredAt: number } +export type SshRelayAiVaultHostInfo = { + targetId: string + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform +} + const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 const REPLAY_FINGERPRINT_EDGE_CHARS = 128 @@ -189,6 +198,19 @@ export class SshRelaySession { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } + getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { + const env = this.remoteCliBridgeEnv + if (!env) { + return null + } + return { + targetId: this.targetId, + executionHostId: toSshExecutionHostId(this.targetId), + remoteHome: env.remoteHome, + hostPlatform: env.hostPlatform + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -218,6 +240,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, @@ -345,6 +368,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index f67c7d82a3a..3fd7ae88809 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { 'runtime', [activeWorktreeId, resumeTargetState] ) + const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } = + useAiVaultExecutionHostScope({ + activeWorktreeId: activeWorktreeId ?? null, + resumeTargetState + }) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( @@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element { }), [activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection] ) - const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths) + const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( + scopePaths, + executionHostScope + ) const sessionProjectById = useMemo( () => buildAiVaultProjectContext({ @@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, + repos, worktrees: allWorktrees, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null }) @@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeState({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { activeWorktreePath={activeWorktreePath} activeProjectKey={activeProjectKey} scope={scope} + executionHostScope={executionHostScope} + activeSshExecutionHostScope={activeSshExecutionHostScope} agents={agents} sort={sort} group={group} @@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element { adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} + onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 227e96ccca9..2b3cc2802b0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -7,7 +7,8 @@ import { FolderOpen, ListFilter, LoaderCircle, - PanelsTopLeft + PanelsTopLeft, + Server } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -31,6 +32,12 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' @@ -175,6 +182,59 @@ export function VaultScopeSwitch({ ) } +export function VaultHostScopeMenu({ + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange +}: { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +}): React.JSX.Element { + const hostOptions = [ + LOCAL_EXECUTION_HOST_ID, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), + ALL_EXECUTION_HOSTS_SCOPE + ] as const + const label = getExecutionHostLabel(executionHostScope) + + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')} + + onExecutionHostScopeChange(value as ExecutionHostScope)} + > + {hostOptions.map((scope) => ( + + {getExecutionHostLabel(scope)} + + ))} + + + + ) +} + export function VaultViewMenu({ agents, sort, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 6f40190617e..b463e188a7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -7,7 +7,8 @@ import type { AiVaultScope, AiVaultSort } from '../../../../shared/ai-vault-types' -import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' type AiVaultPanelHeaderProps = { query: string @@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = { activeWorktreePath: string | null activeProjectKey: string | null scope: AiVaultScope + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = { adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void @@ -42,6 +46,8 @@ export function AiVaultPanelHeader({ activeWorktreePath, activeProjectKey, scope, + executionHostScope, + activeSshExecutionHostScope, agents, sort, group, @@ -49,6 +55,7 @@ export function AiVaultPanelHeader({ adjustmentCount, onQueryChange, onScopeChange, + onExecutionHostScopeChange, onAgentEnabledChange, onSortChange, onGroupChange, @@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+ void + onJumpToOriginalPane?: () => void + showJumpToWorktree: boolean + onJumpToWorktree?: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog?: () => void + onRevealLog?: () => void + onOpenCwd?: () => void +}) { + const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem + const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + + return ( + <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + {showJumpToWorktree ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + + ) : null} + + + {resumeLabel} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + {hasLocalPathActions ? ( + <> + + {onOpenLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + ) : null} + {onRevealLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + ) : null} + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 58923a53479..d5a538b44fd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -31,7 +31,7 @@ export function SessionInlineDetails({ } onResumeInWorktree: () => void onResumeInNewTab: () => void - onOpenLog: () => void + onOpenLog?: () => void }): React.JSX.Element { const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) @@ -132,20 +132,22 @@ export function SessionInlineDetails({ )} ) : null} - + {onOpenLog ? ( + + ) : null}
) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 5ea6bd9d307..0826eed9509 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,14 +1,6 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { Badge } from '@/components/ui/badge' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { AgentIcon } from '@/lib/agent-catalog' @@ -27,6 +19,7 @@ import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { @@ -74,8 +67,8 @@ export function VaultSessionRow({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt @@ -102,6 +95,7 @@ export function VaultSessionRow({ title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) @@ -223,101 +217,6 @@ export function VaultSessionRow({ ) } -export function SessionActionMenuItems({ - menuKind = 'dropdown', - resumeDisabled, - resumeLabel, - onResume, - onJumpToOriginalPane, - showJumpToWorktree, - onJumpToWorktree, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd -}: { - menuKind?: 'dropdown' | 'context' - resumeDisabled: boolean - resumeLabel: string - onResume: () => void - onJumpToOriginalPane?: () => void - showJumpToWorktree: boolean - onJumpToWorktree?: () => void - onCopyResume: () => void - onCopyId: () => void - onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void - onOpenCwd?: () => void -}) { - const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem - const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator - - return ( - <> - {onJumpToOriginalPane ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', - 'Jump to Original Pane' - )} - - ) : null} - {showJumpToWorktree ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', - 'Jump to Worktree' - )} - - ) : null} - - - {resumeLabel} - - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} - - {onOpenCwd ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', - 'Open Working Directory' - )} - - ) : null} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', - 'Copy Session ID' - )} - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - - - ) -} - function getSessionDetailsId(sessionId: string): string { return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index e7293f15de7..3d2bdde0384 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -19,6 +19,7 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -275,6 +276,8 @@ function AiVaultVirtualRow({ const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) return (
onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={() => onOpenLog(row.session)} - onRevealLog={() => onRevealLog(row.session)} - onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined} + onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 27a29bbf032..10167da346b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionActionMenuItems } from './AiVaultSessionRow' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { aiVaultWorktreeJumpTooltip, type AiVaultSessionWorktreeInfo @@ -67,8 +67,8 @@ export function SessionRowTrailingActions({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts new file mode 100644 index 00000000000..73af6ab915e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' + +type HostScopeResult = ReturnType + +let root: Root | null = null +let latest: HostScopeResult | null = null + +function HookProbe(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): null { + latest = useAiVaultExecutionHostScope(props) + return null +} + +async function renderHook(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): Promise { + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render(createElement(HookProbe, props)) + }) +} + +function stateForWorktree(args: { + worktreeId: string + repoId: string + executionHostId?: string | null + hostId?: string | null +}): AiVaultSessionResumeTargetState { + return { + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: args.repoId, + connectionId: null, + executionHostId: args.executionHostId ?? 'local' + } + ], + worktreesByRepo: { + [args.repoId]: [ + { + id: args.worktreeId, + repoId: args.repoId, + hostId: args.hostId ?? null + } + ] + } + } as unknown as Pick +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + root = null + latest = null + document.body.replaceChildren() +}) + +describe('useAiVaultExecutionHostScope', () => { + it('defaults SSH worktrees to their SSH execution host', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + }) + + expect(latest?.executionHostScope).toBe('ssh:dev-box') + expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box') + }) + + it('defaults local worktrees to local history', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/local/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/local/repo', + repoId: 'repo-1' + }) + }) + + expect(latest?.executionHostScope).toBe('local') + expect(latest?.activeSshExecutionHostScope).toBeNull() + }) + + it('preserves manual host scope changes across unrelated rerenders', async () => { + const props = { + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + } + await renderHook(props) + + await act(async () => { + latest?.onExecutionHostScopeChange('all') + }) + await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } }) + + expect(latest?.executionHostScope).toBe('all') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts new file mode 100644 index 00000000000..8993e8a00e6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' + +export function useAiVaultExecutionHostScope(args: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +} { + const userChangedHostScopeRef = useRef(false) + const activeExecutionHostId = useMemo( + () => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId), + [args.activeWorktreeId, args.resumeTargetState] + ) + const activeExecutionHost = parseExecutionHostId(activeExecutionHostId) + const defaultExecutionHostScope: ExecutionHostScope = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID + const activeSshExecutionHostScope: ExecutionHostScope | null = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null + const [executionHostScope, setExecutionHostScope] = + useState(defaultExecutionHostScope) + + useEffect(() => { + // Why: preserve an explicit user choice (e.g. "All") across incidental + // rerenders, but reset to the new default once that choice no longer + // applies to the active worktree's host. + const allowedScopes = new Set([ + LOCAL_EXECUTION_HOST_ID, + ALL_EXECUTION_HOSTS_SCOPE, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []) + ]) + if (!allowedScopes.has(executionHostScope)) { + setExecutionHostScope(defaultExecutionHostScope) + userChangedHostScopeRef.current = false + return + } + if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) { + setExecutionHostScope(defaultExecutionHostScope) + } + }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope]) + + const handleExecutionHostScopeChange = useCallback( + (nextScope: ExecutionHostScope) => { + userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope + setExecutionHostScope(nextScope) + }, + [defaultExecutionHostScope] + ) + + return { + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange: handleExecutionHostScopeChange + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index cec8a30a270..c47f64b9720 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the pane focus', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index b341558c628..3da906781d9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -9,6 +9,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the flaky golden tests', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 3190221f389..acbe0b051a1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement vault filters', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index dc2ed601f83..c55acb1e939 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -13,6 +13,7 @@ import { import { useAppStore } from '@/store' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -79,6 +80,7 @@ export function useAiVaultSessionLaunchActions({ (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -130,6 +132,7 @@ export type AiVaultSessionLaunchTarget = export function resolveAiVaultSessionLaunchTarget(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -143,7 +146,18 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + targetWorktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return { status: 'unsupported', targetStatus } } @@ -159,12 +173,12 @@ function aiVaultResumeUnsupportedMessage( 'Resume from history is not available in runtime-hosted workspaces.' ) } - // Why: 'ssh' only reaches the unsupported branch when the session file lives - // on this machine, so the message explains the host mismatch, not SSH itself. - if (targetStatus === 'ssh') { + // Why: local and SSH targets can both be valid generally; this branch means + // the session's recorded host does not match the selected workspace. + if (targetStatus === 'ssh' || targetStatus === 'local') { return translate( - 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Open a workspace on the same host to resume it.' ) } return translate( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts new file mode 100644 index 00000000000..93fee6c94e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' + +describe('canUseLocalAiVaultSessionPathActions', () => { + it('allows OS path actions for local session history', () => { + expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true) + }) + + it('blocks OS path actions for non-local or unknown session history', () => { + expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts new file mode 100644 index 00000000000..f723bbb1959 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -0,0 +1,13 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function canUseLocalAiVaultSessionPathActions( + executionHostId: ExecutionHostId | null | undefined +): boolean { + // Why: Electron shell open/reveal APIs only validate paths on this computer; + // SSH session history exposes paths that exist on the remote host instead. + return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index fa53c685da8..d0e4c1a1c6e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement project history', @@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => { repoId: repo.id, path: '/runtime/orca' }) - const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-worktree', + cwd: '/runtime/orca/src', + executionHostId: 'runtime:preview' + }) const context = buildAiVaultProjectContext({ repos: [repo], @@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') }) - it('falls back to folder when a hostless session matches multiple host buckets', () => { + it('uses the session host when matching overlapping local and SSH project paths', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const sshRepo = makeRepo({ id: 'ssh', @@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => { path: '/srv/orca', connectionId: 'target-1' }) - const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:ssh-session', + cwd: '/srv/orca/src', + executionHostId: 'ssh:target-1' + }) const context = buildAiVaultProjectContext({ repos: [localRepo, sshRepo], @@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - kind: 'folder', - key: 'folder:/srv/orca/src', - label: 'orca/src' + kind: 'repo', + key: 'repo:ssh', + label: 'SSH', + hostKey: 'ssh:target-1' }) }) - it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => { + it('falls back to folder when a legacy hostless session matches multiple host buckets', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' }) - const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-ambiguous', + cwd: '/srv/orca/src', + executionHostId: undefined as unknown as AiVaultSession['executionHostId'] + }) const context = buildAiVaultProjectContext({ repos: [localRepo, runtimeRepo], diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index fdb78325025..c0734a11881 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,4 +1,9 @@ -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' @@ -14,7 +19,7 @@ export type AiVaultSessionProject = { label: string projectId?: string repoId?: string - hostKey?: string + hostKey?: ExecutionHostId } export type AiVaultProjectContext = { @@ -27,7 +32,7 @@ export type AiVaultProjectContext = { type SessionProjectCandidate = { source: 'worktree' | 'setup' normalizedPath: string - hostKey: string + hostKey: ExecutionHostId projectId: string | null repoId: string | null } @@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({ for (const session of sessions) { sessionProjectById.set( session.id, - resolveSessionProject(session.cwd, candidates, projectLabelByKey) + resolveSessionProject(session, candidates, projectLabelByKey) ) } @@ -147,10 +152,11 @@ function buildProjectCandidates( candidates.push({ source: 'worktree', normalizedPath: normalizeRuntimePathForComparison(worktree.path), - hostKey: - worktree.hostId ?? - setup?.hostId ?? - (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), projectId: worktree.projectId ?? setup?.projectId ?? null, repoId: worktree.repoId }) @@ -168,7 +174,7 @@ function buildProjectCandidates( candidates.push({ source: 'setup', normalizedPath: normalizeRuntimePathForComparison(setup.path), - hostKey: setup.hostId || getRepoExecutionHostId(setup), + hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), projectId: setup.projectId, repoId: setup.repoId || null }) @@ -193,15 +199,28 @@ function buildProjectCandidates( return candidates } +function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + function hasCandidatePath(pathValue: string): boolean { return pathValue.trim().length > 0 } function resolveSessionProject( - cwd: string | null, + session: AiVaultSession, candidates: readonly SessionProjectCandidate[], projectLabelByKey: ReadonlyMap ): AiVaultSessionProject { + const cwd = session.cwd if (!cwd) { return { kind: 'unknown', key: 'unknown', label: '' } } @@ -209,14 +228,22 @@ function resolveSessionProject( const matches = candidates.filter((candidate) => isPathInsideOrEqual(candidate.normalizedPath, cwd) ) - const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey)) - if (hostBuckets.size > 1) { - // Why: session rows do not carry host ids yet, so overlapping local/SSH - // paths must stay visible without being attributed to the wrong project. + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const hostMatches = sessionHostId + ? matches.filter((candidate) => candidate.hostKey === sessionHostId) + : matches + if (sessionHostId && matches.length > 0 && hostMatches.length === 0) { + // Why: same-path projects can exist on several hosts; a tagged transcript + // should never be attributed to a project on a different machine. return folderProject(cwd) } - const bestCandidate = matches.sort(compareCandidates)[0] + const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey)) + if (!sessionHostId && hostBuckets.size > 1) { + return folderProject(cwd) + } + + const bestCandidate = hostMatches.sort(compareCandidates)[0] if (!bestCandidate) { return folderProject(cwd) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index cdff27604d9..2ea75f40f48 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] let latest: ReturnType | null = null -function HookProbe(props: { scopePaths: readonly string[] }): null { - latest = useAiVaultSessionRefresh(props.scopePaths) +function HookProbe(props: { + scopePaths: readonly string[] + executionHostScope?: 'local' | 'all' | `ssh:${string}` +}): null { + latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local') return null } -async function renderHook(scopePaths: readonly string[] = []): Promise { +async function renderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) await act(async () => { - root.render(createElement(HookProbe, { scopePaths })) + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) + }) +} + +async function rerenderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { + const root = roots.at(-1) + if (!root) { + throw new Error('renderHook must be called before rerenderHook') + } + await act(async () => { + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) }) } @@ -126,7 +145,58 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true }) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ + executionHostScope: 'local', + force: true + }) + }) + + it('passes the requested execution host scope to the scanner', async () => { + await renderHook(['/repo'], 'ssh:dev-box') + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/repo'] + }) + }) + + it('does not apply stale results after the host scope changes mid-scan', async () => { + let resolveLocal: ((result: AiVaultListResult) => void) | null = null + let resolveSsh: ((result: AiVaultListResult) => void) | null = null + listSessionsMock + .mockImplementationOnce( + () => new Promise((resolve) => (resolveLocal = resolve)) + ) + .mockImplementationOnce( + () => new Promise((resolve) => (resolveSsh = resolve)) + ) + + await renderHook([], 'local') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await rerenderHook(['/remote/repo'], 'ssh:dev-box') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult).toBeNull() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/remote/repo'] + }) + + await act(async () => { + resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) it('force re-scans on refocus once the throttle allows it', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 98539f858ce..88757297603 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' const SESSION_LIMIT = 500 @@ -26,7 +27,10 @@ export function resetAiVaultForcedRescanThrottleForTest(): void { type AiVaultRefreshArgs = { force?: boolean; background?: boolean } -export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { +export function useAiVaultSessionRefresh( + scopePaths: readonly string[], + executionHostScope: ExecutionHostScope +): { error: string | null loading: boolean refresh: (args?: AiVaultRefreshArgs) => Promise @@ -45,77 +49,99 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) + const scanScopeKey = `${executionHostScope}\n${scopePathsKey}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths + const executionHostScopeRef = useRef(executionHostScope) + executionHostScopeRef.current = executionHostScope + const currentScanScopeKey = useCallback( + () => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`, + [] + ) - const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => { - // A scope change during an in-flight scan must not be dropped; queue one more - // scan so the current scoped view is refreshed after the older scan settles. - if (refreshInFlightRef.current) { - pendingRefreshRef.current = true - pendingForceRef.current ||= args.force === true - pendingBackgroundRef.current &&= args.background === true - return - } - - refreshInFlightRef.current = true - const refreshId = refreshIdRef.current + 1 - refreshIdRef.current = refreshId - // A manual force scan counts against the throttle so an auto rescan right - // after the button press doesn't trigger a second full scan. - if (args.force === true) { - lastForcedRescanAt = Date.now() - } - // Background (refocus) refreshes usually resolve from the main-process - // cache; suppressing the loading flag avoids a spinner flash on every - // return to the app. - if (args.background !== true) { - setLoading(true) - } - setError(null) - const scopeKey = scopePathsRef.current.join('\n') - try { - const result = await window.api.aiVault.listSessions({ - limit: SESSION_LIMIT, - scopePaths: scopePathsRef.current, - force: args.force - }) - if (!mountedRef.current || refreshIdRef.current !== refreshId) { + const refresh = useCallback( + async (args: AiVaultRefreshArgs = {}): Promise => { + // A scope change during an in-flight scan must not be dropped; queue one more + // scan so the current scoped view is refreshed after the older scan settles. + if (refreshInFlightRef.current) { + pendingRefreshRef.current = true + pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } - // A cache hit returns the snapshot already on screen; skip the state - // updates so refocus flips don't force pointless re-renders. - if ( - lastAppliedScanRef.current?.scopeKey === scopeKey && - lastAppliedScanRef.current.scannedAt === result.scannedAt - ) { - return + + refreshInFlightRef.current = true + const refreshId = refreshIdRef.current + 1 + refreshIdRef.current = refreshId + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() } - lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt } - setScanResult(result) - setSessions(result.sessions) - } catch (err) { - if (mountedRef.current && refreshIdRef.current === refreshId) { - setError(err instanceof Error ? err.message : String(err)) + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) } - } finally { - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) + setError(null) + const scopeKey = scopePathsRef.current.join('\n') + const hostScope = executionHostScopeRef.current + const scanKey = `${hostScope}\n${scopeKey}` + try { + const result = await window.api.aiVault.listSessions({ + limit: SESSION_LIMIT, + scopePaths: scopePathsRef.current, + executionHostScope: hostScope, + force: args.force + }) + if (!mountedRef.current || refreshIdRef.current !== refreshId) { + return + } + // Why: host/scope changes queue a follow-up scan, but the older result + // may resolve first and must not briefly paint the wrong history list. + if (scanKey !== currentScanScopeKey()) { + return + } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scanKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } + setScanResult(result) + setSessions(result.sessions) + } catch (err) { + if ( + mountedRef.current && + refreshIdRef.current === refreshId && + scanKey === currentScanScopeKey() + ) { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + refreshInFlightRef.current = false + if (mountedRef.current && refreshIdRef.current === refreshId) { + setLoading(false) + } + if (pendingRefreshRef.current && mountedRef.current) { + pendingRefreshRef.current = false + const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current + pendingForceRef.current = false + pendingBackgroundRef.current = true + void refresh({ force, background }) + } } - if (pendingRefreshRef.current && mountedRef.current) { - pendingRefreshRef.current = false - const force = pendingForceRef.current - // The queued refresh is background-only if every queued caller was. - const background = pendingBackgroundRef.current - pendingForceRef.current = false - pendingBackgroundRef.current = true - void refresh({ force, background }) - } - } - // Deps are intentionally empty: refresh reads changing values through refs - // and recurses on itself, so its identity must stay stable. - }, []) + // Deps intentionally avoid changing scope values: refresh reads them + // through refs and recurses on itself, so its identity must stay stable. + }, + [currentScanScopeKey] + ) // Forced rescans triggered by events (refocus, agent-session starts) run // immediately when the throttle allows, otherwise once as soon as it frees @@ -162,7 +188,7 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (!force) { requestForcedRescan() } - }, [refresh, requestForcedRescan, scopePathsKey]) + }, [refresh, requestForcedRescan, scanScopeKey]) // Sessions started while the app was backgrounded should appear when the // user returns, so refocus also bypasses the scan cache (throttled). OS diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index b8a428cd0c0..eb82d910cb9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,8 +1,10 @@ import type { Repo, Worktree } from '../../../../shared/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -34,6 +36,7 @@ export type AiVaultSessionResumeActions = { export function resolveAiVaultSessionResumeState(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -56,6 +59,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId, targetState }) @@ -78,6 +82,7 @@ export function resolveAiVaultSessionResumeState(args: { export function resolveAiVaultSessionResumeActions(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -92,11 +97,13 @@ export function resolveAiVaultSessionResumeActions(args: { const sessionTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -142,6 +149,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( function resolveSupportedResumeWorktreeId(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -154,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + args.worktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return null } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index b378afe3d42..58687977a33 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { aiVaultWorktreeCompactPath, aiVaultWorktreeJumpTooltip, @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Find the pane', @@ -55,6 +56,19 @@ function makeWorktree(overrides: Partial = {}): Worktree { return { ...worktree, ...overrides } } +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + connectionId: null, + executionHostId: 'local', + ...overrides + } +} + describe('resolveAiVaultSessionWorktreeInfo', () => { it('marks the selected owning worktree as current', () => { const worktree = makeWorktree() @@ -135,6 +149,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => { path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' }) }) + + it('uses the session host when multiple worktrees share the same path', () => { + const localWorktree = makeWorktree({ + id: 'repo-local::/srv/orca', + repoId: 'repo-local', + displayName: 'local', + path: '/srv/orca', + hostId: 'local' + }) + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca', + hostId: 'ssh:target-1' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + worktrees: [localWorktree, sshWorktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: sshWorktree.id + }) + }) + + it('uses repo host ownership when a legacy worktree lacks host metadata', () => { + const worktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })], + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: worktree.id + }) + }) }) describe('canJumpToAiVaultSessionWorktree', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index bd7aeef1bea..31222bec1b5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -1,6 +1,12 @@ import { useMemo } from 'react' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isPathInsideOrEqual, isRuntimePathAbsolute, @@ -8,7 +14,7 @@ import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { translate } from '@/i18n/i18n' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -23,16 +29,19 @@ export type AiVaultSessionWorktreeInfo = { type WorktreeCandidate = { worktree: Worktree path: string + hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' } export function resolveAiVaultSessionWorktreeInfo({ session, + repos = [], worktrees, activeWorktreeId }: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -40,8 +49,10 @@ export function resolveAiVaultSessionWorktreeInfo({ return null } - const candidates = buildWorktreeCandidates(worktrees) + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const candidates = buildWorktreeCandidates(worktrees, repos) .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) .sort(compareWorktreeCandidates) const best = candidates[0] @@ -85,6 +96,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul export function resolveAiVaultSessionWorktreeDisplay(args: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -117,10 +129,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { export function useAiVaultSessionWorktreeMap({ sessions, + repos = [], worktrees, activeWorktreeId }: { sessions: readonly AiVaultSession[] + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): ReadonlyMap { @@ -130,13 +144,14 @@ export function useAiVaultSessionWorktreeMap({ sessions.flatMap((session) => { const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ session, + repos, worktrees, activeWorktreeId }) return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] }) ), - [activeWorktreeId, sessions, worktrees] + [activeWorktreeId, repos, sessions, worktrees] ) } @@ -192,13 +207,22 @@ export function aiVaultWorktreeJumpTooltip( ) } -function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { +function buildWorktreeCandidates( + worktrees: readonly Worktree[], + repos: readonly Pick[] +): WorktreeCandidate[] { const candidates: WorktreeCandidate[] = [] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) for (const worktree of worktrees) { + const repo = repoById.get(worktree.repoId) + const hostId = + normalizeExecutionHostId(worktree.hostId) ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { candidates.push({ worktree, path: worktree.path, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'current-path' }) @@ -211,6 +235,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid candidates.push({ worktree, path: parsed.worktreePath, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'prior-path' }) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index ee41d5c6e81..af09c852cab 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 're import { toast } from 'sonner' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import { @@ -169,7 +170,9 @@ export default function AiVaultSessionDropLayer({ return true } - const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId) + const state = useAppStore.getState() + const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId) if (targetStatus === 'runtime') { toast.error( translate( @@ -191,13 +194,15 @@ export default function AiVaultSessionDropLayer({ if ( !canResumeAiVaultSessionOnTarget({ sessionFilePath: payload.sessionFilePath ?? null, - targetStatus + sessionExecutionHostId: payload.sessionExecutionHostId ?? null, + targetStatus, + targetExecutionHostId }) ) { toast.error( translate( - 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Drop it onto a workspace on the same host.' ) ) return true diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ea81c2c094a..1080958f7c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2589,6 +2589,7 @@ "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { @@ -9590,6 +9591,7 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Project", "currentProjectLower": "current project", - "project": "Project" + "project": "Project", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 97cbdff6f63..b7a29585352 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2589,7 +2589,8 @@ "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.", "openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.", "openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Proyecto", "currentProjectLower": "proyecto actual", - "project": "Proyecto" + "project": "Proyecto", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "Actualizado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 590f6d505cf..e6ff4fc77f5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2589,7 +2589,8 @@ "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9619,7 +9621,9 @@ "globalScope": "グローバル", "projectScope": "プロジェクト", "currentProjectLower": "現在のプロジェクト", - "project": "プロジェクト" + "project": "プロジェクト", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新日時", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8ae5f66134f..705815c7739 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2589,7 +2589,8 @@ "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9619,7 +9621,9 @@ "globalScope": "전역", "projectScope": "프로젝트", "currentProjectLower": "현재 프로젝트", - "project": "프로젝트" + "project": "프로젝트", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4166c860372..db284162150 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2589,7 +2589,8 @@ "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9619,7 +9621,9 @@ "globalScope": "全局", "projectScope": "项目", "currentProjectLower": "当前项目", - "project": "项目" + "project": "项目", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新时间", diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 1fa2559074d..dc0345491bf 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -294,4 +294,111 @@ describe('ai vault resume command runtime', () => { }) ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) + + it('returns the remote resume command verbatim for non-local host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" }) + }) + + it('bypasses the resume pipeline even when the command override is blank', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: ' ', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'") + }) + + it('rebuilds the command when a non-blank override is supplied for a remote session', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + }) + + it('rebuilds overridden remote commands with the recorded remote host platform', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:/Users/alice/repo', + codexHome: 'C:/Users/alice/.codex', + executionHostId: 'ssh:win-box', + executionHostPlatform: 'win32', + resumeCommand: + 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" + ) + }) + + it('ignores a stored resume command for local-host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'local', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 95da5ee49de..691d69efc09 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -19,10 +19,14 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -type AiVaultResumeCommandSession = Pick +type AiVaultResumeCommandSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' +> & + Partial> export type AiVaultResumeStartup = { command: string @@ -65,7 +69,20 @@ export function buildAiVaultResumeStartupForWorktree(args: { session: AiVaultResumeCommandSession commandOverride?: string | null }): AiVaultResumeStartup { - const platform = getAiVaultResumePlatform(args.state, args.worktreeId) + if ( + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.resumeCommand && + !args.commandOverride?.trim() + ) { + return { command: args.session.resumeCommand } + } + const platform = + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.executionHostPlatform + ? args.session.executionHostPlatform + : getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) // Why: the queued command is typed verbatim into the freshly spawned tab whose // live shell is the configured Windows shell (default PowerShell). Hardcoding diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index a2c827cf99b..207bc525a59 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, @@ -68,6 +69,51 @@ describe('ai vault session storage compatibility', () => { ).toBe(true) }) + it('allows host-tagged SSH sessions only on the matching SSH target', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:other-box' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local', + targetExecutionHostId: 'local' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local' + }) + ).toBe(false) + }) + + it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: wslSessionFile, + sessionExecutionHostId: 'local', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + }) + it('never allows runtime or unknown targets', () => { expect( canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) @@ -166,6 +212,34 @@ describe('ai vault resume target ownership', () => { ).toBe('runtime') }) + it('resolves exact execution host ids for active workspaces', () => { + const state = makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1', hostId: 'ssh:ssh-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }) + + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'repo-1::/repo/orca')).toBe('ssh:ssh-1') + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'worktree:repo-1::/repo/orca')).toBe( + 'ssh:ssh-1' + ) + }) + + it('resolves local execution host ids for local workspaces', () => { + expect( + getAiVaultResumeWorkspaceExecutionHostId( + makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }), + 'repo-1::/repo/orca' + ) + ).toBe('local') + }) + it('supports folder workspaces owned by SSH project groups', () => { expect( getAiVaultResumeWorkspaceTargetStatus( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 74a1c8c48f2..823840ec85b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, @@ -43,11 +44,33 @@ export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | u export function canResumeAiVaultSessionOnTarget(args: { sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null targetStatus: AiVaultResumeTargetStatus + targetExecutionHostId?: ExecutionHostId | null }): boolean { if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { return false } + const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) + if (sessionExecutionHostId) { + if (targetExecutionHostId) { + if (sessionExecutionHostId === targetExecutionHostId) { + return true + } + // Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the + // file lives under a WSL UNC path reachable from any SSH shell into this + // machine, so we bypass the exact host-id match for that case. + return ( + sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID && + args.targetStatus === 'ssh' && + isWslStoredAiVaultSessionFile(args.sessionFilePath) + ) + } + if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) { + return false + } + } // Why: vault sessions are scanned from this machine's disk (host home dirs // plus local WSL homes). An SSH shell can only reach the WSL-stored ones // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist @@ -85,6 +108,32 @@ export function getAiVaultResumeWorktreeTargetStatus(args: { ) } +export function getAiVaultResumeWorkspaceExecutionHostId( + state: Pick, + workspaceId: string | null +): ExecutionHostId | null { + if (!workspaceId) { + return null + } + + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type === 'folder') { + return getAiVaultResumeFolderExecutionHostId(state, workspaceKey.folderWorkspaceId) + } + + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + const worktree = Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === worktreeId) + const worktreeHostId = normalizeExecutionHostId(worktree?.hostId) + if (worktreeHostId) { + return worktreeHostId + } + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + export function getAiVaultResumeWorkspaceTargetStatus( state: Pick, workspaceId: string | null @@ -136,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus( ) } +function getAiVaultResumeFolderExecutionHostId( + state: Pick, + folderWorkspaceId: string +): ExecutionHostId | null { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + const groupHostId = normalizeExecutionHostId(group?.executionHostId) + if (groupHostId) { + return groupHostId + } + const explicitConnectionId = (workspace.connectionId ?? group?.connectionId ?? '').trim() + if (explicitConnectionId) { + return toSshExecutionHostId(explicitConnectionId) + } + return mergeAiVaultResumeExecutionHostIds( + getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getRepoExecutionHostId) + ) +} + function getAiVaultResumeExecutionHostTargetStatus( hostId: ExecutionHostId | null | undefined ): AiVaultResumeTargetStatus { @@ -162,3 +234,13 @@ function mergeAiVaultResumeExecutionHostTargetStatuses( } return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' } + +function mergeAiVaultResumeExecutionHostIds( + hostIds: readonly ExecutionHostId[] +): ExecutionHostId | null { + if (hostIds.length === 0) { + return LOCAL_EXECUTION_HOST_ID + } + const uniqueHostIds = new Set(hostIds) + return uniqueHostIds.size === 1 ? (hostIds[0] ?? null) : null +} diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 2b52359e534..7b5ec941dd7 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -1,6 +1,7 @@ import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session' export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start' @@ -15,6 +16,7 @@ export type AiVaultSessionDragPayload = { // Why: drop targets must know where the session file lives (host vs local // WSL) to reject SSH panes that cannot reach it. sessionFilePath?: string + sessionExecutionHostId?: ExecutionHostId // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -67,6 +69,8 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && + (payload.sessionExecutionHostId === undefined || + Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -114,13 +118,23 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed + const { + agent, + sessionId, + title, + command, + sessionFilePath, + sessionExecutionHostId, + env, + launchConfig + } = parsed return { agent, sessionId, title, command, ...(sessionFilePath ? { sessionFilePath } : {}), + ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 70d2054d623..58bf82597ab 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -5,6 +5,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { TuiAgent } from './types' +import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', @@ -53,6 +54,8 @@ export type AiVaultSessionPreviewMessage = { export type AiVaultSession = { id: string + executionHostId: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null agent: AiVaultAgent sessionId: string title: string @@ -71,6 +74,7 @@ export type AiVaultSession = { } export type AiVaultScanIssue = { + executionHostId?: ExecutionHostId agent: AiVaultAgent path: string message: string @@ -82,6 +86,7 @@ export type AiVaultListArgs = { // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] + executionHostScope?: ExecutionHostScope } export type AiVaultListResult = { diff --git a/tests/e2e/ssh-ai-vault-session-history.spec.ts b/tests/e2e/ssh-ai-vault-session-history.spec.ts new file mode 100644 index 00000000000..0cb6a485ca4 --- /dev/null +++ b/tests/e2e/ssh-ai-vault-session-history.spec.ts @@ -0,0 +1,293 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver' +import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('SSH Agent Session History', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.') + + test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const stamp = Date.now() + const defaultSessionId = `remote-ai-vault-${stamp}` + const runtimeSessionId = `remote-ai-vault-runtime-${stamp}` + const claudeSessionId = `remote-ai-vault-claude-${stamp}` + const defaultTitle = `Remote AI Vault ${stamp}` + const runtimeTitle = `Remote Runtime AI Vault ${stamp}` + const claudeTitle = `Remote Claude AI Vault ${stamp}` + + try { + target = startDockerSshRelayTarget(testInfo) + seedRemoteAiVaultHistory(target, { + defaultSessionId, + runtimeSessionId, + claudeSessionId, + defaultTitle, + runtimeTitle, + claudeTitle + }) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + const sshScope = `ssh:${encodeURIComponent(remote.targetId)}` + + const scan = await orcaPage.evaluate( + async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => { + const local = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + const ssh = await window.api.aiVault.listSessions({ + executionHostScope: sshScope, + force: true + }) + const all = await window.api.aiVault.listSessions({ + executionHostScope: 'all', + force: true + }) + return { + localHasRemote: local.sessions.some((session) => session.title === defaultTitle), + sshTitles: ssh.sessions.map((session) => session.title), + allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle), + allHasClaude: all.sessions.some((session) => session.title === claudeTitle), + remoteHostIds: ssh.sessions + .filter((session) => + [defaultTitle, runtimeTitle, claudeTitle].includes(session.title) + ) + .map((session) => session.executionHostId), + remoteCommands: ssh.sessions + .filter((session) => session.title === defaultTitle || session.title === runtimeTitle) + .map((session) => session.resumeCommand) + } + }, + { sshScope, defaultTitle, runtimeTitle, claudeTitle } + ) + expect(scan.localHasRemote).toBe(false) + expect(scan.sshTitles).toEqual( + expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle]) + ) + expect(scan.allHasRuntime).toBe(true) + expect(scan.allHasClaude).toBe(true) + expect(new Set(scan.remoteHostIds)).toEqual(new Set([sshScope])) + expect(scan.remoteCommands.join('\n')).toContain("CODEX_HOME='/root/.codex'") + expect(scan.remoteCommands.join('\n')).toContain( + "CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'" + ) + + const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true }) + const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true }) + + await openAiVaultSidebar(orcaPage) + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + const hostButton = orcaPage.getByRole('button', { name: /Session History host:/ }) + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: /Local/ }).click() + await expect(defaultSessionTitle).toHaveCount(0, { timeout: 30_000 }) + + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: 'All hosts' }).click() + await expect(runtimeSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await hostButton.click() + await orcaPage + .getByRole('menuitemradio') + .filter({ hasNotText: /Local|All hosts/ }) + .click() + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await installStartupQueueProbe(orcaPage) + await defaultSessionTitle.first().click() + await orcaPage.getByText('Resume in Worktree', { exact: true }).click() + + await expect + .poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 }) + .toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`) + const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage) + expect(queuedWorktreeId).toBe(remote.worktreeId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) + +function seedRemoteAiVaultHistory( + target: DockerSshRelayTarget, + args: { + defaultSessionId: string + runtimeSessionId: string + claudeSessionId: string + defaultTitle: string + runtimeTitle: string + claudeTitle: string + } +): void { + dockerExec( + target, + [ + 'mkdir -p /root/.codex/sessions/2026/07/04', + 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', + 'mkdir -p /root/.claude/projects/orca' + ].join(' && ') + ) + dockerWriteFile( + target, + '/root/.codex/session_index.jsonl', + jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), + '600' + ) + dockerWriteFile( + target, + `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, + codexTranscript({ + sessionId: args.defaultSessionId, + title: args.defaultTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T01:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, + codexTranscript({ + sessionId: args.runtimeSessionId, + title: args.runtimeTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T02:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, + claudeTranscript({ + sessionId: args.claudeSessionId, + title: args.claudeTitle, + timestamp: '2026-07-04T03:00:00.000Z' + }), + '600' + ) +} + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { id: args.sessionId, cwd: args.cwd } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { + return jsonLines([ + { + sessionId: args.sessionId, + timestamp: args.timestamp, + type: 'user', + message: { content: [{ type: 'text', text: args.title }] } + }, + { + sessionId: args.sessionId, + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } + } + ]) +} + +async function openAiVaultSidebar(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) +} + +async function installStartupQueueProbe(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string; startup: { command: string } }[] + } + holder.__aiVaultQueuedStartups = [] + const current = store.getState() + const original = current.queueTabStartupCommand + store.setState({ + queueTabStartupCommand: (tabId, startup) => { + holder.__aiVaultQueuedStartups?.push({ tabId, startup: { command: startup.command } }) + original(tabId, startup) + } + }) + }) +} + +async function readLastQueuedStartupCommand(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { startup: { command: string } }[] + } + return holder.__aiVaultQueuedStartups?.at(-1)?.startup.command ?? null + }) +} + +async function readLastQueuedStartupWorktreeId(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string }[] + } + const tabId = holder.__aiVaultQueuedStartups?.at(-1)?.tabId + if (!tabId) { + return null + } + const state = window.__store?.getState() + if (!state) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null + }) +} From 17d5eff5d69cd088311698a35c2f291aed53db48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:02:14 +0000 Subject: [PATCH 111/141] release: v1.4.123-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 083696f8850..eb6139b6668 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.123-rc.0", + "version": "1.4.123-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 136cb50bcbb740814fbf7d0d90e9bee730f39161 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:29:05 -0700 Subject: [PATCH 112/141] Reflow and edit hard-wrapped prose within single paragraph blocks (#7407) * Reflow and edit hard-wrapped prose within single paragraph blocks Instead of splitting consecutive markdown source lines into multiple visual paragraph nodes during document initialization, preserve them as a single paragraph containing literal newlines. - Use `white-space: normal` CSS to reflow soft breaks naturally. - Introduce `deleteAdjacentEmptyParagraph` to handle Backspace/Delete without converting soft newlines to hard break elements. - Update the cut handler to delete only a visual line on Cmd+X within hard-wrapped paragraphs. - Avoid split-pane/sync phantom dirty states caused by structural block splitting. * Document why normalizeEmptyListItems is used for paragraph reflow Add comments to clarify that normalizeEmptyListItems preserves hard-wrapped paragraphs as single paragraphs, allowing them to reflow via CSS instead of being split on load or external sync. --- .../src/assets/rich-markdown-editor.css | 7 + .../components/editor/RichMarkdownEditor.tsx | 4 +- .../editor/RichMarkdownErrorBoundary.tsx | 2 +- .../editor/markdown-dirty-state.test.ts | 42 +-- .../editor/rich-markdown-cut.test.ts | 142 +++++--- .../editor/rich-markdown-editor-config.ts | 7 +- ...ch-markdown-empty-paragraph-delete.test.ts | 311 ++++++++++++++++++ .../rich-markdown-empty-paragraph-delete.ts | 241 ++++++++++++++ .../editor/rich-markdown-key-handler.ts | 16 +- .../editor/rich-markdown-normalize.test.ts | 20 +- .../editor/rich-markdown-normalize.ts | 31 +- .../editor/useRichMarkdownProgrammaticSync.ts | 6 +- .../src/hooks/useEditorExternalWatch.ts | 2 +- tests/e2e/markdown-prose-reflow.spec.ts | 246 ++++++++++++++ 14 files changed, 987 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts create mode 100644 tests/e2e/markdown-prose-reflow.spec.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index a8a03d39ca4..395b18131a7 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -566,6 +566,13 @@ margin: 0.75em 0; } +/* Why: document markdown files treat source newlines inside a paragraph as + soft breaks, so prose must collapse them instead of inheriting Tiptap's + editable-root `break-spaces` behavior. */ +.rich-markdown-editor-shell .rich-markdown-editor p { + white-space: normal; +} + .rich-markdown-editor .tiptap-mathematics-render { border-radius: 4px; } diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 28f275a0437..990d2fdaf38 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -114,12 +114,12 @@ export default function RichMarkdownEditor({ const editorRef = useRef(null) const cancelAutoFocusRef = useRef<(() => void) | null>(null) const serializeTimerRef = useRef(null) - // Why: normalizeSoftBreaks dispatches a ProseMirror transaction inside onCreate + // Why: empty-list repair dispatches a ProseMirror transaction inside onCreate // which triggers onUpdate. Without this guard the editor immediately marks the // file dirty before the user has typed anything. const isInitializingRef = useRef(true) // Why: internal maintenance paths can dispatch transactions after mount - // (external reloads, soft-break normalization, image-path refresh). Those + // (external reloads, empty-list repair, image-path refresh). Those // are not user edits, so onUpdate must ignore them or split panes can flip a // shared file dirty without any real content change. const isApplyingProgrammaticUpdateRef = useRef(false) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index c99cc1a675b..105be54524f 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -13,7 +13,7 @@ type State = { } // Why: a thrown exception inside the TipTap/ProseMirror render or in the -// effect that runs `setContent` + `normalizeSoftBreaks` on external-reload +// effect that runs `setContent` + empty-list repair on external-reload // would escape to the React root and — without this boundary — cause React // 18 to unmount the entire renderer subtree, blacking out the whole Orca // window (see issue #826). Scoping the boundary to the rich-markdown editor diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts index 108772204a0..46b786ddd75 100644 --- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts +++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts @@ -8,7 +8,7 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' const testExtensions = [ StarterKit, @@ -49,12 +49,12 @@ function shouldSyncPropIntoEditor( } /** - * Simulates the onCreate flow: normalizeSoftBreaks then getMarkdown(). + * Simulates the onCreate flow: empty-list repair then getMarkdown(). */ function simulateOnCreate(diskContent: string): string { const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) return editor.getMarkdown() } finally { editor.destroy() @@ -65,7 +65,7 @@ function simulateOnCreate(diskContent: string): string { // 1. trimEnd normalization prevents phantom dirty from trailing newlines // // getMarkdown() always appends a trailing \n. For content that round-trips -// cleanly (no soft-break normalization), the ONLY difference is that +// cleanly (no structural repair changes), the ONLY difference is that // trailing newline. trimEnd() must eliminate that false positive. // ----------------------------------------------------------------------- describe('trailing newline does not cause false dirty state', () => { @@ -94,45 +94,37 @@ describe('trailing newline does not cause false dirty state', () => { }) // ----------------------------------------------------------------------- -// 2. normalizeSoftBreaks produces structural differences that getMarkdown() -// serializes differently. These cannot be hidden by trimEnd — they are -// handled at runtime by the isInitializingRef guard in onUpdate. -// -// The tests below document the known divergence so that future changes -// to the serializer or normalizer don't silently shift which category -// a given input falls into. +// 2. Hard-wrapped prose must stay structurally clean. The rich editor renders +// soft breaks through CSS reflow, not by splitting the document model. // ----------------------------------------------------------------------- -describe('normalizeSoftBreaks: known structural changes', () => { - it('splits consecutive lines into separate paragraphs', () => { +describe('document soft-break round-trip', () => { + it('keeps consecutive source lines in one paragraph', () => { const editor = createEditor('Line one\nLine two\nLine three') try { const before = countParagraphs(editor) - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const after = countParagraphs(editor) - expect(after).toBeGreaterThan(before) - expect(after).toBe(3) + expect(before).toBe(1) + expect(after).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe('Line one\nLine two\nLine three') } finally { editor.destroy() } }) - it('serialized soft-break content differs from disk content', () => { + it('round-trips a hard-wrapped paragraph without blank-line expansion', () => { const disk = 'Line one\nLine two' const serialized = simulateOnCreate(disk) - // After normalization each line is its own paragraph, serialized with - // blank-line separators. This difference is NOT a bug — the - // isInitializingRef guard prevents it from marking the file dirty. - expect(trimEnd(serialized)).not.toBe(trimEnd(disk)) - expect(trimEnd(serialized)).toBe('Line one\n\nLine two') + expect(trimEnd(serialized)).toBe(trimEnd(disk)) }) it('does not modify content without soft breaks', () => { const editor = createEditor('# Title\n\nBody text') try { const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() expect(docAfter).toEqual(docBefore) @@ -171,7 +163,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# README\n\nOriginal text' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Insert text via a ProseMirror transaction (no DOM required) const { tr } = editor.state @@ -189,7 +181,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# Title\n\nParagraph to keep\n\nParagraph to delete' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Delete the last paragraph node const doc = editor.state.doc diff --git a/src/renderer/src/components/editor/rich-markdown-cut.test.ts b/src/renderer/src/components/editor/rich-markdown-cut.test.ts index 3ca4064c4b8..67eb9f3766f 100644 --- a/src/renderer/src/components/editor/rich-markdown-cut.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-cut.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -8,7 +11,8 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { handleRichMarkdownCut } from './rich-markdown-cut-handler' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' /** * Minimal extensions matching the rich editor schema without UI dependencies. @@ -33,6 +37,10 @@ function createEditor(markdown: string): Editor { }) } +afterEach(() => { + vi.restoreAllMocks() +}) + /** * Simulates the cut handler's depth walk to determine what node would be cut. * This mirrors the logic in RichMarkdownEditor.tsx handleDOMEvents.cut, @@ -101,6 +109,25 @@ function countParagraphs(editor: Editor): number { return count } +function createClipboardEventMock(): { + data: Map + event: ClipboardEvent + preventDefault: ReturnType +} { + const data = new Map() + const preventDefault = vi.fn() + const event = { + clipboardData: { + setData: vi.fn((type: string, value: string) => { + data.set(type, value) + }) + }, + preventDefault + } as unknown as ClipboardEvent + + return { data, event, preventDefault } +} + describe('rich markdown cut handler behavior', () => { it('heading + paragraph: cuts only the paragraph', () => { const editor = createEditor('# Title\n\nBody text here.\n') @@ -121,20 +148,25 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('multi-line markdown (no blank separator): after normalization, each line is a separate paragraph', () => { + it('hard-wrapped document prose stays one paragraph after normalization', () => { const editor = createEditor('Line one\nLine two\nLine three\n') - // Before normalization: single paragraph with \n chars expect(countParagraphs(editor)).toBe(1) expect(editor.state.doc.firstChild!.textContent).toContain('\n') - // Normalize: splits the single paragraph into three + normalizeEmptyListItems(editor) + + expect(countParagraphs(editor)).toBe(1) + expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three') + + editor.destroy() + }) + + it('soft-break normalization still creates visible paragraph breaks', () => { + const editor = createEditor('Line one\nLine two\nLine three\n') normalizeSoftBreaks(editor) - // After normalization: three separate paragraph nodes expect(countParagraphs(editor)).toBe(3) - - // Each paragraph is its own line — no \n inside any of them const paragraphs: string[] = [] editor.state.doc.forEach((node) => { if (node.type.name === 'paragraph') { @@ -144,37 +176,71 @@ describe('rich markdown cut handler behavior', () => { }) expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three']) - // Cmd+X on the first paragraph only cuts "Line one", not all three lines - const result = simulateCut(editor, 1) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line one') - editor.destroy() }) - it('multi-line: Cmd+X on second line only cuts that line after normalization', () => { - const editor = createEditor('Line one\nLine two\nLine three\n') - normalizeSoftBreaks(editor) + it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => { + const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays') + try { + normalizeEmptyListItems(editor) + expect(countParagraphs(editor)).toBe(1) - // Find the second paragraph ("Line two") - const doc = editor.state.doc - let secondParaPos = -1 - let count = 0 - doc.forEach((node, offset) => { - if (node.type.name === 'paragraph') { - count++ - if (count === 2) { - secondParaPos = offset + 1 + const text = editor.state.doc.firstChild!.textContent + const paraStart = 1 + const paraEnd = paraStart + text.length + const lineFrom = paraStart + text.indexOf('Middle') + const nextLineFrom = paraStart + text.indexOf('Omega') + const cursorPos = lineFrom + 'Middle'.length + + let viewState = editor.state.apply( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, cursorPos)) + ) + + const paragraphElement = document.createElement('p') + vi.spyOn(paragraphElement, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({ x: 20, y: 0, width: 600, height: 60 }) + ) + const view = { + get state() { + return viewState + }, + dispatch: vi.fn((tr) => { + viewState = viewState.apply(tr) + }), + domAtPos: vi.fn(() => ({ node: paragraphElement, offset: 0 })), + coordsAtPos: vi.fn((pos: number) => { + if (pos === paraStart) { + return { top: 0, bottom: 20, left: 20, right: 20 } + } + if (pos === paraEnd) { + return { top: 40, bottom: 60, left: 280, right: 280 } + } + return { top: 20, bottom: 40, left: 120, right: 120 } + }), + posAtCoords: vi.fn((coords: { top: number }) => { + return { pos: coords.top < 40 ? lineFrom : nextLineFrom, inside: -1 } + }) + } as unknown as EditorView + + const clipboard = createClipboardEventMock() + const handled = handleRichMarkdownCut(view, clipboard.event) + + expect(handled).toBe(true) + expect(clipboard.preventDefault).toHaveBeenCalled() + expect(clipboard.data.get('text/plain')).toBe('Middle segment is cut\n') + expect(view.state.doc.firstChild!.textContent).toBe( + 'Alpha segment stays\nOmega segment stays' + ) + let paragraphCount = 0 + view.state.doc.forEach((node) => { + if (node.type.name === 'paragraph') { + paragraphCount++ } - } - }) - - expect(secondParaPos).toBeGreaterThan(0) - const result = simulateCut(editor, secondParaPos) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line two') - - editor.destroy() + }) + expect(paragraphCount).toBe(1) + } finally { + editor.destroy() + } }) it('paragraphs separated by blank lines are separate blocks', () => { @@ -348,11 +414,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks is idempotent on already-clean documents', () => { + it('normalizeEmptyListItems is idempotent on already-clean documents', () => { const editor = createEditor('First.\n\nSecond.\n\nThird.\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // Already separated paragraphs should not be modified @@ -361,11 +427,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks does not modify list items or blockquotes', () => { + it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => { const editor = createEditor('- Item 1\n- Item 2\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // List structure should be unchanged (no top-level paragraphs to split) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index de2e520d631..28536b258c5 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -2,7 +2,7 @@ import type { Editor, UseEditorOptions } from '@tiptap/react' import { handleRichMarkdownCut } from './rich-markdown-cut-handler' import { handleRichMarkdownPaste } from './rich-markdown-paste-handler' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { syncSlashMenu, @@ -208,7 +208,10 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE clearAnnotationTarget() }, onCreate: ({ editor: nextEditor }) => { - normalizeSoftBreaks(nextEditor) + // Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped + // source paragraphs stay one paragraph and reflow via CSS instead of being + // split on load. + normalizeEmptyListItems(nextEditor) lastCommittedMarkdownRef.current = content isInitializingRef.current = false cancelAutoFocusRef.current?.() diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts new file mode 100644 index 00000000000..bfdb60af423 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Markdown } from '@tiptap/markdown' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' + +const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] + +const hardWrappedMarkdown = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + '## Next section' + +const hardWrappedTwoParagraphs = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + 'Second paragraph starts here.' + +const headingBeforeHardWrappedParagraph = + '## Existing section\n\n' + + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + +function createEditor(content = hardWrappedMarkdown): Editor { + return new Editor({ + element: null, + extensions, + content, + contentType: 'markdown' + }) +} + +function findNodeTextPosition( + editor: Editor, + nodeName: string, + textNeedle: string +): { from: number; to: number } { + let result: { from: number; to: number } | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === nodeName && node.textContent.includes(textNeedle)) { + result = { from: pos + 1, to: pos + node.nodeSize - 1 } + return false + } + return true + }) + + if (!result) { + throw new Error(`Could not find ${nodeName} containing ${textNeedle}`) + } + + return result +} + +function countHardBreaks(editor: Editor): number { + let count = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'hardBreak') { + count += 1 + } + }) + return count +} + +function expectHardWrappedParagraphPreserved(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown().trimEnd()).toBe(hardWrappedMarkdown) +} + +function expectNoHardBreaks(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) +} + +describe('rich markdown empty paragraph deletion', () => { + it('Backspace in an inserted empty paragraph preserves soft-wrapped prose', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace at the next block start removes a previous empty paragraph without joining', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete at a soft-wrapped paragraph end removes the following empty paragraph', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + editor.commands.setTextSelection(paragraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete in an empty paragraph before soft-wrapped prose preserves soft newlines', () => { + const editor = createEditor(`\n\n${hardWrappedMarkdown}`) + try { + editor.commands.setTextSelection(1) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace after deleting slash text in an empty command paragraph preserves soft newlines', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + const slashPos = editor.state.selection.from + editor.view.dispatch(editor.state.tr.insertText('/', slashPos)) + editor.view.dispatch(editor.state.tr.delete(slashPos, slashPos + 1)) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('Delete joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const heading = findNodeTextPosition(editor, 'heading', 'Existing section') + editor.commands.setTextSelection(heading.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('works inside blockquotes without converting soft newlines to hard breaks', () => { + const editor = createEditor( + '> Alpha owns launch-lifetime state keyed by tab id, while native\n' + + '> chat owns conversion, ordering, and transcript reconciliation.\n' + + '>\n' + + '> Second paragraph starts here.' + ) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts new file mode 100644 index 00000000000..37081d47813 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts @@ -0,0 +1,241 @@ +import type { Editor } from '@tiptap/react' +import { Fragment, type Node as PmNode, type Schema } from '@tiptap/pm/model' +import { TextSelection, type Transaction } from '@tiptap/pm/state' + +const BLOCKED_CONTAINER_TYPES = new Set(['listItem', 'taskItem', 'tableCell', 'tableHeader']) +const PARAGRAPH_JOIN_NEIGHBOR_TYPES = new Set(['paragraph', 'heading']) + +function isEmptyParagraph(node: PmNode | null | undefined): boolean { + return node?.type.name === 'paragraph' && node.content.size === 0 +} + +function hasSoftNewline(node: PmNode): boolean { + return node.textContent.includes('\n') +} + +function setSelectionNear(tr: Transaction, pos: number): void { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)) + tr.setSelection(TextSelection.near(tr.doc.resolve(clampedPos))) +} + +type TextblockBoundary = { + after: PmNode | null + before: PmNode | null + current: PmNode + currentEnd: number + currentStart: number +} + +function getTextblockBoundary(editor: Editor): TextblockBoundary | null { + const { selection } = editor.state + if (!selection.empty || !selection.$from.parent.isTextblock) { + return null + } + + const { $from } = selection + const textblockDepth = $from.depth + for (let depth = 1; depth < textblockDepth; depth += 1) { + if (BLOCKED_CONTAINER_TYPES.has($from.node(depth).type.name)) { + return null + } + } + + const parentIndex = $from.index(textblockDepth - 1) + const container = $from.node(textblockDepth - 1) + return { + after: container.maybeChild(parentIndex + 1) ?? null, + before: container.maybeChild(parentIndex - 1) ?? null, + current: $from.parent, + currentEnd: $from.after(textblockDepth), + currentStart: $from.before(textblockDepth) + } +} + +type SoftNewlineJoin = { + nodes: PmNode[] + selectionOffset: number +} + +function splitParagraphAtFirstSoftNewline( + paragraph: PmNode, + schema: Schema +): { after: Fragment; before: Fragment } | null { + const beforeNodes: PmNode[] = [] + const afterNodes: PmNode[] = [] + let foundSoftNewline = false + + paragraph.content.forEach((child) => { + if (foundSoftNewline) { + afterNodes.push(child) + return + } + + if (!child.isText || !child.text?.includes('\n')) { + beforeNodes.push(child) + return + } + + const newlineIndex = child.text.indexOf('\n') + if (newlineIndex > 0) { + beforeNodes.push(schema.text(child.text.slice(0, newlineIndex), child.marks)) + } + if (newlineIndex + 1 < child.text.length) { + afterNodes.push(schema.text(child.text.slice(newlineIndex + 1), child.marks)) + } + foundSoftNewline = true + }) + + if (!foundSoftNewline) { + return null + } + + return { + after: Fragment.fromArray(afterNodes), + before: Fragment.fromArray(beforeNodes) + } +} + +function createSoftNewlineJoin( + left: PmNode, + right: PmNode, + schema: Schema +): SoftNewlineJoin | null { + if (left.type.name === 'paragraph' && PARAGRAPH_JOIN_NEIGHBOR_TYPES.has(right.type.name)) { + if (!hasSoftNewline(left) && (right.type.name !== 'paragraph' || !hasSoftNewline(right))) { + return null + } + + // Why: ProseMirror Transform.join substitutes paragraph text `\n` with + // hardBreak nodes at textblock boundaries, which makes hard-wrapped prose narrow. + const content = left.content.append(right.content) + if (!left.type.validContent(content)) { + return null + } + + return { + nodes: [left.type.create(left.attrs, content, left.marks)], + selectionOffset: left.content.size + } + } + + if (left.type.name !== 'heading' || right.type.name !== 'paragraph' || !hasSoftNewline(right)) { + return null + } + + const split = splitParagraphAtFirstSoftNewline(right, schema) + if (!split) { + return null + } + + const headingContent = left.content.append(split.before) + if (!left.type.validContent(headingContent)) { + return null + } + + const nodes = [left.type.create(left.attrs, headingContent, left.marks)] + if (split.after.size > 0) { + if (!right.type.validContent(split.after)) { + return null + } + nodes.push(right.type.create(right.attrs, split.after, right.marks)) + } + + return { + nodes, + selectionOffset: left.content.size + } +} + +function dispatchSoftNewlineJoin( + editor: Editor, + from: number, + to: number, + join: SoftNewlineJoin +): void { + const tr = editor.state.tr.replaceWith(from, to, join.nodes) + setSelectionNear(tr, from + 1 + join.selectionOffset) + editor.view.dispatch(tr) +} + +export function deleteAdjacentEmptyParagraph(editor: Editor, direction: 'backward' | 'forward') { + const boundary = getTextblockBoundary(editor) + if (!boundary) { + return false + } + + const { selection } = editor.state + const { $from } = selection + const { after, before, current, currentEnd, currentStart } = boundary + + if (direction === 'backward') { + if ($from.parentOffset !== 0) { + return false + } + + if (isEmptyParagraph(current)) { + if (!before) { + return false + } + // Why: ProseMirror's default Backspace join converts soft `\n` text in + // the previous paragraph into hardBreak nodes. Delete the blank block only. + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart - 1) + editor.view.dispatch(tr) + return true + } + + if (before && isEmptyParagraph(before)) { + const from = currentStart - before.nodeSize + const tr = editor.state.tr.delete(from, currentStart) + setSelectionNear(tr, tr.mapping.map(selection.from, -1)) + editor.view.dispatch(tr) + return true + } + + if (before && current.isTextblock && before.isTextblock) { + const join = createSoftNewlineJoin(before, current, editor.state.schema) + if (!join) { + return false + } + const from = currentStart - before.nodeSize + dispatchSoftNewlineJoin(editor, from, currentEnd, join) + return true + } + + return false + } + + if ($from.parentOffset !== current.content.size) { + return false + } + + if (isEmptyParagraph(current)) { + if (!after) { + return false + } + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart) + editor.view.dispatch(tr) + return true + } + + if (after && isEmptyParagraph(after)) { + // Why: Delete at the end of a soft-wrapped paragraph should remove the + // blank line without running ProseMirror's newline-to-hardBreak join path. + const tr = editor.state.tr.delete(currentEnd, currentEnd + after.nodeSize) + setSelectionNear(tr, currentEnd - 1) + editor.view.dispatch(tr) + return true + } + + if (after && current.isTextblock && after.isTextblock) { + const join = createSoftNewlineJoin(current, after, editor.state.schema) + if (!join) { + return false + } + dispatchSoftNewlineJoin(editor, currentStart, currentEnd + after.nodeSize, join) + return true + } + + return false +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 42111c8c368..b5e9ced95a9 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -17,6 +17,7 @@ import { convertEmptyNestedOrderedItemToContinuation, exitTrailingEmptyOrderedListItem } from './rich-markdown-list-continuation' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' export type KeyHandlerContext = { isMac: boolean @@ -162,7 +163,20 @@ export function createRichMarkdownKeyHandler( ed && !isComposingMarkdownInput(event, ed) && (convertEmptyNestedOrderedItemToContinuation(ed) || - collapseEmptyListContinuationParagraph(ed)) + collapseEmptyListContinuationParagraph(ed) || + deleteAdjacentEmptyParagraph(ed, 'backward')) + ) { + event.preventDefault() + return true + } + } + + if (event.key === 'Delete') { + const ed = ctx.editorRef.current + if ( + ed && + !isComposingMarkdownInput(event, ed) && + deleteAdjacentEmptyParagraph(ed, 'forward') ) { event.preventDefault() return true diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts index 62599e8df7b..01cb1d96cb2 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] @@ -24,7 +24,7 @@ describe('rich markdown normalization', () => { const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n') try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const list = editor.state.doc.child(0) const emptyItem = list.child(2) @@ -37,7 +37,21 @@ describe('rich markdown normalization', () => { } }) - it('normalizes newline-heavy soft-break paragraphs without splitting text nodes', () => { + it('leaves hard-wrapped document prose as one paragraph', () => { + const editor = createEditor('Line one\nLine two\nLine three') + + try { + normalizeEmptyListItems(editor) + + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.child(0).type.name).toBe('paragraph') + expect(editor.state.doc.child(0).textContent).toBe('Line one\nLine two\nLine three') + } finally { + editor.destroy() + } + }) + + it('keeps soft-break splitting without splitting text nodes', () => { const editor = createEditor('Line one\nLine two\nLine three') try { diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts index c1beb2d7773..695d99440ef 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts @@ -1,22 +1,19 @@ import type { Editor } from '@tiptap/core' import { Fragment, type Node as PmNode } from '@tiptap/pm/model' -/** - * Why: the `marked` parser (with `breaks: false`, the default) treats consecutive - * lines without a blank separator as a single paragraph with literal `\n` characters - * in the text content (e.g. "Line one\nLine two\nLine three"). These `\n` chars are - * invisible in the rendered HTML (normal `white-space` collapsing), but they cause - * the block-cut handler to remove the entire multi-line paragraph on Cmd+X instead - * of just one logical line. - * - * This function normalises the ProseMirror document by splitting any paragraph whose - * text nodes contain `\n` into separate paragraph nodes — one per line — and by - * giving empty parsed list items a paragraph caret target. Inline marks (bold, - * italic, links, etc.) are preserved on each resulting paragraph. This is - * structurally correct for the editing model: each visual line becomes its own block, - * so the cut handler (and all other block-level operations) work on a per-line basis. - */ +type NormalizeOptions = { + splitSoftBreakParagraphs: boolean +} + +export function normalizeEmptyListItems(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: false }) +} + export function normalizeSoftBreaks(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: true }) +} + +function normalizeRichMarkdownDocument(editor: Editor, options: NormalizeOptions): void { // Why: we read from editor.view.state (not editor.state) so that the doc // we traverse and the transaction we later create share the same base state. // After setContent(), editor.state can be stale (last React render), while @@ -52,6 +49,10 @@ export function normalizeSoftBreaks(editor: Editor): void { if (node.type !== paragraphType) { return true // continue descending into container nodes } + if (!options.splitSoftBreakParagraphs) { + // Document-editor prose reflows soft breaks through CSS, preserving clean diffs. + return false + } if (!node.textContent.includes('\n')) { return false // no need to descend into inline content } diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index db34644c66a..2defa250401 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react' import type { MarkdownDocument } from '../../../../shared/types' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' import { createRichMarkdownImageResolverContext, @@ -133,7 +133,9 @@ function applyExternalRichMarkdownContent( contentType: 'markdown', emitUpdate: false }) - normalizeSoftBreaks(editor) + // Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from + // external content, matching onCreate's single-paragraph reflow behavior. + normalizeEmptyListItems(editor) lastCommittedMarkdownRef.current = content if (hadFocus) { const docSize = editor.state.doc.content.size diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index de0f681998c..157b91df034 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -33,7 +33,7 @@ import { isGitRepoKind } from '../../../shared/repo-kind' // VSCode) land as a short burst of `update` events — or `delete + create` on // renamers — within a few milliseconds for the same path. Dispatching an // `ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT` per raw event fan-outs into N full -// `setContent` + `normalizeSoftBreaks` doc rebuilds per mounted EditorPanel, +// `setContent` + document-repair rebuilds per mounted EditorPanel, // which under split-pane + large markdown is enough to wedge the renderer // and black out the window (issue #826). Coalescing per (worktreeId + path) // on a short debounce collapses that burst into one reload notification. diff --git a/tests/e2e/markdown-prose-reflow.spec.ts b/tests/e2e/markdown-prose-reflow.spec.ts new file mode 100644 index 00000000000..6767d3ea2ae --- /dev/null +++ b/tests/e2e/markdown-prose-reflow.spec.ts @@ -0,0 +1,246 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const HARD_WRAPPED_MARKDOWN = + 'The store owns launch-lifetime presentation state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation through pure\n' + + 'helpers. This keeps the bridge removable later if provider-native launch\n' + + 'delivery becomes universal.\n\n' + + '## Next section\n' + +type ReflowMetrics = { + hardBreakCount: number + lineCount: number + paragraphCount: number + sourceLineCount: number + textContent: string + whiteSpace: string +} + +type PageEditorDocNode = { + textContent?: string + type?: { name?: string } +} + +type PageRichMarkdownEditorElement = Element & { + editor?: { + commands?: { + focus?: () => boolean + setTextSelection?: (position: number) => boolean + } + state?: { + doc?: { + descendants?: (callback: (node: PageEditorDocNode, pos: number) => boolean) => void + } + } + } +} + +async function openHardWrappedFixture( + page: Parameters[0], + testInfo: { workerIndex: number } +): Promise { + const context = await getActiveWorktreeContext(page) + const filePath = await createMarkdownFixture( + context, + 'prose-reflow', + testInfo.workerIndex, + HARD_WRAPPED_MARKDOWN + ) + await openMarkdownFixture(page, context, filePath) + await waitForRichMarkdownEditor(page) + return filePath +} + +async function getGoalParagraphMetrics( + page: Parameters[0] +): Promise { + return page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + if (!editor) { + throw new Error('Rich markdown editor was not mounted') + } + + const paragraphs = Array.from(editor.querySelectorAll('p')).filter((paragraph) => + paragraph.textContent?.includes('launch-lifetime') + ) + const paragraph = paragraphs[0] + if (!paragraph) { + throw new Error('Hard-wrapped paragraph was not rendered') + } + + const range = document.createRange() + range.selectNodeContents(paragraph) + const lineTops: number[] = [] + for (const rect of Array.from(range.getClientRects())) { + if (rect.width <= 0 || rect.height <= 0) { + continue + } + if (!lineTops.some((top) => Math.abs(top - rect.top) < 2)) { + lineTops.push(rect.top) + } + } + + return { + hardBreakCount: paragraph.querySelectorAll('br').length, + lineCount: lineTops.length, + paragraphCount: paragraphs.length, + sourceLineCount: paragraph.textContent?.split('\n').length ?? 0, + textContent: paragraph.textContent ?? '', + whiteSpace: window.getComputedStyle(paragraph).whiteSpace + } + }) +} + +async function placeCaretAtGoalParagraphEnd( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const paragraph = Array.from(editorElement?.querySelectorAll('p') ?? []).find((candidate) => + candidate.textContent?.includes('launch-lifetime') + ) as + | (HTMLParagraphElement & { + pmViewDesc?: { + posAtEnd?: number + } + }) + | undefined + const selectionPosition = paragraph?.pmViewDesc?.posAtEnd + if (!editorElement?.editor?.commands || typeof selectionPosition !== 'number') { + throw new Error('Cannot place caret at the hard-wrapped paragraph end') + } + + editorElement.editor.commands.setTextSelection?.(selectionPosition) + editorElement.editor.commands.focus?.() + }) +} + +async function placeCaretAtHeadingStart( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const editor = editorElement?.editor + let selectionPosition: number | null = null + editor?.state?.doc?.descendants?.((node, pos) => { + if (node.type?.name === 'heading' && node.textContent?.includes('Next section')) { + selectionPosition = pos + 1 + return false + } + return true + }) + + if (!editor?.commands || selectionPosition === null) { + throw new Error('Cannot place caret at the heading start') + } + + editor.commands.setTextSelection?.(selectionPosition) + editor.commands.focus?.() + }) +} + +test.describe('Markdown prose reflow', () => { + test.beforeEach(async ({ orcaPage }) => { + await orcaPage.setViewportSize({ width: 1440, height: 900 }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('hard-wrapped prose reflows as one document paragraph', async ({ orcaPage }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting an inserted empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting slash text then the empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type('/') + await orcaPage.keyboard.press('Backspace') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting the block boundary before a heading keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtHeadingStart(orcaPage) + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) From f11a2109c821ea62bf1de3f0845b54e4ec4ec0fd Mon Sep 17 00:00:00 2001 From: guihirsch <121435005+guihirsch@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:33:25 -0300 Subject: [PATCH 113/141] feat(rate-limits): add MiniMax token control (#7387) --- resources/minimax-icon.svg | 9 + .../runtime-home-service.test.ts | 2 + src/main/codex-accounts/service.test.ts | 2 + src/main/index.ts | 9 + src/main/ipc/minimax-credentials.test.ts | 122 +++++ src/main/ipc/minimax-credentials.ts | 33 ++ src/main/ipc/rate-limits.test.ts | 57 +++ src/main/ipc/rate-limits.ts | 1 + src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + src/main/minimax/minimax-cookie-store.test.ts | 148 ++++++ src/main/minimax/minimax-cookie-store.ts | 66 +++ src/main/rate-limits/minimax-fetcher.test.ts | 423 ++++++++++++++++++ src/main/rate-limits/minimax-fetcher.ts | 276 ++++++++++++ .../minimax-request-context.test.ts | 353 +++++++++++++++ .../rate-limits/minimax-request-context.ts | 178 ++++++++ src/main/rate-limits/service.test.ts | 89 +++- src/main/rate-limits/service.ts | 74 ++- src/preload/api-types.ts | 6 + src/preload/index.ts | 10 + .../src/components/settings/AccountsPane.tsx | 397 +++++++++++++++- .../settings/accounts-search.test.ts | 44 ++ .../components/settings/accounts-search.ts | 23 +- .../src/components/status-bar/StatusBar.tsx | 60 ++- .../src/components/status-bar/icons.test.tsx | 24 + .../src/components/status-bar/icons.tsx | 19 + .../status-bar-provider-visibility.test.ts | 67 ++- .../status-bar-provider-visibility.ts | 21 +- .../src/components/status-bar/tooltip.test.ts | 55 +++ .../src/components/status-bar/tooltip.tsx | 5 +- .../status-bar/usage-error-copy.test.ts | 27 ++ .../components/status-bar/usage-error-copy.ts | 3 + src/renderer/src/i18n/locales/en.json | 49 +- src/renderer/src/i18n/locales/es.json | 49 +- src/renderer/src/i18n/locales/ja.json | 49 +- src/renderer/src/i18n/locales/ko.json | 49 +- src/renderer/src/i18n/locales/zh.json | 49 +- .../src/lib/window-label-formatter.test.ts | 17 + src/renderer/src/store/slices/rate-limits.ts | 2 + src/renderer/src/web/web-preload-api.ts | 3 + src/shared/constants.test.ts | 12 + src/shared/constants.ts | 2 + src/shared/rate-limit-types.test.ts | 27 ++ src/shared/rate-limit-types.ts | 10 +- src/shared/status-bar-defaults.ts | 1 + src/shared/types.ts | 5 + 46 files changed, 2889 insertions(+), 48 deletions(-) create mode 100644 resources/minimax-icon.svg create mode 100644 src/main/ipc/minimax-credentials.test.ts create mode 100644 src/main/ipc/minimax-credentials.ts create mode 100644 src/main/ipc/rate-limits.test.ts create mode 100644 src/main/minimax/minimax-cookie-store.test.ts create mode 100644 src/main/minimax/minimax-cookie-store.ts create mode 100644 src/main/rate-limits/minimax-fetcher.test.ts create mode 100644 src/main/rate-limits/minimax-fetcher.ts create mode 100644 src/main/rate-limits/minimax-request-context.test.ts create mode 100644 src/main/rate-limits/minimax-request-context.ts create mode 100644 src/renderer/src/components/settings/accounts-search.test.ts create mode 100644 src/renderer/src/components/status-bar/icons.test.tsx create mode 100644 src/renderer/src/components/status-bar/usage-error-copy.test.ts create mode 100644 src/shared/rate-limit-types.test.ts diff --git a/resources/minimax-icon.svg b/resources/minimax-icon.svg new file mode 100644 index 00000000000..9698ab27567 --- /dev/null +++ b/resources/minimax-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 51908be3253..0f6d968cc52 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -127,6 +127,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 13b39b6b0d7..69fed98ea8d 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -131,6 +131,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/index.ts b/src/main/index.ts index ff4b249f8dc..902a01a8d06 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -99,6 +99,7 @@ import { import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' import { RateLimitService } from './rate-limits/service' +import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { @@ -1685,6 +1686,14 @@ app.whenReady().then(async () => { workspaceIdOverride: settings.opencodeWorkspaceId } }) + rateLimits.setMiniMaxConfigResolver(() => { + const settings = store!.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts new file mode 100644 index 00000000000..b34d85ad176 --- /dev/null +++ b/src/main/ipc/minimax-credentials.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + ipcState.handleHandlers.set(channel, handler) + } + } +})) + +const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) +const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) +const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock, + clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock, + hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock +})) + +import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' +import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitState } from '../../shared/rate-limit-types' + +function makeRefreshMock(): { + refresh: ReturnType + service: Pick +} { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + return { refresh, service: { refresh } } +} + +async function invoke(channel: string, ...args: unknown[]): Promise { + const handler = ipcState.handleHandlers.get(channel) + if (!handler) { + throw new Error(`No handler registered for ${channel}`) + } + return (await handler({}, ...args)) as T +} + +describe('registerMiniMaxCredentialsHandlers', () => { + beforeEach(() => { + ipcState.handleHandlers.clear() + saveMiniMaxSessionCookieMock.mockReset() + clearMiniMaxSessionCookieMock.mockReset() + hasMiniMaxSessionCookieMock.mockReset() + hasMiniMaxSessionCookieMock.mockReturnValue(false) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers the three MiniMax credential channels', () => { + registerMiniMaxCredentialsHandlers(null) + expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true) + expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true) + expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true) + }) + + it('returns the configured state on getStatus from the cookie store', async () => { + hasMiniMaxSessionCookieMock.mockReturnValue(true) + registerMiniMaxCredentialsHandlers(null) + const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus') + expect(status).toEqual({ configured: true }) + }) + + it('persists the cookie and reports configured after saveCookie', async () => { + hasMiniMaxSessionCookieMock.mockReturnValueOnce(true) + registerMiniMaxCredentialsHandlers(null) + const status = await invoke<{ configured: boolean }>( + 'minimaxCredentials:saveCookie', + '_token=abc; minimax_group_id_v2=42' + ) + expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') + expect(status).toEqual({ configured: true }) + }) + + it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => { + const { refresh, service } = makeRefreshMock() + registerMiniMaxCredentialsHandlers(service as RateLimitService) + await invoke('minimaxCredentials:saveCookie', '_token=abc') + // Why: the save handler is fire-and-forget — wait a microtask cycle so + // the queued `void rateLimits?.refresh()` resolves before we assert. + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('does not throw when saveCookie runs without a rate-limit service', async () => { + registerMiniMaxCredentialsHandlers(null) + await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined() + }) + + it('clears the cookie and triggers a refresh on clearCookie', async () => { + const { refresh, service } = makeRefreshMock() + hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) + registerMiniMaxCredentialsHandlers(service as RateLimitService) + const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') + expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(status).toEqual({ configured: false }) + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('logs but does not throw when the post-save rate-limit refresh rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const refresh = vi.fn(() => Promise.reject(new Error('refresh boom'))) + registerMiniMaxCredentialsHandlers({ + refresh + } as Pick as RateLimitService) + await invoke('minimaxCredentials:saveCookie', '_token=abc') + await new Promise((resolve) => setImmediate(resolve)) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to trigger rate-limit refresh after save'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts new file mode 100644 index 00000000000..9f773762151 --- /dev/null +++ b/src/main/ipc/minimax-credentials.ts @@ -0,0 +1,33 @@ +import { ipcMain } from 'electron' +import { + clearMiniMaxSessionCookie, + hasMiniMaxSessionCookie, + saveMiniMaxSessionCookie +} from '../minimax/minimax-cookie-store' +import type { RateLimitService } from '../rate-limits/service' + +export type MiniMaxCredentialsStatus = { + configured: boolean +} + +function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { + return { configured: hasMiniMaxSessionCookie() } +} + +export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { + ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus()) + ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => { + saveMiniMaxSessionCookie(cookie) + void rateLimits?.refresh().catch((error: unknown) => { + console.error('[minimax] failed to trigger rate-limit refresh after save:', error) + }) + return getMiniMaxCredentialsStatus() + }) + ipcMain.handle('minimaxCredentials:clearCookie', () => { + clearMiniMaxSessionCookie() + void rateLimits?.refresh().catch((error: unknown) => { + console.error('[minimax] failed to trigger rate-limit refresh after clear:', error) + }) + return getMiniMaxCredentialsStatus() + }) +} diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts new file mode 100644 index 00000000000..3a299c5bda6 --- /dev/null +++ b/src/main/ipc/rate-limits.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + ipcState.handleHandlers.set(channel, handler) + } + } +})) + +import { registerRateLimitHandlers } from './rate-limits' +import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitState } from '../../shared/rate-limit-types' + +function makeService(): { service: RateLimitService; refresh: ReturnType } { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + const service = { + getState: vi.fn(() => ({}) as RateLimitState), + refresh, + refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), + refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), + consumeCodexRateLimitResetCredit: vi.fn(() => + Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) + ), + setPollingInterval: vi.fn(() => Promise.resolve()), + fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()), + fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve()) + } + return { service: service as unknown as RateLimitService, refresh } +} + +describe('registerRateLimitHandlers', () => { + beforeEach(() => { + ipcState.handleHandlers.clear() + }) + + it('registers a refreshMiniMax channel that delegates to refresh()', async () => { + const { service, refresh } = makeService() + registerRateLimitHandlers(service) + const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax') + expect(handler).toBeDefined() + await handler!({}) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('keeps the existing rate-limit channels registered', () => { + const { service } = makeService() + registerRateLimitHandlers(service) + expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true) + }) +}) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 3f657c0c160..a28b7810263 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => rateLimits.fetchInactiveCodexAccountsOnOpen() ) + ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 76e8b16933b..5ce6342c351 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -34,6 +34,7 @@ const { registerAgentHookHandlersMock, registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, + registerMiniMaxCredentialsHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -89,6 +90,7 @@ const { registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), + registerMiniMaxCredentialsHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) +vi.mock('./minimax-credentials', () => ({ + registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock +})) + vi.mock('../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => { registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() + registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => { expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) + expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 4baae415b87..50dbd0a8ff4 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' +import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' import { registerUpdaterHandlers } from '../window/attach-main-window-services' import { registerClipboardHandlers, @@ -117,6 +118,7 @@ export function registerCoreHandlers( registerAgentHookHandlers(runtime) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) + registerMiniMaxCredentialsHandlers(rateLimits) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts new file mode 100644 index 00000000000..436add5a854 --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as MiniMaxCookieStore from './minimax-cookie-store' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value)), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')) +})) + +const electronMock = vi.hoisted(() => ({ + safeStorage: safeStorageMock +})) + +vi.mock('electron', () => electronMock) + +const existsSyncMock = vi.fn() +const mkdirSyncMock = vi.fn() +const readFileSyncMock = vi.fn() +const rmSyncMock = vi.fn() +const writeFileSyncMock = vi.fn() +const homedirMock = vi.fn(() => '/home/test') + +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdirSync: mkdirSyncMock, + readFileSync: readFileSyncMock, + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +vi.mock('node:path', () => ({ + join: (...parts: string[]) => parts.join('/') +})) + +const storePath = '/home/test/.orca/minimax-session-cookie.enc' + +async function loadStore(): Promise { + return await import('./minimax-cookie-store') +} + +describe('minimax-cookie-store', () => { + beforeEach(() => { + existsSyncMock.mockReset() + mkdirSyncMock.mockReset() + readFileSyncMock.mockReset() + rmSyncMock.mockReset() + writeFileSyncMock.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value)) + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8')) + }) + + afterEach(() => { + vi.resetModules() + }) + + it('returns false when no file exists yet', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(false) + }) + + it('writes the cookie using safeStorage when encryption is available', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42') + expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') + expect(writeFileSyncMock).toHaveBeenCalledWith( + storePath, + Buffer.from('_token=abc; minimax_group_id_v2=42'), + { mode: 0o600 } + ) + expect(mkdirSyncMock).toHaveBeenCalledWith('/home/test/.orca', { recursive: true }) + }) + + it('warns and writes plaintext when safeStorage is unavailable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveMiniMaxSessionCookie('_token=abc') + expect(writeFileSyncMock).toHaveBeenCalledWith(storePath, '_token=abc', { + encoding: 'utf8', + mode: 0o600 + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) + warn.mockRestore() + }) + + it('refuses empty cookies', async () => { + const store = await loadStore() + expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/) + }) + + it('reads decrypted cookie from disk and caches it', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9') + const store = await loadStore() + const first = store.readMiniMaxSessionCookie() + const second = store.readMiniMaxSessionCookie() + expect(first).toBe('_token=cached; minimax_group_id_v2=9') + expect(second).toBe(first) + expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1) + }) + + it('returns null when no file exists', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBeNull() + }) + + it('returns plaintext when safeStorage is unavailable and reads succeed', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=plaintext')) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext') + }) + + it('throws when decryption fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockImplementation(() => { + throw new Error('boom') + }) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('clears the cached cookie and removes the file', async () => { + existsSyncMock.mockReturnValueOnce(true) + readFileSyncMock.mockReturnValueOnce(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear') + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear') + store.clearMiniMaxSessionCookie() + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + expect(store.readMiniMaxSessionCookie()).toBeNull() + }) +}) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts new file mode 100644 index 00000000000..31102e4bbbe --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.ts @@ -0,0 +1,66 @@ +import { safeStorage } from 'electron' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' +let cachedMiniMaxCookie: string | null = null + +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getMiniMaxCookiePath(): string { + return join(getOrcaDir(), MINIMAX_COOKIE_FILE) +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +export function hasMiniMaxSessionCookie(): boolean { + return existsSync(getMiniMaxCookiePath()) +} + +export function saveMiniMaxSessionCookie(cookie: string): void { + const trimmed = cookie.trim() + if (!trimmed) { + throw new Error('MiniMax session cookie is required') + } + ensureOrcaDir() + if (safeStorage.isEncryptionAvailable()) { + writeFileSync(getMiniMaxCookiePath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + cachedMiniMaxCookie = trimmed + return + } + console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext') + writeFileSync(getMiniMaxCookiePath(), trimmed, { encoding: 'utf8', mode: 0o600 }) + cachedMiniMaxCookie = trimmed +} + +export function readMiniMaxSessionCookie(): string | null { + if (cachedMiniMaxCookie !== null) { + return cachedMiniMaxCookie + } + const keyPath = getMiniMaxCookiePath() + if (!existsSync(keyPath)) { + return null + } + try { + const raw = readFileSync(keyPath) + cachedMiniMaxCookie = safeStorage.isEncryptionAvailable() + ? safeStorage.decryptString(raw) + : raw.toString('utf8') + return cachedMiniMaxCookie + } catch { + throw new Error('MiniMax session cookie could not be decrypted') + } +} + +export function clearMiniMaxSessionCookie(): void { + cachedMiniMaxCookie = null + rmSync(getMiniMaxCookiePath(), { force: true }) +} diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts new file mode 100644 index 00000000000..89c095de959 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted( + () => { + const netFetchMock = vi.fn() + const cookiesSetMock = vi.fn(() => Promise.resolve()) + const clearStorageDataMock = vi.fn(() => Promise.resolve()) + const sessionFromPartitionMock = vi.fn(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } + } +) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { + extractMiniMaxCookieValue, + fetchMiniMaxRateLimits, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-fetcher' + +const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' + +function makeResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response +} + +function makeOkPayload(remainingPercent: number): unknown { + const now = Date.now() + return { + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: remainingPercent, + start_time: now - 60_000, + end_time: now + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + } +} + +const FULL_COOKIE = + '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3' + +function getCookieJarSetNames(): string[] { + return cookiesSetMock.mock.calls.map((call) => { + const [details] = call as unknown as [{ name: string }] + return details.name + }) +} + +describe('fetchMiniMaxRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z')) + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() + netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('returns unavailable when cookie is empty', async () => { + const result = await fetchMiniMaxRateLimits({ cookie: '' }) + expect(result.status).toBe('unavailable') + expect(result.provider).toBe('minimax') + expect(result.session).toBeNull() + expect(result.weekly).toBeNull() + expect(result.error).toMatch(/not configured/i) + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('returns unavailable when cookie is only whitespace', async () => { + const result = await fetchMiniMaxRateLimits({ cookie: ' ' }) + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('returns error when cookie has no _token', async () => { + const result = await fetchMiniMaxRateLimits({ + cookie: '_twpid=tw.123; minimax_group_id_v2=12345' + }) + expect(result.status).toBe('error') + expect(result.error).toMatch(/MiniMax auth cookie not found/) + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('classifies 401 as stale-token', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 401)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + expect(result.error).toMatch(/session expired/i) + }) + + it('classifies 403 as stale-token', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 403)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + }) + + it('classifies 500 as server', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 500)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('server') + expect(result.error).toMatch(/500/) + }) + + it('returns ok with session window mapping remaining to usedPercent', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35))) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.provider).toBe('minimax') + expect(result.session?.usedPercent).toBe(65) + expect(result.session?.windowMinutes).toBe(300) + expect(result.weekly).toBeNull() + }) + + it('reports a fixed 5-hour session window regardless of API interval drift', async () => { + const startTime = 1_700_000_000_000 + const endTime = startTime + 295 * 60_000 + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 60, + start_time: startTime, + end_time: endTime, + remains_time: endTime - startTime + } + ] + }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.windowMinutes).toBe(300) + expect(result.session?.resetsAt).toBe(endTime) + }) + + it('does not derive the windowMinutes label from the raw start/end interval', async () => { + const startTime = 1_700_000_000_000 + const endTime = startTime + 4 * 60 * 60_000 + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 63, + start_time: startTime, + end_time: endTime, + remains_time: endTime - startTime + } + ] + }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.windowMinutes).toBe(300) + expect(result.session?.usedPercent).toBe(37) + expect(result.session?.resetsAt).toBe(endTime) + }) + + it('sets the cookie jar and sends browser-like request headers', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledWith({ + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(getCookieJarSetNames()).toEqual([ + '_token', + '_twpid', + 'minimax_group_id_v2', + 'platform_cookie_consent' + ]) + expect(netFetchMock).toHaveBeenCalledTimes(1) + const [url, init] = netFetchMock.mock.calls[0] + expect(url).toBe(MINIMAX_URL) + expect(init.method).toBe('GET') + expect(init.headers.Cookie).toBeUndefined() + expect(init.headers['X-Group-Id']).toBe('12345') + expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage') + expect(init.headers.Accept).toMatch(/application\/json/) + expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9') + expect(init.headers['Sec-Fetch-Dest']).toBeUndefined() + expect(init.headers['Sec-Fetch-Mode']).toBeUndefined() + expect(init.headers['Sec-Fetch-Site']).toBeUndefined() + expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/) + expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage') + }) + + it('accepts quoted MiniMax cookie storage syntax', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ + cookie: '_token:"jwt-token" minimax_group_id_v2:"42"' + }) + const [, init] = netFetchMock.mock.calls[0] + expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2']) + expect(init.headers.Cookie).toBeUndefined() + expect(init.headers['X-Group-Id']).toBe('42') + }) + + it('preserves the complete browser Cookie header', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + const fullBrowserCookie = [ + 'platform_cookie_consent=3', + '_ga=analytics', + '_token=tok', + '_twpid=tw.1', + 'ak_bmsc=ak', + 'bm_sv=sv', + 'bm_sz=sz', + '_abck=ab', + 'minimax_group_id_v2=42', + 'sensorsdata2015jssdkcross=analytics' + ].join('; ') + await fetchMiniMaxRateLimits({ + cookie: fullBrowserCookie + }) + const [, init] = netFetchMock.mock.calls[0] + expect(getCookieJarSetNames()).toEqual([ + 'platform_cookie_consent', + '_ga', + '_token', + '_twpid', + 'ak_bmsc', + 'bm_sv', + 'bm_sz', + '_abck', + 'minimax_group_id_v2', + 'sensorsdata2015jssdkcross' + ]) + expect(init.headers.Cookie).toBeUndefined() + }) + + it('prefers explicit groupId over cookie value', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' }) + expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id') + }) + + it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' }) + expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345') + }) + + it('surfaces base_resp status_code != 0 as usage-unavailable', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('unauth') + }) + + it('returns error when model_remains is empty', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toMatch(/configured model was not found/i) + }) + + it('returns error when configured model is not in response', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) + ) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'unrelated-model' + }) + expect(result.status).toBe('error') + expect(result.error).toMatch(/configured model was not found/i) + }) + + it('falls back to the lone snapshot when no configured model matches', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40))) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'unrelated-model' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(60) + }) + + it('selects the first configured model when multiple are listed', async () => { + const payload = makeOkPayload(40) + ;(payload as { model_remains: unknown[] }).model_remains = [ + { + model_name: 'unrelated', + current_interval_remaining_percent: 10, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + }, + { + model_name: 'general', + current_interval_remaining_percent: 40, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'general' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(60) + }) + + it('redacts _token in any error path that includes payload text', async () => { + const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } } + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.error).not.toContain('eyJh') + expect(result.error).not.toContain('minimax_group_id_v2=12345') + expect(result.error).toContain('[REDACTED]') + }) + + it('logs MiniMax failures with cookie names but without cookie values', async () => { + const warn = vi.mocked(console.warn) + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } }) + ) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + baseRespStatusMsg: expect.not.stringContaining('eyJh'), + cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent'] + }) + ) + }) +}) + +describe('normalizeMiniMaxCookieHeader', () => { + it('preserves all cookie pairs from a browser Cookie header', () => { + const normalized = normalizeMiniMaxCookieHeader( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + expect(normalized).toBe( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + }) + + it('normalizes quoted MiniMax cookie storage syntax', () => { + expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( + '_token=tok; minimax_group_id_v2=42' + ) + }) + + it('accepts a copied Cookie header line', () => { + expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( + 'session=abc; other=xyz' + ) + }) +}) + +describe('extractMiniMaxCookieValue', () => { + it('returns the value for a given cookie name', () => { + expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345') + }) + it('returns null when the name is absent', () => { + expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() + }) +}) + +describe('redactMiniMaxSecret', () => { + it('redacts _token values', () => { + expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]') + expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF') + }) + it('redacts minimax_group_id_v2 values', () => { + expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') + }) + it('redacts MiniMax anti-bot cookie values', () => { + const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"') + expect(redacted).not.toContain('secret') + expect(redacted).toContain('ak_bmsc=[REDACTED]') + expect(redacted).toContain('bm_sv:[REDACTED]') + }) + it('redacts Cookie: header lines', () => { + expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]') + }) +}) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts new file mode 100644 index 00000000000..a04ba89db55 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -0,0 +1,276 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + extractMiniMaxCookieValue, + fetchMiniMaxWithManualCookieHeader, + fetchMiniMaxWithSessionCookieJar, + getUniqueMiniMaxCookieNames, + logMiniMaxFetchFailure, + makeMiniMaxRequestHeaders, + MINIMAX_USAGE_ENDPOINT, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret, + type MiniMaxFetchResponse +} from './minimax-request-context' + +export { + extractMiniMaxCookieValue, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-request-context' + +const API_TIMEOUT_MS = 15_000 + +type MiniMaxUsageItem = { + model_name?: unknown + current_interval_remaining_percent?: unknown + start_time?: unknown + end_time?: unknown + remains_time?: unknown +} + +type MiniMaxUsageResponse = { + base_resp?: { + status_code?: unknown + status_msg?: unknown + } + model_remains?: MiniMaxUsageItem[] +} + +type MiniMaxUsageSnapshot = { + modelName: string + window: RateLimitWindow +} + +export type FetchMiniMaxRateLimitsOptions = { + cookie: string + groupId?: string | null + models?: string | readonly string[] | null + endpoint?: string +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))) +} + +function makeUnavailable(error: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'unavailable', + usageMetadata: { failureKind: 'missing-credentials', source: 'web' } + } +} + +function makeError( + error: string, + failureKind: NonNullable['failureKind'] +): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { failureKind, source: 'web' } + } +} + +function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] { + if (Array.isArray(models)) { + return models.map((model) => model.trim()).filter(Boolean) + } + if (typeof models === 'string') { + return models + .split(',') + .map((model) => model.trim()) + .filter(Boolean) + } + return ['general'] +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +// Why: MiniMax's API returns `end_time - start_time` that can drift below the +// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted +// session — a fixed 5-hour window — so the status bar reads "5h" regardless of +// what the API reports. Mirrors how Codex always reports 300/10080 minutes. +const MINIMAX_SESSION_WINDOW_MINUTES = 300 + +function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null { + const modelName = typeof item.model_name === 'string' ? item.model_name : null + const remainingPercent = asNumber(item.current_interval_remaining_percent) + const startTime = asNumber(item.start_time) + const endTime = asNumber(item.end_time) + if (!modelName || remainingPercent === null || startTime === null || endTime === null) { + return null + } + return { + modelName, + window: { + usedPercent: clampPercent(100 - remainingPercent), + windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES, + resetsAt: endTime, + resetDescription: null + } + } +} + +function selectSnapshot( + snapshots: MiniMaxUsageSnapshot[], + preferredModels: string[] +): MiniMaxUsageSnapshot | null { + for (const model of preferredModels) { + const match = snapshots.find((snapshot) => snapshot.modelName === model) + if (match) { + return match + } + } + return snapshots.length === 1 ? snapshots[0] : null +} + +async function fetchMiniMaxResponse(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + try { + return await fetchMiniMaxWithSessionCookieJar(args) + } catch (sessionFetchError) { + const message = + sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError) + console.warn( + '[minimax] session cookie jar fetch failed; falling back to manual Cookie header', + { + error: redactMiniMaxSecret(message), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId)) + } + ) + return await fetchMiniMaxWithManualCookieHeader(args) + } +} + +function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null { + const { response } = fetchResult + if (response.status === 401 || response.status === 403) { + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: response.status, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + return makeError( + 'MiniMax session expired. Replace the MiniMax cookie in Settings.', + 'stale-token' + ) + } + if (!response.ok) { + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: response.status, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + return makeError(`MiniMax usage fetch failed (${response.status})`, 'server') + } + return null +} + +function handleMiniMaxPayloadError( + fetchResult: MiniMaxFetchResponse, + payload: MiniMaxUsageResponse +): ProviderRateLimits | null { + const statusCode = payload.base_resp?.status_code + if (statusCode === undefined || statusCode === 0) { + return null + } + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: fetchResult.response.status, + statusCode, + statusMsg: payload.base_resp?.status_msg, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + const message = + typeof payload.base_resp?.status_msg === 'string' + ? payload.base_resp.status_msg + : 'MiniMax returned an error' + return makeError(redactMiniMaxSecret(message), 'usage-unavailable') +} + +export async function fetchMiniMaxRateLimits( + options: FetchMiniMaxRateLimitsOptions +): Promise { + const rawCookie = options.cookie.trim() + if (!rawCookie) { + return makeUnavailable('MiniMax session cookie not configured') + } + const cookie = normalizeMiniMaxCookieHeader(rawCookie) + if (!extractMiniMaxCookieValue(cookie, '_token')) { + return makeError( + 'MiniMax auth cookie not found — paste a Cookie header with _token', + 'missing-credentials' + ) + } + const groupId = + options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2') + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + try { + const fetchResult = await fetchMiniMaxResponse({ + cookie, + endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT, + groupId, + signal: controller.signal + }) + const httpError = handleMiniMaxHttpError(fetchResult) + if (httpError) { + return httpError + } + const payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + const payloadError = handleMiniMaxPayloadError(fetchResult, payload) + if (payloadError) { + return payloadError + } + const snapshots = (payload.model_remains ?? []) + .map(parseUsageItem) + .filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null) + const selected = selectSnapshot(snapshots, parseModels(options.models)) + if (!selected) { + return makeError( + 'MiniMax usage data for the configured model was not found', + 'usage-unavailable' + ) + } + return { + provider: 'minimax', + session: selected.window, + weekly: null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'web' } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error' + const failureKind = message.toLowerCase().includes('json') ? 'parse' : 'network' + return makeError(redactMiniMaxSecret(message), failureKind) + } finally { + clearTimeout(timeout) + } +} diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts new file mode 100644 index 00000000000..15caa55214a --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted( + () => { + const netFetchMock = vi.fn() + const cookiesSetMock = vi.fn(() => Promise.resolve()) + const clearStorageDataMock = vi.fn(() => Promise.resolve()) + const sessionFromPartitionMock = vi.fn(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } + } +) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { + extractMiniMaxCookieValue, + fetchMiniMaxWithManualCookieHeader, + fetchMiniMaxWithSessionCookieJar, + getUniqueMiniMaxCookieNames, + logMiniMaxFetchFailure, + makeMiniMaxRequestHeaders, + MINIMAX_USAGE_ENDPOINT, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-request-context' + +const FULL_COOKIE = + '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3' + +describe('normalizeMiniMaxCookieHeader', () => { + it('preserves all cookie pairs from a browser Cookie header', () => { + const normalized = normalizeMiniMaxCookieHeader( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + expect(normalized).toBe( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + }) + + it('normalizes quoted MiniMax cookie storage syntax', () => { + expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( + '_token=tok; minimax_group_id_v2=42' + ) + }) + + it('accepts a copied Cookie header line with prefix', () => { + expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( + 'session=abc; other=xyz' + ) + }) + + it('merges header and quoted forms without dropping any pair', () => { + const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42' + const normalized = normalizeMiniMaxCookieHeader(mixed) + expect(normalized).toContain('_token=header-quoted') + expect(normalized).toContain('_twpid=q-1') + expect(normalized).toContain('minimax_group_id_v2=42') + }) +}) + +describe('extractMiniMaxCookieValue', () => { + it('returns the value for a known name', () => { + expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload') + }) + + it('returns null when the name is absent', () => { + expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() + }) + + it('handles Chromium quoted syntax', () => { + expect( + extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2') + ).toBe('42') + }) +}) + +describe('getUniqueMiniMaxCookieNames', () => { + it('deduplicates repeated names', () => { + const names = getUniqueMiniMaxCookieNames( + '_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99' + ) + expect(names).toEqual(['_token', 'minimax_group_id_v2']) + }) + + it('returns names from a quoted-only Cookie storage export', () => { + const names = getUniqueMiniMaxCookieNames( + '_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"' + ) + expect(names).toEqual( + expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent']) + ) + }) +}) + +describe('redactMiniMaxSecret', () => { + it('redacts _token values in header and quoted syntax', () => { + const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"') + expect(redacted).toContain('_token=[REDACTED]') + expect(redacted).not.toContain('eyJhABCDEF') + expect(redacted).not.toContain('x.y') + }) + + it('redacts minimax_group_id_v2', () => { + expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') + }) + + it('redacts MiniMax anti-bot cookie values', () => { + const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"') + expect(redacted).not.toContain('zzzsecret') + expect(redacted).not.toContain('yyyvalue') + expect(redacted).not.toContain('xxxdata') + expect(redacted).toContain('ak_bmsc=[REDACTED]') + expect(redacted).toContain('bm_sv:[REDACTED]') + expect(redacted).toContain('_abck=[REDACTED]') + }) + + it('redacts full Cookie: header lines', () => { + const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter') + expect(redacted).toContain('Cookie: [REDACTED]') + expect(redacted).not.toContain('Cookie: _token=secret') + }) +}) + +describe('makeMiniMaxRequestHeaders', () => { + it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => { + const headers = makeMiniMaxRequestHeaders(null) + expect(headers.Accept).toMatch(/application\/json/) + expect(headers['Accept-Language']).toBe('en-US,en;q=0.9') + expect(headers.Referer).toBe('https://platform.minimax.io/console/usage') + expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/) + expect(headers['User-Agent']).not.toContain('orca-minimax-usage') + }) + + it('omits X-Group-Id when groupId is null', () => { + const headers = makeMiniMaxRequestHeaders(null) + expect(headers['X-Group-Id']).toBeUndefined() + }) + + it('omits X-Group-Id when groupId is empty string', () => { + const headers = makeMiniMaxRequestHeaders('') + expect(headers['X-Group-Id']).toBeUndefined() + }) + + it('includes X-Group-Id when groupId is provided', () => { + const headers = makeMiniMaxRequestHeaders('2034972027806299092') + expect(headers['X-Group-Id']).toBe('2034972027806299092') + }) +}) + +describe('fetchMiniMaxWithSessionCookieJar', () => { + beforeEach(() => { + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() + netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses a dedicated MiniMax partition and clears cookies before fetching', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledWith({ + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + }) + + it('sets every cookie pair onto the session jar with secure + path /', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithSessionCookieJar({ + cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42', + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + expect(cookiesSetMock).toHaveBeenCalledTimes(3) + const setDetails = cookiesSetMock.mock.calls.map((call) => { + const [details] = call as unknown as [ + { name: string; value: string; secure: boolean; path: string } + ] + return details + }) + expect(setDetails).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }), + expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }), + expect.objectContaining({ + name: 'minimax_group_id_v2', + value: '42', + secure: true, + path: '/' + }) + ]) + ) + }) + + it('reports the transport name as session-cookie-jar on success', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + const result = await fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(result.transport).toBe('session-cookie-jar') + expect(result.cookieNames).toEqual([ + '_token', + '_twpid', + 'minimax_group_id_v2', + 'platform_cookie_consent' + ]) + expect(result.requestHeaderNames).toContain('X-Group-Id') + }) +}) + +describe('fetchMiniMaxWithManualCookieHeader', () => { + beforeEach(() => { + netFetchMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('attaches the full Cookie header and X-Group-Id via net.fetch', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + const result = await fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(result.transport).toBe('manual-cookie-header') + expect(netFetchMock).toHaveBeenCalledTimes(1) + const [url, init] = netFetchMock.mock.calls[0] + expect(url).toBe(MINIMAX_USAGE_ENDPOINT) + expect(init.method).toBe('GET') + expect(init.headers.Cookie).toBe(FULL_COOKIE) + expect(init.headers['X-Group-Id']).toBe('12345') + }) + + it('omits X-Group-Id when groupId is null', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + const [, init] = netFetchMock.mock.calls[0] + expect(init.headers['X-Group-Id']).toBeUndefined() + }) +}) + +describe('logMiniMaxFetchFailure', () => { + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs structured fields without leaking cookie values', () => { + logMiniMaxFetchFailure({ + transport: 'session-cookie-jar', + responseStatus: 200, + statusCode: 7, + statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue', + cookieNames: ['_token', 'ak_bmsc'], + requestHeaderNames: ['Accept', 'X-Group-Id'] + }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + transport: 'session-cookie-jar', + responseStatus: 200, + baseRespStatusCode: 7, + baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'), + cookieNames: ['_token', 'ak_bmsc'], + requestHeaderNames: ['Accept', 'X-Group-Id'] + }) + ) + }) + + it('logs without redacting when statusMsg is not a string', () => { + logMiniMaxFetchFailure({ + transport: 'manual-cookie-header', + responseStatus: 401, + statusCode: undefined, + statusMsg: undefined, + cookieNames: ['_token'], + requestHeaderNames: ['Accept'] + }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + transport: 'manual-cookie-header', + responseStatus: 401, + baseRespStatusCode: undefined, + baseRespStatusMsg: undefined + }) + ) + }) +}) diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts new file mode 100644 index 00000000000..3953ab53e6f --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.ts @@ -0,0 +1,178 @@ +import { net, session } from 'electron' + +export const MINIMAX_USAGE_ENDPOINT = + 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' + +const MINIMAX_ORIGIN = 'https://platform.minimax.io' +const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage' +const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch' +const SENSITIVE_COOKIE_NAMES = new Set([ + '_token', + '_twpid', + '_abck', + 'ak_bmsc', + 'bm_mi', + 'bm_sv', + 'bm_sz', + 'minimax_group_id_v2' +]) + +export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header' + +export type MiniMaxFetchResponse = { + response: Response + requestHeaderNames: string[] + cookieNames: string[] + transport: MiniMaxFetchTransport +} + +function getMiniMaxBrowserUserAgent(): string { + if (process.platform === 'win32') { + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0' + } + if (process.platform === 'darwin') { + return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0' + } + return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0' +} + +function parseCookiePairs(cookie: string): { name: string; value: string }[] { + const headerPairs = cookie + .split(';') + .map((part) => part.trim()) + .map((part) => { + const normalizedPart = part.replace(/^Cookie:\s*/i, '') + const eq = normalizedPart.indexOf('=') + if (eq < 0) { + return null + } + return { + name: normalizedPart.slice(0, eq).trim(), + value: normalizedPart.slice(eq + 1).trim() + } + }) + .filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value)) + // Why: Chromium cookie storage exports are often copied as `name:"value"`, + // not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps. + const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g + const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)] + .map((match) => { + const [, name = '', value = ''] = match + return { name: name.trim(), value: value.trim() } + }) + .filter((pair) => pair.name && pair.value) + return [...headerPairs, ...quotedPairs] +} + +export function extractMiniMaxCookieValue(cookie: string, name: string): string | null { + return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null +} + +export function normalizeMiniMaxCookieHeader(cookie: string): string { + return parseCookiePairs(cookie) + .map((pair) => `${pair.name}=${pair.value}`) + .join('; ') +} + +export function getUniqueMiniMaxCookieNames(cookie: string): string[] { + return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))] +} + +export function redactMiniMaxSecret(value: string): string { + let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]') + for (const name of SENSITIVE_COOKIE_NAMES) { + redacted = redacted + .replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`) + .replace(new RegExp(`${name}:["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) + } + return redacted +} + +export function makeMiniMaxRequestHeaders(groupId: string | null): Record { + const headers: Record = { + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: MINIMAX_REFERER, + 'User-Agent': getMiniMaxBrowserUserAgent() + } + if (groupId) { + headers['X-Group-Id'] = groupId + } + return headers +} + +export async function fetchMiniMaxWithSessionCookieJar(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) + const cookiePairs = parseCookiePairs(args.cookie) + await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) + await Promise.all( + cookiePairs.map((pair) => + miniMaxSession.cookies.set({ + url: MINIMAX_ORIGIN, + name: pair.name, + value: pair.value, + secure: true, + path: '/' + }) + ) + ) + const headers = makeMiniMaxRequestHeaders(args.groupId) + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'session-cookie-jar' + } +} + +export async function fetchMiniMaxWithManualCookieHeader(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + const headers = { + ...makeMiniMaxRequestHeaders(args.groupId), + Cookie: args.cookie + } + return { + response: await net.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'manual-cookie-header' + } +} + +export function logMiniMaxFetchFailure(details: { + transport: MiniMaxFetchTransport + responseStatus?: number + statusCode?: unknown + statusMsg?: unknown + cookieNames: string[] + requestHeaderNames: string[] +}): void { + console.warn('[minimax] usage fetch failed', { + transport: details.transport, + responseStatus: details.responseStatus, + baseRespStatusCode: details.statusCode, + baseRespStatusMsg: + typeof details.statusMsg === 'string' + ? redactMiniMaxSecret(details.statusMsg) + : details.statusMsg, + cookieNames: details.cookieNames, + requestHeaderNames: details.requestHeaderNames + }) +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 1b810859ce7..796d7d516ef 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -9,7 +9,9 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), @@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({ fetchOpenCodeGoRateLimits: vi.fn() })) +vi.mock('./minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + type Deferred = { promise: Promise resolve: (value: T) => void @@ -42,7 +52,7 @@ function deferred(): Deferred { } function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -62,7 +72,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', message: string ): ProviderRateLimits { return { @@ -116,6 +126,8 @@ describe('RateLimitService', () => { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) }) it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => { @@ -1056,4 +1068,77 @@ describe('RateLimitService', () => { } ]) }) + + it('fetches MiniMax alongside other providers when a config resolver is set', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc; minimax_group_id_v2=42', + groupId: '', + models: 'general' + })) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now())) + + await service.refresh() + + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) + expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({ + cookie: '_token=abc; minimax_group_id_v2=42', + groupId: '', + models: 'general' + }) + + const state = service.getState() + expect(state.minimax?.status).toBe('ok') + expect(state.minimax?.session?.usedPercent).toBe(50) + expect(state.minimaxCookieConfigured).toBe(true) + }) + + it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => { + const service = new RateLimitService() + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + expect(service.getState().minimaxCookieConfigured).toBe(true) + }) + + it('discards the previous MiniMax snapshot when its config hash changes', async () => { + const service = new RateLimitService() + let models = 'general' + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models + })) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + vi.mocked(fetchMiniMaxRateLimits) + .mockResolvedValueOnce(okProvider('minimax', 40, Date.now())) + .mockResolvedValueOnce(okProvider('minimax', 10, Date.now())) + + await service.refresh() + expect(service.getState().minimax?.session?.usedPercent).toBe(40) + + models = 'premium' + await service.refresh() + + const state = service.getState() + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2) + expect(state.minimax?.session?.usedPercent).toBe(10) + }) + + it('isolates MiniMax failures from other providers', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models: 'general' + })) + vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down')) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + const state = service.getState() + expect(state.minimax?.status).toBe('error') + expect(state.minimax?.error).toBe('minimax down') + expect(state.claude?.status).toBe('ok') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 840f4e4a788..7e2bf948148 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -21,6 +21,8 @@ import { } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' +import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { normalizeCodexAccountSelectionTarget, @@ -43,6 +45,12 @@ type OpenCodeGoRateLimitConfig = { workspaceIdOverride: string } +type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + type GeminiCliOAuthEnabledResolver = () => boolean // Why: Claude's subscription usage endpoint has a tight request budget. Quota @@ -64,6 +72,7 @@ type InternalRateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null } function normalizePollingInterval(ms: number): number { @@ -91,7 +100,8 @@ export class RateLimitService { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null } private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null @@ -108,7 +118,9 @@ export class RateLimitService { private codexFetchGeneration = 0 private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 + private minimaxFetchGeneration = 0 private lastOpencodeConfigHash = '' + private lastMiniMaxConfigHash = '' private codexHomePathResolver: CodexHomePathResolver | null = null private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { runtime: 'host', @@ -120,6 +132,7 @@ export class RateLimitService { wslDistro: null } private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null @@ -163,6 +176,10 @@ export class RateLimitService { this.openCodeGoConfigResolver = resolver } + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { this.geminiCliOAuthEnabledResolver = resolver } @@ -239,6 +256,10 @@ export class RateLimitService { this.pruneInactiveCodexState() return { ...this.state, + // Why: the cookie lives in the file system, not GlobalSettings. Surface + // its presence on the pushed state so the renderer keeps the MiniMax + // bar visible across reloads and between snapshot refreshes. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( @@ -949,7 +970,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' ): ProviderRateLimits { if (!current) { return { @@ -983,6 +1004,10 @@ export class RateLimitService { const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfig = this.miniMaxConfigResolver?.() + const miniMaxCookie = miniMaxConfig?.sessionCookie ?? '' + const miniMaxGroupId = miniMaxConfig?.groupId ?? '' + const miniMaxModels = miniMaxConfig?.models ?? 'general' const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Detect if configuration changed — if it did, we must discard any stale @@ -995,6 +1020,14 @@ export class RateLimitService { } const opencodeGeneration = this.opencodeFetchGeneration + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + // Mark all providers as fetching while keeping previous data visible. // Codex account changes clear Codex separately before this method is // called, so ordinary refreshes still preserve the current values. @@ -1006,13 +1039,16 @@ export class RateLimitService { opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi') + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax') }) const missingWslCodexHome = codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] = + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, @@ -1029,7 +1065,12 @@ export class RateLimitService { }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), - fetchKimiRateLimits() + fetchKimiRateLimits(), + fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) ]) if (signal.aborted) { @@ -1103,6 +1144,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { @@ -1117,6 +1173,7 @@ export class RateLimitService { claudeProvenance === latestClaudeProvenance && this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a // Codex result if both the selected-account provenance and the request @@ -1136,7 +1193,12 @@ export class RateLimitService { ? opencodeGo : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi) + kimi: this.applyStalePolicy(kimi, previousState.kimi), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax }) this.lastFetchAt = Date.now() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0af29e96d02..cb9cd51aa20 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2796,8 +2796,14 @@ export type PreloadApi = { setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise + refreshMiniMax: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } + minimaxCredentials: { + getStatus: () => Promise<{ configured: boolean }> + saveCookie: (cookie: string) => Promise<{ configured: boolean }> + clearCookie: () => Promise<{ configured: boolean }> + } ssh: { listTargets: () => Promise addTarget: (args: { target: Omit }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 01a42bd43e7..680c8b8d88c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3799,6 +3799,7 @@ const api = { ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), fetchInactiveCodexAccounts: (): Promise => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -3806,6 +3807,15 @@ const api = { } }, + minimaxCredentials: { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') + }, + ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index d50003b5122..aac01de620f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -13,17 +13,37 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { + AlertTriangle, + ExternalLink, + HelpCircle, + Loader2, + Lock, + LockOpen, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + X +} from 'lucide-react' import { useAppStore } from '../../store' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' +import { + ClaudeIcon, + GeminiIcon, + MiniMaxIcon, + OpenAIIcon, + OpenCodeGoIcon +} from '../status-bar/icons' import { toast } from 'sonner' import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, + getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' @@ -41,10 +61,73 @@ import { } from '../ui/dialog' import { getCodexAccountAuthWarning } from './codex-account-auth-warning' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' export { getAccountsPaneSearchEntries } const EMPTY_WSL_DISTROS: string[] = [] +const MINIMAX_CONSOLE_URL = 'https://platform.minimax.io/console/usage' + +function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { + const diffMs = Math.max(0, now - updatedAt) + if (diffMs < 60_000) { + return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now') + } + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const minutes = Math.round(diffMs / 60_000) + if (minutes < 60) { + return formatter.format(-minutes, 'minute') + } + const hours = Math.round(minutes / 60) + if (hours < 24) { + return formatter.format(-hours, 'hour') + } + return formatter.format(-Math.round(hours / 24), 'day') +} + +function MiniMaxCookieHelpPopover(): React.JSX.Element { + const steps = [ + translate( + 'auto.components.settings.AccountsPane.f5d8d2a6a1', + 'Open platform.minimax.io/console/usage in your browser and sign in.' + ), + translate('auto.components.settings.AccountsPane.24560fe830', 'Open DevTools.'), + translate( + 'auto.components.settings.AccountsPane.4cab0fa42d', + 'Go to the Network tab and enable Preserve log.' + ), + translate('auto.components.settings.AccountsPane.bee4e63e1c', 'Reload the page.'), + translate( + 'auto.components.settings.AccountsPane.87f814af6f', + 'Filter for remains and select the coding_plan/remains request.' + ), + translate( + 'auto.components.settings.AccountsPane.435df0ee51', + 'Under Request Headers, copy the Cookie value.' + ), + translate('auto.components.settings.AccountsPane.7492fb3bba', 'Paste it here and click Save.') + ] + return ( +
+
+

+ {translate('auto.components.settings.AccountsPane.9fec52de4b', 'How to copy the cookie')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.4e32e030b2', + 'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.' + )} +

+
+
    + {steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ ) +} type AccountsPaneProps = { settings: GlobalSettings @@ -254,9 +337,14 @@ export function AccountsPane({ const searchQuery = useAppStore((s) => s.settingsSearchQuery) const codexRateLimits = useAppStore((s) => s.rateLimits.codex) const codexRateLimitTarget = useAppStore((s) => s.rateLimits.codexTarget) + const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') + const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) + const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) const accountRuntime = getSelectedAccountRuntime( settings, wslSupportedPlatform, @@ -319,6 +407,70 @@ export function AccountsPane({ recordFeatureInteraction('usage-tracking') } + const refreshMiniMaxCredentialStatus = async (): Promise => { + try { + const status = await window.api.minimaxCredentials.getStatus() + setMiniMaxConfigured(status.configured) + } catch (error) { + console.error('Failed to load MiniMax credential status:', error) + } + } + + const saveMiniMaxCookie = async (): Promise => { + if (!miniMaxCookieDraft.trim()) { + toast.error( + translate('auto.components.settings.AccountsPane.2f24f244a4', 'MiniMax cookie is required.') + ) + return + } + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft) + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + void refreshRateLimits() + toast.success( + translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.') + ) + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + const clearMiniMaxCookie = async (): Promise => { + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.clearCookie() + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + void refreshRateLimits() + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + useEffect(() => { + void refreshMiniMaxCredentialStatus() + }, []) + useEffect(() => { let stale = false @@ -1351,6 +1503,247 @@ export function AccountsPane({

+ ) : null, + matchesSettingsSearch(searchQuery, getAccountsMiniMaxSearchEntries()) ? ( +
+
+
+

+ + {translate('auto.components.settings.AccountsPane.5d63bbfbec', 'MiniMax')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.15e831350e', + 'Configure MiniMax usage tracking from platform.minimax.io.' + )} +

+
+ + {translate('auto.components.settings.AccountsPane.0d8e77bc40', 'Open console')} + + +
+ +
+ +
+

+ {translate( + 'auto.components.settings.AccountsPane.0b8c1c7e02', + miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set' + )} +

+

+ {translate( + 'auto.components.settings.AccountsPane.5e08b0fe57', + 'Sent only to platform.minimax.io. Never leaves Orca.' + )} +

+
+
+ + +
+
+ + + {miniMaxConfigured ? : } + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+ + + + + + + + +
+
+ setMiniMaxCookieDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.b8a4f21c3e', + 'Paste the Cookie header from DevTools' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {miniMaxConfigured ? ( + + ) : null} +
+

+ {translate( + 'auto.components.settings.AccountsPane.79418c782a', + 'Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).' + )} +

+ {miniMaxConfigured && + miniMaxRateLimits?.status === 'ok' && + miniMaxRateLimits.error === null ? ( +

+ {translate( + 'auto.components.settings.AccountsPane.53f7b8c7a2', + 'Last refresh: {{value0}}', + { value0: formatMiniMaxRelativeRefresh(miniMaxRateLimits.updatedAt, Date.now()) } + )} +

+ ) : null} +

+ {translate( + 'auto.components.settings.AccountsPane.31d24a4e87', + 'Cookie expires when you sign out in the browser.' + )} +

+
+ +
+
+
+

+ {translate('auto.components.settings.AccountsPane.9dd50d3f75', 'Advanced')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.174fb408f9', + 'Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.' + )} +

+
+ {miniMaxConfigured ? ( + + ) : null} +
+ + + + updateSettings({ minimaxGroupId: e.target.value })} + placeholder={translate( + 'auto.components.settings.AccountsPane.0747d6391a', + settings.minimaxGroupId ? 'From cookie' : 'Use group ID from cookie' + )} + spellCheck={false} + className="text-xs" + /> + + + + + updateSettings({ minimaxUsageModels: e.target.value })} + placeholder={translate('auto.components.settings.AccountsPane.3c92b0d31c', 'general')} + spellCheck={false} + className="text-xs" + /> + +
+
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts new file mode 100644 index 00000000000..7958045b8e1 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' + +describe('getAccountsMiniMaxSearchEntries', () => { + it('returns a single entry that targets the MiniMax session cookie flow', () => { + const entries = getAccountsMiniMaxSearchEntries() + expect(entries).toHaveLength(1) + const [entry] = entries + expect(entry.title).toBe('MiniMax Usage') + expect(entry.description).toContain('platform.minimax.io') + expect(entry.description.toLowerCase()).toContain('cookie') + }) + + it('exposes the keywords that drive the Settings search index', () => { + const [entry] = getAccountsMiniMaxSearchEntries() + // Why: the Settings search needs at least one of these tokens to + // surface the MiniMax section when the user types a related term. + expect(entry.keywords).toEqual( + expect.arrayContaining(['minimax', 'cookie', 'session', 'rate limit', 'status bar']) + ) + }) + + it('is included in the rolled-up pane search entries', () => { + const allEntries = getAccountsPaneSearchEntries() + const titles = allEntries.map((entry) => entry.title) + expect(titles).toContain('MiniMax Usage') + }) +}) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 1d4e1564553..7f0d68a8387 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -171,10 +171,31 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.733f9e2a93', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.accounts.search.f8374c3151', + 'Paste your platform.minimax.io session cookie for local rate-limit fetching.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.d16378a88f', 'minimax'), + ...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'), + ...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), - ...getAccountsOpencodeSearchEntries() + ...getAccountsOpencodeSearchEntries(), + ...getAccountsMiniMaxSearchEntries() ]) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d08ffca7a06..6fcbc68801d 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -53,7 +53,7 @@ import { formatResetCreditExpiry, getProviderUsageStatusLabel } from './tooltip' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' @@ -1689,7 +1689,9 @@ export function ProviderDetailsMenu({ ? 'O' : provider.provider === 'kimi' ? 'K' - : 'X'} + : provider.provider === 'minimax' + ? 'M' + : 'X'} ) : ( @@ -1831,17 +1833,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while // usage snapshots hydrate, fail, or temporarily report unavailable. // Detection-gating (see status-bar-agent-gating) additionally hides per-CLI // bars when the agent isn't installed on PATH. - const visibleClaude = getVisibleUsageProvider('claude', claude, settings) - const visibleCodex = getVisibleUsageProvider('codex', codex, settings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, settings) - const visibleKimi = getVisibleUsageProvider('kimi', kimi, settings) + // Why: thread the cookie durability flag from RateLimitState so the + // MiniMax bar stays visible after a reload and between snapshot refreshes. + const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured } + const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) + const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) + const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) + const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) + const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -1858,9 +1864,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. - const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, settings) + const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') const showResourceUsage = statusBarItems.includes('resource-usage') @@ -1868,12 +1875,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const showFloatingTerminalToggle = floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' const anyVisible = - showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage + showClaude || + showCodex || + showGemini || + showOpencodeGo || + showKimi || + showMiniMax || + showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. - const isEmptyUsageState = isUsageEmptyState({ claude, codex, gemini, opencodeGo, kimi }, settings) + const isEmptyUsageState = isUsageEmptyState( + { claude, codex, gemini, opencodeGo, kimi, minimax }, + usageSettings + ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it // hidden even after providers are disconnected again. const showEmptyUsageCta = isEmptyUsageState && !usageEmptyStateDismissed @@ -1882,7 +1898,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele codex?.status === 'fetching' || gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || - kimi?.status === 'fetching' + kimi?.status === 'fetching' || + minimax?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -1957,6 +1974,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showMiniMax && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2087,6 +2115,16 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')} )} + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('minimax') + }} + > + + {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + { diff --git a/src/renderer/src/components/status-bar/icons.test.tsx b/src/renderer/src/components/status-bar/icons.test.tsx new file mode 100644 index 00000000000..d46d9a65cce --- /dev/null +++ b/src/renderer/src/components/status-bar/icons.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { MiniMaxIcon } from './icons' + +describe('MiniMaxIcon', () => { + it('renders the official MiniMax mark as an image', () => { + const markup = renderToStaticMarkup() + expect(markup.startsWith(' { + const markup = renderToStaticMarkup() + expect(markup).toContain('width="20"') + expect(markup).toContain('height="20"') + }) + + it('does not render the legacy "M" placeholder text', () => { + const markup = renderToStaticMarkup() + expect(markup).not.toContain('>M<') + }) +}) diff --git a/src/renderer/src/components/status-bar/icons.tsx b/src/renderer/src/components/status-bar/icons.tsx index e6fb38bccca..480297b6412 100644 --- a/src/renderer/src/components/status-bar/icons.tsx +++ b/src/renderer/src/components/status-bar/icons.tsx @@ -1,4 +1,5 @@ import React from 'react' +import minimaxIconUrl from '../../../../../resources/minimax-icon.svg?url' export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element { return ( @@ -15,6 +16,24 @@ export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element ) } +export function MiniMaxIcon({ size = 14 }: { size?: number }): React.JSX.Element { + // Why: ship the official MiniMax wordmark mark so the icon is recognizable + // in both light and dark themes. The SVG carries its own pink→orange gradient + // that reads on muted backgrounds, so we keep the asset monochrome-agnostic + // and let CSS theme tokens drive the surrounding chrome instead. + return ( + + ) +} + // Why: each instance needs unique filter/mask IDs — reusing the same ID across // multiple SVGs on the same page causes the browser to resolve to the first one, // breaking all subsequent instances. diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 790a4dbfa82..23de9434c37 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, + minimaxCookieConfigured: false, ...overrides } } @@ -117,6 +118,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) }) it('does not treat empty or unloaded settings as configured', () => { @@ -147,6 +149,17 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false) }) + + it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => { + expect( + hasUsageProviderSettingsForProvider( + 'minimax', + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false) + }) }) describe('getVisibleUsageProvider', () => { @@ -207,6 +220,45 @@ describe('getVisibleUsageProvider', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) }) + + it('keeps MiniMax visible while the snapshot is pending when a cookie is configured', () => { + const visible = getVisibleUsageProvider( + 'minimax', + null, + usageSettings({ minimaxCookieConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'minimax', + status: 'fetching', + session: null, + weekly: null + }) + }) + + it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => { + const unavailable = provider('unavailable', { + provider: 'minimax', + error: 'MiniMax session expired. Replace the MiniMax cookie in Settings.' + }) + expect( + getVisibleUsageProvider( + 'minimax', + unavailable, + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(unavailable) + }) + + it('hides MiniMax when no cookie is configured and the snapshot is empty', () => { + expect(getVisibleUsageProvider('minimax', null, usageSettings())).toBe(null) + expect( + getVisibleUsageProvider( + 'minimax', + provider('unavailable', { provider: 'minimax' }), + usageSettings() + ) + ).toBe(null) + }) }) describe('isUsageEmptyState', () => { @@ -218,7 +270,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, usageSettings() ) @@ -233,7 +286,8 @@ describe('isUsageEmptyState', () => { codex: provider('fetching', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) @@ -248,7 +302,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings({ codexManagedAccounts: [ @@ -274,7 +329,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, null ) @@ -289,7 +345,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index b6e35c292fb..bbac8ba8a38 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -7,7 +7,13 @@ export type UsageProviderSettings = Pick< | 'claudeManagedAccounts' | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' -> +> & { + // Why: the MiniMax cookie lives in the file system, not GlobalSettings, so + // we can't derive durability from settings alone. The renderer threads the + // flag from RateLimitState (pushed by the main process) so the bar stays + // visible across reloads and between snapshot refreshes. + minimaxCookieConfigured: boolean +} type UsageProviderSnapshots = { claude: ProviderRateLimits | null @@ -15,6 +21,7 @@ type UsageProviderSnapshots = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -58,7 +65,8 @@ export function hasUsageProviderSettings( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || - Boolean(settings?.opencodeSessionCookie?.trim()) + Boolean(settings?.opencodeSessionCookie?.trim()) || + settings?.minimaxCookieConfigured === true ) } @@ -81,6 +89,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } + if (providerId === 'minimax') { + return settings.minimaxCookieConfigured === true + } return false } @@ -128,7 +139,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.codex) || isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || - isProviderSnapshotPending(providers.kimi) + isProviderSnapshotPending(providers.kimi) || + isProviderSnapshotPending(providers.minimax) ) { return false } @@ -138,6 +150,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.codex) && !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && - !isProviderConfigured(providers.kimi) + !isProviderConfigured(providers.kimi) && + !isProviderConfigured(providers.minimax) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index cf631423693..510e3eea66f 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -22,6 +22,7 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel, getWindowSections, + ProviderIcon, ProviderPanel } from './tooltip' @@ -402,4 +403,58 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('Fable') expect(markup).toContain('Resets in 6d 17h') }) + + it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + // Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the + // same source field so the two views stay consistent. + expect(markup).toContain('65%') + expect(markup).toContain('% left') + expect(markup).not.toContain('100% left') + }) + + it('clamps MiniMax session to 0% left when usedPercent reports 100', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + expect(markup).toContain('0% left') + }) +}) + +describe('ProviderIcon', () => { + it('renders the official MiniMax icon asset for the minimax provider', () => { + // Why: the icon must travel to the status bar / tooltip unchanged so the + // user recognises the brand. We pin it to an with a non-empty + // resource URL and aria-hidden so the icon stays purely decorative. + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + expect(markup.startsWith(' } + if (provider === 'minimax') { + return + } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts new file mode 100644 index 00000000000..3fc29780adc --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getProviderDisplayName } from './usage-error-copy' + +describe('getProviderDisplayName', () => { + it('returns the MiniMax brand name', () => { + expect(getProviderDisplayName('minimax')).toBe('MiniMax') + }) + + it('returns the existing provider brand names', () => { + expect(getProviderDisplayName('claude')).toBe('Claude') + expect(getProviderDisplayName('codex')).toBe('Codex') + expect(getProviderDisplayName('gemini')).toBe('Gemini') + expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') + expect(getProviderDisplayName('kimi')).toBe('Kimi') + }) + + it('falls back to the raw provider id when no mapping exists', () => { + // Why: provider id is a closed union, but TypeScript may not enforce + // exhaustiveness on dynamic callers. Fallback keeps logging safe. + expect(getProviderDisplayName('unknown-provider' as never)).toBe('unknown-provider') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 60c0b99d452..3d33fe763f6 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'minimax') { + return 'MiniMax' + } return provider } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1080958f7c2..3226d39b6f1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Reset Codex limits?", "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "c0e972d726": "Cancel", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -4634,7 +4636,46 @@ "75ca9b718e": "Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "Use your current {{value0}} Claude login." + "e05d0ff737": "Use your current {{value0}} Claude login.", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).", + "9dd50d3f75": "Advanced", + "174fb408f9": "Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.", + "bf160bb6c0": "Group ID override", + "b1e2743313": "Optional. Leave blank to use minimax_group_id_v2 from the cookie.", + "0747d6391a": "Use group ID from cookie", + "4ff2af7524": "Usage model names", + "5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.", + "3c92b0d31c": "general", + "0d8e77bc40": "Open console", + "0b8c1c7e02": "Encrypted locally", + "5e08b0fe57": "Sent only to platform.minimax.io. Never leaves Orca.", + "43d7a45b97": "How to copy", + "b8a4f21c3e": "Paste the Cookie header from DevTools", + "53f7b8c7a2": "Last refresh: {{value0}}", + "31d24a4e87": "Cookie expires when you sign out in the browser.", + "42c2cb21cf": "Forget cookie", + "3a30aaf526": "just now", + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "24560fe830": "Open DevTools.", + "4cab0fa42d": "Go to the Network tab and enable Preserve log.", + "bee4e63e1c": "Reload the page.", + "87f814af6f": "Filter for remains and select the coding_plan/remains request.", + "435df0ee51": "Under Request Headers, copy the Cookie value.", + "7492fb3bba": "Paste it here and click Save.", + "9fec52de4b": "How to copy the cookie", + "4e32e030b2": "The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -6802,7 +6843,9 @@ "bdbd1e668e": "windows", "593720c17f": "location", "b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.", - "d09fb5ca92": "Account Location" + "d09fb5ca92": "Account Location", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b7a29585352..b7bccb7e426 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2935,7 +2935,9 @@ "972a1ff497": "¿Restablecer límites de Codex?", "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", - "c0e972d726": "Cancelar" + "c0e972d726": "Cancelar", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4634,7 +4636,46 @@ "75ca9b718e": "Codex informó que la cuenta activa requiere un inicio de sesión nuevo. Reautentícala antes de iniciar nuevas sesiones de Codex.", "b11078a9c2": "wsl", "350b2a1aa7": "Usa tu actual", - "e05d0ff737": "Usa tu login actual de Claude de {{value0}}." + "e05d0ff737": "Usa tu login actual de Claude de {{value0}}.", + "2f24f244a4": "La cookie de MiniMax es obligatoria.", + "8d61637a77": "Cookie de MiniMax guardada.", + "b43e761fe5": "No se pudo actualizar la cookie de MiniMax.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configura el seguimiento de uso de MiniMax desde platform.minimax.io.", + "21d6eb141e": "Cookie de sesión de MiniMax", + "33bba5ad83": "Pega tu cookie de sesión de MiniMax para obtener límites de uso locales.", + "73ea15f24b": "Guardado", + "23afe8f226": "No guardado", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Reemplazar", + "590a3130f9": "Guardar", + "79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y copia el encabezado Cookie de DevTools (Red → cualquier solicitud a remains → Cookie).", + "9dd50d3f75": "Avanzado", + "174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al workspace o modelo equivocado.", + "bf160bb6c0": "Anulación de Group ID", + "b1e2743313": "Opcional. Déjalo en blanco para usar minimax_group_id_v2 de la cookie.", + "0747d6391a": "Usar el Group ID de la cookie", + "4ff2af7524": "Nombres de modelos de uso", + "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", + "3c92b0d31c": "general", + "0d8e77bc40": "Abrir consola", + "0b8c1c7e02": "Cifrado localmente", + "5e08b0fe57": "Solo se envía a platform.minimax.io. Nunca sale de Orca.", + "43d7a45b97": "Cómo copiar", + "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", + "53f7b8c7a2": "Última actualización: {{value0}}", + "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", + "42c2cb21cf": "Olvidar cookie", + "3a30aaf526": "ahora mismo", + "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", + "24560fe830": "Abre DevTools.", + "4cab0fa42d": "Ve a la pestaña Red y activa Preserve log.", + "bee4e63e1c": "Recarga la página.", + "87f814af6f": "Filtra por remains y selecciona la solicitud coding_plan/remains.", + "435df0ee51": "En Request Headers, copia el valor de Cookie.", + "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", + "9fec52de4b": "Cómo copiar la cookie", + "4e32e030b2": "La cookie permanece en este dispositivo. Orca solo la envía a platform.minimax.io para actualizar el uso." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", @@ -6765,7 +6806,9 @@ "bdbd1e668e": "Windows", "593720c17f": "ubicación", "b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.", - "d09fb5ca92": "Ubicación de cuentas" + "d09fb5ca92": "Ubicación de cuentas", + "733f9e2a93": "Uso de MiniMax", + "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e6ff4fc77f5..2496debcd8c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Codex の制限をリセットしますか?", "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", "f077f586db": "今後表示しない", - "c0e972d726": "キャンセル" + "c0e972d726": "キャンセル", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex は、アクティブなアカウントには新たにサインインする必要があると報告しました。新規 Codex セッションを開始する前に、再認証してください。", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。" + "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。", + "9dd50d3f75": "詳細設定", + "174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。", + "bf160bb6c0": "グループ ID の上書き", + "b1e2743313": "任意。空白にすると Cookie の minimax_group_id_v2 が使われます。", + "0747d6391a": "Cookie からグループ ID を使用", + "4ff2af7524": "使用モデルの名前", + "5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。", + "3c92b0d31c": "general", + "0d8e77bc40": "コンソールを開く", + "0b8c1c7e02": "ローカルで暗号化", + "5e08b0fe57": "platform.minimax.io のみに送信されます。Orca の外部に出ることはありません。", + "43d7a45b97": "コピー方法", + "b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け", + "53f7b8c7a2": "最終更新: {{value0}}", + "31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。", + "42c2cb21cf": "Cookie を削除", + "3a30aaf526": "たった今", + "f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。", + "24560fe830": "DevTools を開きます。", + "4cab0fa42d": "ネットワークタブを開き、Preserve log を有効にします。", + "bee4e63e1c": "ページを再読み込みします。", + "87f814af6f": "remains でフィルタリングし、coding_plan/remains のリクエストを選択します。", + "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", + "7492fb3bba": "ここに貼り付けて保存をクリックします。", + "9fec52de4b": "Cookie のコピー方法", + "4e32e030b2": "Cookie はこのデバイスにのみ保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -6787,7 +6828,9 @@ "bdbd1e668e": "窓", "593720c17f": "場所", "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", - "d09fb5ca92": "アカウントの場所" + "d09fb5ca92": "アカウントの場所", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 705815c7739..e774013d063 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Codex 한도를 재설정할까요?", "6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.", "f077f586db": "다시 묻지 않기", - "c0e972d726": "취소" + "c0e972d726": "취소", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex는 활성 계정에 새로 로그인해야 한다고 보고했습니다. 새 Codex 세션을 시작하기 전에 다시 인증하세요.", "b11078a9c2": "wsl", "350b2a1aa7": "현재 사용 중인", - "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요." + "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.", + "9dd50d3f75": "고급", + "174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.", + "bf160bb6c0": "Group ID 재정의", + "b1e2743313": "선택 항목입니다. 비워두면 Cookie의 minimax_group_id_v2가 사용됩니다.", + "0747d6391a": "Cookie의 Group ID 사용", + "4ff2af7524": "사용 모델 이름", + "5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.", + "3c92b0d31c": "general", + "0d8e77bc40": "콘솔 열기", + "0b8c1c7e02": "로컬에서 암호화됨", + "5e08b0fe57": "platform.minimax.io에만 전송됩니다. Orca 외부로 나가지 않습니다.", + "43d7a45b97": "복사 방법", + "b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기", + "53f7b8c7a2": "마지막 새로 고침: {{value0}}", + "31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.", + "42c2cb21cf": "Cookie 삭제", + "3a30aaf526": "방금 전", + "f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.", + "24560fe830": "DevTools를 엽니다.", + "4cab0fa42d": "네트워크 탭으로 이동하여 Preserve log를 활성화합니다.", + "bee4e63e1c": "페이지를 새로 고침합니다.", + "87f814af6f": "remains로 필터링하고 coding_plan/remains 요청을 선택합니다.", + "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", + "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", + "9fec52de4b": "Cookie 복사 방법", + "4e32e030b2": "Cookie는 이 기기에만 보관됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -6750,7 +6791,9 @@ "bdbd1e668e": "Windows", "593720c17f": "위치", "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", - "d09fb5ca92": "계정 위치" + "d09fb5ca92": "계정 위치", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index db284162150..29d853b248b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2935,7 +2935,9 @@ "972a1ff497": "重置 Codex 限额?", "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", "f077f586db": "不再询问", - "c0e972d726": "取消" + "c0e972d726": "取消", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex 报告活动账户需要重新登录。在开始新的 Codex 会话之前重新对其进行身份验证。", "b11078a9c2": "wsl", "350b2a1aa7": "用你已有的", - "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。" + "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", + "9dd50d3f75": "高级", + "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", + "bf160bb6c0": "Group ID 覆盖", + "b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。", + "0747d6391a": "使用 Cookie 中的 Group ID", + "4ff2af7524": "使用模型名称", + "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", + "3c92b0d31c": "general", + "0d8e77bc40": "打开控制台", + "0b8c1c7e02": "本地加密", + "5e08b0fe57": "仅发送到 platform.minimax.io。不会离开 Orca。", + "43d7a45b97": "如何复制", + "b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头", + "53f7b8c7a2": "上次刷新: {{value0}}", + "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", + "42c2cb21cf": "忘记 Cookie", + "3a30aaf526": "刚刚", + "f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。", + "24560fe830": "打开 DevTools。", + "4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。", + "bee4e63e1c": "重新加载页面。", + "87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。", + "435df0ee51": "在请求头中,复制 Cookie 的值。", + "7492fb3bba": "在此粘贴并点击保存。", + "9fec52de4b": "如何复制 Cookie", + "4e32e030b2": "Cookie 仅保留在此设备上。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -6750,7 +6791,9 @@ "bdbd1e668e": "视窗", "593720c17f": "位置", "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", - "d09fb5ca92": "账户位置" + "d09fb5ca92": "账户位置", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 730afbbd5ad..01050c93cc7 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -37,4 +37,21 @@ describe('formatWindowLabel', () => { it('returns "3d" for 4320 minutes (3 days)', () => { expect(formatWindowLabel(4320)).toBe('3d') }) + + it('documents the formatter contract: 295 minutes stays raw ("295m")', () => { + // Why: the formatter does NOT snap — the snap lives in the MiniMax + // fetcher (snapMiniMaxWindowMinutes), where raw drift of a few minutes + // rounds to the canonical 300-minute bucket before reaching this label. + // This test pins the contract so a future change to the formatter + // does not silently regress the snap's "5h" output. + expect(formatWindowLabel(295)).toBe('295m') + expect(formatWindowLabel(300)).toBe('5h') + }) + + it('falls back to per-minute labels outside canonical buckets', () => { + // Why: when the window length lands between buckets (e.g. 2h30m), we + // render the raw minute count rather than guess at a half-bucket label. + expect(formatWindowLabel(75)).toBe('75m') + expect(formatWindowLabel(150)).toBe('150m') + }) }) diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index fa4d41b9b03..4513119ab69 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -21,6 +21,8 @@ export const createRateLimitSlice: StateCreator['rateLimits']> { gemini: null, opencodeGo: null, kimi: null, + minimax: null, + minimaxCookieConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -2469,6 +2471,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 26db028766d..76afe5caab4 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -125,3 +125,15 @@ describe('getDefaultPrimarySelectionMiddleClickPaste', () => { expect(getDefaultPrimarySelectionMiddleClickPaste('win32')).toBe(false) }) }) + +describe('MiniMax defaults', () => { + it('starts MiniMax with empty group id and the canonical default model', () => { + const settings = getDefaultSettings('/tmp') + // Why: the fetcher reads these defaults on first launch. An empty + // group id is the signal that the fetcher must pull the value from + // the cookie itself, and "general" matches the model name the + // MiniMax usage endpoint exposes by default. + expect(settings.minimaxGroupId).toBe('') + expect(settings.minimaxUsageModels).toBe('general') + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4eebbaa9383..cc9f8ce9603 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -320,6 +320,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts new file mode 100644 index 00000000000..dfc8c1dad31 --- /dev/null +++ b/src/shared/rate-limit-types.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { RateLimitState } from './rate-limit-types' + +describe('RateLimitState', () => { + it('documents the MiniMax surface used by the AccountsPane settings UI', () => { + // Why: the AccountsPane and the status bar both read these fields + // from RateLimitState. The shape must stay stable so that the + // visibility check (status-bar-provider-visibility) keeps working + // across refactors. + const state: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + minimax: null, + minimaxCookieConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + + expect(state.minimax).toBeNull() + expect(state.minimaxCookieConfigured).toBe(false) + }) +}) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 59a897f51e1..6ed2c696f1b 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -101,6 +101,14 @@ export type RateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null + /** + * True when a MiniMax session cookie is persisted on disk. The cookie lives + * outside GlobalSettings, so this flag is the durable signal that the + * status bar uses to keep the MiniMax provider visible across reloads and + * between snapshot refreshes. + */ + minimaxCookieConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 7e673849589..5e9939c6461 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'kimi', + 'minimax', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/types.ts b/src/shared/types.ts index 57c2d61c188..8d3268035aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2763,6 +2763,10 @@ export type GlobalSettings = { /** Optional workspace ID override for OpenCode Go. When set, skips the * workspaces lookup and fetches usage directly for this workspace. */ opencodeWorkspaceId: string + /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ + minimaxGroupId: string + /** Comma-separated MiniMax model names to show in the status bar usage window. */ + minimaxUsageModels: string /** Whether to extract OAuth credentials from the local Gemini CLI installation * for rate-limit fetching. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean @@ -3109,6 +3113,7 @@ export type StatusBarItem = | 'gemini' | 'opencode-go' | 'kimi' + | 'minimax' | 'ssh' | 'resource-usage' | 'ports' From a169b7d03d6d58d3d4959c27817eaf9b9740f6be Mon Sep 17 00:00:00 2001 From: Wolfie Date: Sat, 4 Jul 2026 23:52:24 -0700 Subject: [PATCH 114/141] fix: focus new floating-workspace agent tab on launch (#6250) * fix: focus new floating-workspace agent tab on launch The default-agent button in the floating workspace titlebar created its tab with { activate: false } and only called setActiveTabForWorktree, which writes activeTabIdByWorktree. The floating panel selects its visible tab from the unified group's activeTabId, so the new agent tab was appended but never selected or focused. Add activateTab(tab.id), matching the empty-state tab creators, so launching a new agent switches to and focuses its tab. * test: use top-level react type import in floating controls test * test: lock queue-before-activate order and state updates for agent launch Strengthen the FloatingTerminalWindowControls regression test per review: - make the createTab mock append the new tab to the worktree (realistic store) - seed an existing tab so append/order behavior is observable - assert startup command is queued before activateTab (invocationCallOrder) - assert setActiveTabForWorktree and tab bar order placement --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> --- .../FloatingTerminalWindowControls.test.tsx | 233 ++++++++++++++++++ .../FloatingTerminalWindowControls.tsx | 8 +- 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx new file mode 100644 index 00000000000..a4be125a2f9 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' + +type ReactElementLike = { + type: unknown + props: Record +} + +const storeBox = vi.hoisted(() => ({ + state: null as unknown +})) + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + createTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabBarOrder: vi.fn(), + queueTabStartupCommand: vi.fn(), + focusTerminalTabSurface: vi.fn(), + buildAgentStartupPlan: vi.fn() +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeBox.state), { + getState: () => storeBox.state + }) +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: mocks.focusTerminalTabSurface +})) + +vi.mock('@/lib/tui-agent-startup', () => ({ + buildAgentStartupPlan: mocks.buildAgentStartupPlan +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [{ id: 'claude', label: 'Claude' }], + AgentIcon: function AgentIcon() { + return null + } +})) + +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'darwin' +})) + +vi.mock('@/lib/telemetry', () => ({ + tuiAgentToAgentKind: () => 'claude' +})) + +vi.mock('../../../../shared/tui-agent-selection', () => ({ + isTuiAgentEnabled: () => true +})) + +vi.mock('../../../../shared/tui-agent-launch-defaults', () => ({ + resolveTuiAgentLaunchArgs: () => [], + resolveTuiAgentLaunchEnv: () => ({}) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, vars?: Record) => + vars ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars[name] ?? '') : fallback +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: () => null +})) + +vi.mock('@/components/ui/button', () => ({ + Button: function Button(props: { children?: unknown }) { + return props.children ?? null + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: function Tooltip(props: { children?: unknown }) { + return props.children + }, + TooltipContent: function TooltipContent(props: { children?: unknown }) { + return props.children + }, + TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { + return props.children + } +})) + +vi.mock('lucide-react', () => ({ + Maximize2: function Maximize2() { + return null + }, + Minimize2: function Minimize2() { + return null + }, + Minus: function Minus() { + return null + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + if (!element.props) { + return + } + cb(element) + visit(element.props.children, cb) +} + +function findOnClickByAriaLabel(node: unknown, ariaLabel: string): () => void { + let found: (() => void) | null = null + visit(node, (entry) => { + if (entry.props['aria-label'] === ariaLabel && typeof entry.props.onClick === 'function') { + found = entry.props.onClick as () => void + } + }) + if (!found) { + throw new Error(`onClick for aria-label "${ariaLabel}" not found`) + } + return found +} + +const NEW_AGENT_TAB_ID = 'floating-agent-tab' +const EXISTING_TAB_ID = 'floating-existing-tab' + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset() + } + mocks.createTab.mockImplementation(() => { + const tab = { id: NEW_AGENT_TAB_ID } + const state = storeBox.state as { tabsByWorktree: Record } + const existing = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [...existing, tab] + return tab + }) + mocks.buildAgentStartupPlan.mockReturnValue({ + launchCommand: 'claude', + launchConfig: {}, + env: undefined, + startupCommandDelivery: undefined + }) + storeBox.state = { + settings: { + defaultTuiAgent: 'claude', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }, + createTab: mocks.createTab, + activateTab: mocks.activateTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabBarOrder: mocks.setTabBarOrder, + queueTabStartupCommand: mocks.queueTabStartupCommand, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] }, + tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [EXISTING_TAB_ID] } + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('FloatingTerminalWindowControls default-agent launch', () => { + it('activates the new agent tab so the floating panel selects and focuses it', () => { + const element = FloatingTerminalWindowControls({ + maximized: false, + onToggleMaximized: vi.fn(), + onMinimize: vi.fn() + }) + + const launch = findOnClickByAriaLabel(element, 'Open Claude in floating workspace') + launch() + + expect(mocks.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + undefined, + { activate: false } + ) + // Why: TerminalPane consumes any pending startup command on first render, so + // the launch command must be queued before activation can mount the surface - + // otherwise the new tab can come up as a bare shell. + expect(mocks.queueTabStartupCommand).toHaveBeenCalledWith( + NEW_AGENT_TAB_ID, + expect.objectContaining({ + command: 'claude', + launchAgent: 'claude' + }) + ) + expect(mocks.queueTabStartupCommand.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTab.mock.invocationCallOrder[0] + ) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId, which only activateTab writes. setActiveTabForWorktree updates + // the complementary legacy per-worktree map. Without activateTab the new agent + // tab would be appended but never selected/focused. + expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + NEW_AGENT_TAB_ID + ) + expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + // Why: createTab appends the new tab to the worktree; the order reconciliation + // must keep the pre-existing tab and place the new agent tab last. + expect(mocks.setTabBarOrder).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, [ + EXISTING_TAB_ID, + NEW_AGENT_TAB_ID + ]) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 514402a0f36..03213e9820e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -43,6 +43,7 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const activateTab = useAppStore((s) => s.activateTab) const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') @@ -100,7 +101,12 @@ export function FloatingTerminalWindowControls({ request_kind: 'new' } }) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so + // the new agent tab would be appended but never selected/focused. activateTab + // selects it within the group, matching the empty-state tab creators. setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) + activateTab(tab.id) const fresh = useAppStore.getState() const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] @@ -114,7 +120,7 @@ export function FloatingTerminalWindowControls({ order.push(tab.id) fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order) focusTerminalTabSurface(tab.id) - }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) + }, [activateTab, createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) return (
From 6677de1807d524c992827e89f1fd804f1dbc5343 Mon Sep 17 00:00:00 2001 From: leter <117361093@qq.com> Date: Sun, 5 Jul 2026 14:54:26 +0800 Subject: [PATCH 115/141] Improve SSH Node.js prerequisite guidance Improve SSH remote Node.js prerequisite failures by adding package-manager-specific POSIX install guidance, Windows Node 18+ candidate validation, fnm XDG probing, and regression coverage while preserving SSH abort and session-limit behavior. --- .../ssh/ssh-remote-node-install-guidance.ts | 93 +++++++++ .../ssh/ssh-remote-node-resolution.test.ts | 84 ++++++++- src/main/ssh/ssh-remote-node-resolution.ts | 177 +++++++++++++----- 3 files changed, 304 insertions(+), 50 deletions(-) create mode 100644 src/main/ssh/ssh-remote-node-install-guidance.ts diff --git a/src/main/ssh/ssh-remote-node-install-guidance.ts b/src/main/ssh/ssh-remote-node-install-guidance.ts new file mode 100644 index 00000000000..e4ca3a0d3c3 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-install-guidance.ts @@ -0,0 +1,93 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' + +export type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + +const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000 + +const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [ + { bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' }, + { bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' }, + { bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' }, + { bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' }, + { bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' }, + { bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' }, + { bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' } +] + +export async function buildPosixNodeInstallGuidance( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + const detectedBin = await detectPackageManager(conn, options) + return formatNodeInstallHints(detectedBin) +} + +async function detectPackageManager( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + try { + const output = await execCommand( + conn, + `for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`, + commandOptions(options) + ) + const detected = output.trim().split('\n')[0] + return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + return null + } +} + +function formatNodeInstallHints(detectedBin: string | null): string { + const tailored = detectedBin + ? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin) + : null + const lines = [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ with npm on the remote host, then reconnect:' + ] + if (tailored) { + lines.push(` ${tailored.label}: ${tailored.install}`) + } else { + for (const hint of NODE_PACKAGE_MANAGER_HINTS) { + lines.push(` ${hint.label}: ${hint.install}`) + } + } + lines.push( + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.' + ) + return lines.join('\n') +} + +function commandOptions(options?: RemoteNodeResolutionOptions): { + timeoutMs: number + signal?: AbortSignal +} { + const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS } + return options?.signal ? { ...base, signal: options.signal } : base +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } +} diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b3cad3c0ecc..84d9b9b48e7 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + if (!match) { + throw new Error(`No encoded PowerShell command found in: ${command}`) + } + return Buffer.from(match[1]!, 'base64').toString('utf16le') +} + describe('resolveRemoteNodePath', () => { beforeEach(() => { execCommandMock.mockReset() @@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node') expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') }) + it('probes fnm XDG data directory installs', async () => { + execCommandMock + .mockResolvedValueOnce( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n' + ) + .mockResolvedValueOnce('v24.18.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node' + ) + }) + it('does not depend on GNU sort when probing version-manager directories', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') @@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('\n') // path probe: empty .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockResolvedValueOnce('apt-get\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/) }) it('throws when the path-probe exec fails and the login shell finds nothing', async () => { @@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => { .mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors .mockResolvedValueOnce('/bin/zsh') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockRejectedValueOnce(new Error('package manager probe failed')) - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/) }) it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { @@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('\n') + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => { // Why: in concurrent bootstrap a sibling probe aborts this resolution via // the shared signal. Each strategy catch swallows the injected AbortError, @@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('/old/node2\n') // login shell .mockResolvedValueOnce('v6.17.0\n') // too old + .mockResolvedValueOnce('dnf\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/) + }) + + it('rejects a Windows node.exe below the minimum version', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths + .mockResolvedValueOnce('v16.20.2\n') // version check: too old + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow( + /winget install OpenJS\.NodeJS\.LTS/ + ) + }) + + it('does not stop Windows node discovery after the first existing candidate', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n') + .mockResolvedValueOnce('v16.20.2\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe( + 'C:/Program Files/nodejs/node.exe' + ) + + const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) + expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) }) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index e4bb3181b79..52baa610d3f 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' -import { powerShellCommand } from './ssh-remote-powershell' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + buildPosixNodeInstallGuidance, + type RemoteNodeResolutionOptions +} from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' import { isSshSessionLimitError } from './ssh-session-limit-error' @@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 -type RemoteNodeResolutionOptions = { - rethrowSessionLimitErrors?: boolean - signal?: AbortSignal -} - export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform, @@ -47,7 +46,7 @@ export async function resolveRemoteNodePath( return loginShellPath } - throwNodeNotFound(options) + return throwNodeNotFound(conn, options) } // Probe the on-disk install directories of every common Node version manager @@ -91,6 +90,7 @@ for candidate in \\ "$HOME/.local/bin/node" \\ "$HOME/.fnm/aliases/default/bin/node" \\ "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ "$HOME/.local/share/mise/shims/node" \\ "$HOME/.local/share/mise/installs/node"/*/bin/node \\ "$HOME/.asdf/shims/node" \\ @@ -104,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script, { signal: options?.signal }) + const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -138,20 +138,21 @@ async function tryResolveViaLoginShell( // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). - const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const shellResult = await execCommand( + conn, + 'echo "${SHELL:-/bin/sh}"', + commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const shell = shellResult.trim().split('\n')[0] if (!shell) { return null } - const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), { - wrapCommand: false, - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const nodePath = await execCommand( + conn, + buildCommandInShell(shell, 'command -v node'), + commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const candidate = nodePath.trim().split('\n')[0] if (!candidate) { return null @@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement( options?: RemoteNodeResolutionOptions ): Promise { try { - const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false, - signal: options?.signal - }) - const match = versionOutput.trim().match(/^v?(\d+)/) - if (!match) { - return false - } - const major = Number.parseInt(match[1]!, 10) - return major >= MIN_NODE_MAJOR + const versionOutput = await execCommand( + conn, + `${shellEscape(nodePath)} --version`, + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath( 'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }', 'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }', 'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }', + '$found = $false', 'foreach ($path in $paths) {', ' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {', ' Write-Output $path', - ' exit 0', + ' $found = $true', ' }', '}', + 'if ($found) { exit 0 }', "Write-Error 'Node.js not found'", 'exit 1' ].join('\n') try { - const result = await execCommand(conn, powerShellCommand(script), { - wrapCommand: false, - signal: options?.signal - }) - const nodePath = result.trim().split('\n')[0] - if (nodePath) { + const result = await execCommand( + conn, + powerShellCommand(script), + commandOptions({ wrapCommand: false }, options) + ) + for (const line of result.split('\n')) { + const nodePath = line.trim() + if (!nodePath) { + continue + } const normalized = normalizeWindowsRemotePath(nodePath) - console.log(`[ssh-relay] Found Windows node at: ${normalized}`) - return normalized + if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) { + console.log(`[ssh-relay] Found Windows node at: ${normalized}`) + return normalized + } } } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { @@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath( // Fall through to the shared error below. } - throwNodeNotFound(options) + throwWindowsNodeNotFound(options) } -function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never { - // Why: when a sibling bootstrap probe aborts this resolution via the shared - // signal, the strategy catch-blocks swallow the injected AbortError and fall - // through to here. Surfacing "Node.js not found" would launder a cancellation - // into a fatal non-session failure, defeating the concurrent-bootstrap - // sequential fallback. Re-raise the abort so callers classify it correctly. +async function windowsNodeMeetsVersionRequirement( + conn: SshConnection, + nodePath: string, + options?: RemoteNodeResolutionOptions +): Promise { + try { + const versionOutput = await execCommand( + conn, + powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`), + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + return false + } +} + +function nodeVersionMeetsRequirement(versionOutput: string): boolean { + const match = versionOutput.trim().match(/^v?(\d+)/) + if (!match) { + return false + } + const major = Number.parseInt(match[1]!, 10) + return major >= MIN_NODE_MAJOR +} + +async function throwNodeNotFound( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const guidance = await buildPosixNodeInstallGuidance(conn, options) + throwIfAborted(options) + throw new Error(guidance) +} + +function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never { + throwIfAborted(options) + throw new Error( + [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ on the remote host, then reconnect:', + ' winget install OpenJS.NodeJS.LTS', + ' choco install nodejs-lts', + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If those package managers are unavailable, install an LTS release from https://nodejs.org/.' + ].join('\n') + ) +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + // Why: strategy fallbacks intentionally swallow probe failures, but a shared + // bootstrap abort must stay an AbortError so callers can continue fallback. if (options?.signal?.aborted) { throw createSshOperationAbortError() } - throw new Error( - 'Node.js not found on remote host. Orca relay requires Node.js 18+. ' + - 'Install Node.js on the remote and try again.' - ) +} + +function signalOnlyOptions( + options?: RemoteNodeResolutionOptions +): { signal: AbortSignal } | undefined { + return options?.signal ? { signal: options.signal } : undefined +} + +type RemoteExecOptions = { + wrapCommand?: boolean + timeoutMs?: number + signal?: AbortSignal +} + +function commandOptions( + base: RemoteExecOptions, + options?: RemoteNodeResolutionOptions +): RemoteExecOptions { + return options?.signal ? { ...base, signal: options.signal } : base +} + +async function execCommandWithOptionalOptions( + conn: SshConnection, + command: string, + options?: { signal: AbortSignal } +): Promise { + return options ? execCommand(conn, command, options) : execCommand(conn, command) } From d6c96e77d2db1c6c0acc019bd526250ad580051b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:00:25 -0700 Subject: [PATCH 116/141] Surface detailed commit failure summaries in Create PR flow (#7401) Instead of showing a generic commit failure message during the Create PR intent flow, leverage `summarizeCommitFailure` to extract the underlying reason (such as pre-commit hook or lint failures) and present it clearly. Also introduce `commitErrorsRef` to reliably track and retrieve the most up-to-date commit errors across renders without stale closure issues. --- .../right-sidebar/SourceControl.tsx | 45 +++++++++++++------ ...urce-control-create-pr-intent-flow.test.ts | 20 +++++++++ .../source-control-create-pr-intent-flow.ts | 21 +++++++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 8 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 11803eee85e..85080732d4c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -254,6 +254,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, @@ -1027,6 +1028,7 @@ function SourceControlInner(): React.JSX.Element { loadSessionCommitDrafts() ) const commitDraftsRef = useRef(commitDrafts) + const commitErrorsRef = useRef>({}) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< Record @@ -1155,6 +1157,13 @@ function SourceControlInner(): React.JSX.Element { }, [] ) + const setCommitErrorForWorktree = useCallback( + (worktreeId: string, message: string | null): void => { + commitErrorsRef.current = { ...commitErrorsRef.current, [worktreeId]: message } + setCommitErrors((prev) => ({ ...prev, [worktreeId]: message })) + }, + [] + ) const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null @@ -1917,6 +1926,7 @@ function SourceControlInner(): React.JSX.Element { return changed ? next : prev } updateCommitDrafts((prev) => pruneRecord(prev)) + commitErrorsRef.current = pruneRecord(commitErrorsRef.current) setCommitErrors((prev) => pruneRecord(prev)) setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) @@ -2037,7 +2047,7 @@ function SourceControlInner(): React.JSX.Element { commitInFlightRef.current[target.worktreeId] = true setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) try { const commitResult = await commitRuntimeGit( { @@ -2050,10 +2060,7 @@ function SourceControlInner(): React.JSX.Element { message ) if (!commitResult.success) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: commitResult.error ?? 'Commit failed' - })) + setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed') return false } @@ -2071,7 +2078,7 @@ function SourceControlInner(): React.JSX.Element { } return writeCommitDraftForWorktree(prev, target.worktreeId, '') }) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) if (!options?.target) { void refreshActiveGitStatusAfterMutation() } @@ -2103,10 +2110,10 @@ function SourceControlInner(): React.JSX.Element { } return true } catch (error) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: error instanceof Error ? error.message : 'Commit failed' - })) + setCommitErrorForWorktree( + target.worktreeId, + error instanceof Error ? error.message : 'Commit failed' + ) return false } finally { setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false })) @@ -2122,6 +2129,7 @@ function SourceControlInner(): React.JSX.Element { compareBaseRef, grouped.staged.length, refreshActiveGitStatusAfterMutation, + setCommitErrorForWorktree, updateCommitDrafts, unresolvedConflicts.length, worktreePath @@ -3822,12 +3830,21 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } + const commitFailure = commitErrorsRef.current[token.worktreeId] ?? null setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', - 'Could not commit changes. Fix the issue, then retry Create PR.' - ) + message: getCreatePrIntentCommitFailureNoticeMessage(commitFailure, { + fallback: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', + 'Could not commit changes. Fix the issue, then retry Create PR.' + ), + withSummary: (summary) => + translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitBlockedSummary', + 'Commit blocked: {{value0}} Fix the issue, then retry Create PR.', + { value0: summary } + ) + }) }) return } diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 07bff95171e..74853dd4147 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -5,6 +5,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep @@ -241,4 +242,23 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('blocked') }) + + it('surfaces the commit failure summary in the Create PR intent notice', () => { + expect( + getCreatePrIntentCommitFailureNoticeMessage( + 'husky - pre-commit hook\neslint found 2 errors\nfull output' + ) + ).toBe('Commit blocked: Lint failed during commit. Fix the issue, then retry Create PR.') + + expect(getCreatePrIntentCommitFailureNoticeMessage(null)).toBe( + 'Could not commit changes. Fix the issue, then retry Create PR.' + ) + + expect( + getCreatePrIntentCommitFailureNoticeMessage('pre-commit hook failed', { + fallback: 'fallback', + withSummary: (summary) => `localized ${summary}` + }) + ).toBe('localized Pre-commit hook failed.') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 009315fb197..7e183122ec5 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -5,6 +5,7 @@ import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none' @@ -145,3 +146,23 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } + +export function getCreatePrIntentCommitFailureNoticeMessage( + commitError: string | null | undefined, + copy: { + fallback: string + withSummary: (summary: string) => string + } = { + fallback: 'Could not commit changes. Fix the issue, then retry Create PR.', + withSummary: (summary: string) => + `Commit blocked: ${summary} Fix the issue, then retry Create PR.` + } +): string { + const summary = commitError ? summarizeCommitFailure(commitError) : null + + if (!summary) { + return copy.fallback + } + + return copy.withSummary(summary) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3226d39b6f1..d07151569b4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value0}} commits ahead of {{value1}}", "c1a8f3e204": "1 commit behind {{value0}}", "d2b9g4f315": "{{value0}} commits behind {{value1}}", - "e9c2a5d416": "Even with {{value0}}" + "e9c2a5d416": "Even with {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b7bccb7e426..51f3c236b5d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value0}} commits por delante de {{value1}}", "c1a8f3e204": "1 commit por detrás de {{value0}}", "d2b9g4f315": "{{value0}} commits por detrás de {{value1}}", - "e9c2a5d416": "A la par con {{value0}}" + "e9c2a5d416": "A la par con {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2496debcd8c..be351905992 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value1}}より{{value0}}commits 進んでいます", "c1a8f3e204": "{{value0}}より1commit 遅れています", "d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています", - "e9c2a5d416": "{{value0}}と同じ状態" + "e9c2a5d416": "{{value0}}と同じ状態", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e774013d063..ef3d48a60bb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음", "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", - "e9c2a5d416": "{{value0}}와 동일한 상태" + "e9c2a5d416": "{{value0}}와 동일한 상태", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 29d853b248b..5a539073c54 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9232,7 +9232,8 @@ "b715ef615b": "领先 {{value1}} {{value0}} 个提交", "c1a8f3e204": "落后 {{value0}} 1 个提交", "d2b9g4f315": "落后 {{value1}} {{value0}} 个提交", - "e9c2a5d416": "与 {{value0}} 同步" + "e9c2a5d416": "与 {{value0}} 同步", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。", From ec9efe4638d110ec3aa791ee29ba8cc508ac694e Mon Sep 17 00:00:00 2001 From: jeganathan I Date: Sun, 5 Jul 2026 12:31:30 +0530 Subject: [PATCH 117/141] fix: distinguish duplicate project names in picker (#6272) * fix: distinguish duplicate project names in picker * review: disambiguate duplicate project picker details Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../new-workspace/ProjectCombobox.test.tsx | 30 +++ src/renderer/src/hooks/useComposerState.ts | 5 +- ...new-workspace-duplicate-project-details.ts | 147 ++++++++++ .../lib/new-workspace-project-options.test.ts | 251 ++++++++++++++++++ .../src/lib/new-workspace-project-options.ts | 28 +- 5 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/lib/new-workspace-duplicate-project-details.ts diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 63e88f9e0d1..cb0309d3e6e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -138,4 +138,34 @@ describe('ProjectCombobox', () => { expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group') }) + + it('renders directory details for duplicate project names', () => { + const duplicateProjects: NewWorkspaceProjectOption[] = [ + { + kind: 'project', + id: 'project:merchant-a', + projectId: 'project:merchant-a', + displayName: 'merchant', + badgeColor: '#111111', + detail: '/workspace/storefront/merchant' + }, + { + kind: 'project', + id: 'project:merchant-b', + projectId: 'project:merchant-b', + displayName: 'merchant', + badgeColor: '#222222', + detail: '/workspace/admin/merchant' + } + ] + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('/workspace/storefront/merchant') + expect(container.textContent).toContain('/workspace/admin/merchant') + }) }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 33049241411..dc6e1aa1957 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -825,9 +825,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, eligibleRepos, - projectGroups + projectGroups, + hosts: hostOptions }), - [eligibleRepos, projectGroups, projectHostSetups, projects] + [eligibleRepos, hostOptions, projectGroups, projectHostSetups, projects] ) const selectedRepoSettings = useMemo(() => { if (!settings) { diff --git a/src/renderer/src/lib/new-workspace-duplicate-project-details.ts b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts new file mode 100644 index 00000000000..127b8b99812 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts @@ -0,0 +1,147 @@ +import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host' +import type { NewWorkspaceProjectOption } from './new-workspace-project-options' + +export type ProjectSetupDirectory = { + path: string + hostId: ExecutionHostId +} + +export type ProjectOptionDraft = Extract & { + detailSource: 'provider' | 'generic' +} + +type ProjectDirectoryDetailMode = 'path' | 'host-label' | 'host-id' + +function getRepeatedValues(values: readonly string[]): Set { + const seen = new Set() + const repeated = new Set() + for (const value of values) { + if (seen.has(value)) { + repeated.add(value) + continue + } + seen.add(value) + } + return repeated +} + +function getProjectDirectoryDetail( + directories: readonly ProjectSetupDirectory[], + hostLabelById: ReadonlyMap, + mode: ProjectDirectoryDetailMode +): string | null { + const detailByKey = new Map() + for (const directory of directories) { + const path = directory.path.trim() + if (!path) { + continue + } + const hostLabel = + hostLabelById.get(directory.hostId)?.trim() || getExecutionHostLabel(directory.hostId) + const detail = + mode === 'path' + ? path + : mode === 'host-id' + ? `${hostLabel} (${directory.hostId}) · ${path}` + : `${hostLabel} · ${path}` + const key = mode === 'path' ? path : `${directory.hostId}\0${path}` + detailByKey.set(key, detail) + } + + const distinctDetails = [...detailByKey.values()].sort() + if (distinctDetails.length === 0) { + return null + } + + const [firstDetail] = distinctDetails + return distinctDetails.length === 1 + ? firstDetail + : `${firstDetail} (+${distinctDetails.length - 1} more)` +} + +export function getDuplicateProjectDetailsById( + options: readonly ProjectOptionDraft[], + setupDirectoriesByProjectId: ReadonlyMap, + hostLabelById: ReadonlyMap +): Map { + const optionsByName = new Map() + for (const option of options) { + optionsByName.set(option.displayName, [ + ...(optionsByName.get(option.displayName) ?? []), + option + ]) + } + + const detailsById = new Map() + for (const sameNameOptions of optionsByName.values()) { + if (sameNameOptions.length < 2) { + continue + } + + const repeatedProviderDetails = getRepeatedValues( + sameNameOptions + .filter((option) => option.detailSource === 'provider') + .map((option) => option.detail) + ) + const ambiguousOptions = sameNameOptions.filter( + (option) => option.detailSource === 'generic' || repeatedProviderDetails.has(option.detail) + ) + if (ambiguousOptions.length === 0) { + continue + } + + const pathOnlyDetailsById = new Map() + for (const option of ambiguousOptions) { + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'path' + ) + if (detail) { + pathOnlyDetailsById.set(option.id, detail) + } + } + + const repeatedPathOnlyDetails = getRepeatedValues([...pathOnlyDetailsById.values()]) + const hostLabelDetailsById = new Map() + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail || !repeatedPathOnlyDetails.has(pathOnlyDetail)) { + continue + } + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-label' + ) + if (detail) { + hostLabelDetailsById.set(option.id, detail) + } + } + + const repeatedHostLabelDetails = getRepeatedValues([...hostLabelDetailsById.values()]) + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail) { + continue + } + let detail = pathOnlyDetail + if (repeatedPathOnlyDetails.has(pathOnlyDetail)) { + detail = hostLabelDetailsById.get(option.id) ?? pathOnlyDetail + if (repeatedHostLabelDetails.has(detail)) { + // Why: user-renamed hosts can share a label, so fall back to the + // stable host id only when the final visible detail still repeats. + detail = + getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-id' + ) ?? detail + } + } + detailsById.set(option.id, detail) + } + } + + return detailsById +} diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index d96cc14ba4a..67c25ab2598 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -108,6 +108,257 @@ describe('buildNewWorkspaceProjectOptions', () => { expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) }) + it('shows configured directories when project names are duplicated', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-a-setup', + projectId: 'project:merchant-a', + repoId: 'merchant-a-repo', + path: '/workspace/storefront/merchant' + }), + setup({ + id: 'merchant-b-setup', + projectId: 'project:merchant-b', + repoId: 'merchant-b-repo', + path: '/workspace/admin/merchant' + }) + ], + eligibleRepos: [repo('merchant-a-repo'), repo('merchant-b-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ + id: 'project:merchant-b', + detail: '/workspace/admin/merchant' + }), + expect.objectContaining({ + id: 'project:merchant-a', + detail: '/workspace/storefront/merchant' + }) + ]) + }) + + it('keeps provider details when duplicate project names are already distinguishable', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'github:contoso/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'contoso', repo: 'merchant' } + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'contoso-setup', + projectId: 'github:contoso/merchant', + repoId: 'contoso-repo', + path: '/workspace/contoso/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('contoso-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'acme/merchant', + 'contoso/merchant' + ]) + }) + + it('shows directory details for non-provider duplicates with different setup counts', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-single', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-multi', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-single-setup', + projectId: 'project:merchant-single', + repoId: 'merchant-single-repo', + path: '/workspace/single/merchant' + }), + setup({ + id: 'merchant-multi-local-setup', + projectId: 'project:merchant-multi', + repoId: 'merchant-multi-local-repo', + path: '/workspace/multi/local/merchant' + }), + setup({ + id: 'merchant-multi-remote-setup', + projectId: 'project:merchant-multi', + hostId: 'ssh:builder', + repoId: 'merchant-multi-remote-repo', + path: '/workspace/multi/remote/merchant' + }) + ], + eligibleRepos: [ + repo('merchant-single-repo'), + repo('merchant-multi-local-repo'), + repo('merchant-multi-remote-repo') + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/multi/local/merchant (+1 more)', + '/workspace/single/merchant' + ]) + }) + + it('keeps provider details while disambiguating non-provider duplicate names', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + repoId: 'merchant-local-repo', + path: '/workspace/local/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('merchant-local-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/local/merchant', + 'acme/merchant' + ]) + }) + + it('adds host labels when duplicate project directory details collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-remote', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + hostId: 'local', + repoId: 'merchant-local-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-remote-setup', + projectId: 'project:merchant-remote', + hostId: 'ssh:builder', + repoId: 'merchant-remote-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-local-repo'), repo('merchant-remote-repo')], + hosts: [ + { id: 'local', label: 'Local Mac' }, + { id: 'ssh:builder', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder · /workspace/merchant', + 'Local Mac · /workspace/merchant' + ]) + }) + + it('adds host ids when duplicate project host labels still collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-builder-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-builder-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-builder-a-setup', + projectId: 'project:merchant-builder-a', + hostId: 'ssh:builder-a', + repoId: 'merchant-builder-a-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-builder-b-setup', + projectId: 'project:merchant-builder-b', + hostId: 'ssh:builder-b', + repoId: 'merchant-builder-b-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-builder-a-repo'), repo('merchant-builder-b-repo')], + hosts: [ + { id: 'ssh:builder-a', label: 'Builder' }, + { id: 'ssh:builder-b', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder (ssh:builder-a) · /workspace/merchant', + 'Builder (ssh:builder-b) · /workspace/merchant' + ]) + }) + it('filters project options by display name and detail', () => { const options: NewWorkspaceProjectOption[] = [ { diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index cabbb4f4cf6..bfa47e2a63b 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -1,6 +1,11 @@ import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + getDuplicateProjectDetailsById, + type ProjectSetupDirectory +} from './new-workspace-duplicate-project-details' export const NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX = 'project-group:' export const NEW_WORKSPACE_FOLDER_SOURCE_OPTION_PREFIX = 'folder-source:' @@ -45,6 +50,7 @@ type BuildNewWorkspaceProjectOptionsInput = { projects: readonly Project[] projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] + hosts?: readonly Pick[] } type BuildNewWorkspaceCreateTargetOptionsInput = BuildNewWorkspaceProjectOptionsInput & { @@ -85,7 +91,9 @@ export function buildNewWorkspaceProjectOptions( const { eligibleRepos } = input const { projects, projectHostSetups } = getProjectModel(input) const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) const readySetupCountsByProjectId = new Map() + const setupDirectoriesByProjectId = new Map() for (const setup of projectHostSetups) { if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { @@ -95,9 +103,12 @@ export function buildNewWorkspaceProjectOptions( setup.projectId, (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 ) + const directories = setupDirectoriesByProjectId.get(setup.projectId) ?? [] + directories.push({ path: setup.path, hostId: setup.hostId }) + setupDirectoriesByProjectId.set(setup.projectId, directories) } - return projects + const options = projects .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) .map((project) => ({ kind: 'project' as const, @@ -105,8 +116,21 @@ export function buildNewWorkspaceProjectOptions( projectId: project.id, displayName: project.displayName, badgeColor: project.badgeColor, - detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0), + detailSource: project.providerIdentity ? ('provider' as const) : ('generic' as const) })) + + const duplicateProjectDetailsById = getDuplicateProjectDetailsById( + options, + setupDirectoriesByProjectId, + hostLabelById + ) + + return options + .map(({ detailSource: _detailSource, ...option }) => { + const directoryDetail = duplicateProjectDetailsById.get(option.id) + return directoryDetail ? { ...option, detail: directoryDetail } : option + }) .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) } From 85d555f3348532f1d95e1163cbeafc460c3dd8cf Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:04:24 +0200 Subject: [PATCH 118/141] fix(terminal): launch prior/default agent when a woken terminal can't resume (#6277) * fix(terminal): launch prior/default agent when a woken terminal can't resume Waking a sleeping worktree (or restoring terminals after an app restart) left an agent terminal as a BLANK shell while still showing the agent's icon: `buildColdRestoreAgentResumeStartup` returned null whenever the provider session couldn't be resumed, and the caller then spawned a bare shell. Now, when resume isn't possible, an agent terminal comes back as a FRESH session of the agent it previously ran (live status -> sleeping record -> persisted tab `launchAgent`); if that agent is unknown but the terminal was an agent terminal, the configured default agent is used. A genuine plain shell stays blank, and the existing resume path, SSH/remote, and WSL behavior are unchanged. Fixes #4557 Co-Authored-By: Claude Opus 4.8 (1M context) * test(terminal): assert default-agent cold restore is a fresh (non-resume) launch Addresses CodeRabbit review on #6277. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix cold restore agent fallback Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 281 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 165 ++++++++-- 2 files changed, 418 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 383d5d141f4..c8e90d3d6f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -136,7 +136,7 @@ type StoreState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }[] - sshConnectionStates: Map + sshConnectionStates: Map cacheTimerByKey: Record settings: { theme?: 'system' | 'dark' | 'light' @@ -5612,6 +5612,285 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + // #4557: an agent terminal that can't be resumed should come back as a fresh + // agent (prior agent, else default) instead of a blank shell. + const findReattachConnect = (transport: ReturnType) => { + const call = transport.connect.mock.calls.find( + (args) => (args[0] as { sessionId?: string })?.sessionId === 'lost-pty' + ) + return call?.[0] as + | { + command?: string + launchAgent?: string + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + env?: Record + } + | undefined + } + + const mountColdRestore = async ( + transport: ReturnType, + depsOverrides: Record = {} + ) => { + const { connectPanePty } = await import('./pty-connection') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: 'fresh-pty', coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }, + ...depsOverrides + }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + return deps + } + + it('cold-restores a fresh agent from the sleeping record when its session is unresumable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + // Agent known, but no resumable provider session id. + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(new RegExp(`^${UUID_RE}$`)) + }) + + it('cold-restores a fresh agent with the sleeping record launch config when resume is unavailable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: { codex: 'current-codex' }, + agentDefaultArgs: { codex: '--current-args' } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBe('sleeping-codex --stored-args') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.launchConfig).toMatchObject({ + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + }) + expect(reattach?.env).toMatchObject({ + CODEX_HOME: '/tmp/orca-stored-codex', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + + it('uses the known SSH remote platform when fresh-launching an unresumable agent', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude-agent-teams' }] + }, + repos: [{ id: 'repo1', connectionId: 'ssh-win' }], + sshConnectionStates: new Map([['ssh-win', { status: 'connected', remotePlatform: 'win32' }]]), + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^orca\.cmd claude-teams/) + expect(reattach?.command).not.toContain('orca-ide') + expect(reattach?.launchAgent).toBe('claude-agent-teams') + }) + + it('cold-restores the prior agent from the persisted tab launchAgent after a restart', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + // After a restart there is no live status nor sleeping record — only the + // persisted tab.launchAgent tells us this terminal ran an agent. + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain('claude') + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('claude') + }) + + it('cold-restores the default agent when an agent terminal has no recoverable agent', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: {}, + // A record exists (it was an agent terminal) but carries no usable agent id. + sleepingAgentSessionsByPaneKey: { + [paneKey]: { paneKey, tabId: 'tab-1', worktreeId: 'wt-1', capturedAt: 1, updatedAt: 1 } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('does not cold-restore a fresh agent from only a completed live status row', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'done task', + state: 'done', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + + it('ignores malformed sleeping launch config when a live resumable status exists', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'live task', + providerSession: { key: 'session_id', id: 'live-session' }, + state: 'working', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'malformed sleeping task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'malformed-sleeping-codex', + agentArgs: '', + agentEnv: {} + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain("'resume' 'live-session'") + expect(reattach?.command).not.toBe('malformed-sleeping-codex') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('leaves a plain shell blank on cold restore (no prior agent, no default)', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dffa30c47d8..8d009829be7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -148,16 +148,17 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { isResumableTuiAgent, normalizeAgentProviderSession, - type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { @@ -370,7 +371,9 @@ type FreshSpawnOptions = { } type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - agent: ResumableTuiAgent + // TuiAgent (not just ResumableTuiAgent): the fresh-launch fallback can spawn any + // agent the terminal previously ran, including ones without resume support. + agent: TuiAgent launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -1120,32 +1123,34 @@ export function connectPanePty( ) } ) + const legacyProviderSessionKeys = legacyMatches.map(([, record]) => { + const providerSession = normalizeAgentProviderSession(record.providerSession) + return providerSession + ? [record.worktreeId, record.agent, providerSession.key, providerSession.id].join('\0') + : null + }) const exactLegacyMatch = legacyMatches.find(([paneKey]) => { const legacy = parseLegacyNumericPaneKey(paneKey) return legacy?.numericPaneId === String(pane.id) }) const providerSessionKeys = new Set( - legacyMatches.map(([, record]) => - [ - record.worktreeId, - record.agent, - record.providerSession.key, - record.providerSession.id - ].join('\0') - ) + legacyProviderSessionKeys.filter((key): key is string => key !== null) ) const oldestLegacyMatch = legacyMatches .slice() .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] // Why: duplicate legacy aliases can point at one provider session; consume // the oldest capture as canonical and clear its aliases after resume. + const allLegacyMatchesHaveProviderSession = legacyProviderSessionKeys.every( + (key) => key !== null + ) const selectedLegacyMatch = exactLegacyMatch ?? - (providerSessionKeys.size === 1 - ? legacyMatches.length === 1 - ? legacyMatches[0] - : oldestLegacyMatch - : null) + (legacyMatches.length === 1 + ? legacyMatches[0] + : allLegacyMatchesHaveProviderSession && providerSessionKeys.size === 1 + ? oldestLegacyMatch + : null) if (!selectedLegacyMatch) { return null } @@ -1157,13 +1162,19 @@ export function connectPanePty( consumed: { paneKey: string; record: SleepingAgentSessionRecord } ): void => { state.clearSleepingAgentSession(consumed.paneKey) + const consumedSession = normalizeAgentProviderSession(consumed.record.providerSession) + // No provider session (e.g. a fresh-launch fallback record) means there is no + // shared session to alias, so there is nothing else to clear. + if (!consumedSession) { + return + } for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { if ( paneKey !== consumed.paneKey && record.worktreeId === consumed.record.worktreeId && record.agent === consumed.record.agent && - record.providerSession.key === consumed.record.providerSession.key && - record.providerSession.id === consumed.record.providerSession.id + record.providerSession?.key === consumedSession.key && + record.providerSession?.id === consumedSession.id ) { // Why: legacy pane aliases can leave multiple sleeping rows for one // provider session; once this pane resumes it, every alias is stale. @@ -3387,6 +3398,10 @@ export function connectPanePty( if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { return 'linux' } + const sshRemotePlatform = getTerminalPasteSshRemotePlatform(connectionId) + if (sshRemotePlatform) { + return sshRemotePlatform + } if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } @@ -3400,27 +3415,123 @@ export function connectPanePty( const entry = state.agentStatusByPaneKey[cacheKey] const sleepingRecordEntry = getSleepingRecordForPane(state) const sleepingRecord = sleepingRecordEntry?.record - const useLiveEntry = entry && entry.state !== 'done' - const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + const liveEntry = entry && entry.state !== 'done' ? entry : null + const useLiveEntry = Boolean(liveEntry) + + // When the provider session can't be resumed, an agent terminal should still + // come back as its agent (or the default), not a blank shell (#4557). A plain + // shell - no agent ever ran here - stays blank (buildFreshFallback returns null). + const buildFreshFallback = (): ColdRestoreAgentResumeStartup | null => { + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const liveAgent = + liveEntry && isTuiAgent(liveEntry.agentType) ? liveEntry.agentType : undefined + const priorAgent = + liveAgent ?? sleepingRecord?.agent ?? tab?.launchAgent ?? paneStartup?.launchAgent + const defaultPref = state.settings?.defaultTuiAgent + const defaultAgent = + defaultPref && + defaultPref !== 'blank' && + isTuiAgentEnabled(defaultPref, state.settings?.disabledTuiAgents) + ? defaultPref + : undefined + // Fall back to the default agent only when this was an active/sleeping + // agent terminal whose specific agent is unrecoverable - never for a + // genuine plain shell or a stale completed live row. + const fallbackAgent = priorAgent ?? (liveEntry || sleepingRecord ? defaultAgent : undefined) + if (!fallbackAgent) { + return null + } + const liveLaunchConfig = + liveEntry && liveAgent === fallbackAgent + ? state.getAgentLaunchConfigForStatusEntry(liveEntry) + : undefined + const fallbackLaunchConfig = + liveLaunchConfig ?? + (sleepingRecord?.agent === fallbackAgent ? sleepingRecord.launchConfig : undefined) ?? + (paneStartup?.launchAgent === fallbackAgent ? paneStartup.launchConfig : undefined) + if (fallbackLaunchConfig?.agentCommand?.trim()) { + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: fallbackLaunchConfig.agentCommand.trim(), + env: { + ...fallbackLaunchConfig.agentEnv, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: { + agentCommand: fallbackLaunchConfig.agentCommand.trim(), + agentArgs: fallbackLaunchConfig.agentArgs, + agentEnv: { ...fallbackLaunchConfig.agentEnv } + }, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared + // after the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + const freshPlan = buildAgentStartupPlan({ + agent: fallbackAgent, + prompt: '', + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentArgs + : resolveTuiAgentLaunchArgs(fallbackAgent, state.settings?.agentDefaultArgs), + agentEnv: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentEnv + : resolveTuiAgentLaunchEnv(fallbackAgent, state.settings?.agentDefaultEnv), + platform: getColdRestoreAgentResumePlatform(), + allowEmptyPromptLaunch: true + }) + if (!freshPlan) { + return null + } + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: freshPlan.launchCommand, + env: { + ...freshPlan.env, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: freshPlan.launchConfig, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared after + // the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + + const agent = liveEntry ? liveEntry.agentType : sleepingRecord?.agent if (!agent || !isResumableTuiAgent(agent)) { - return null + return buildFreshFallback() } const providerSession = normalizeAgentProviderSession( - useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + liveEntry ? liveEntry.providerSession : sleepingRecord?.providerSession ) if (!providerSession) { - return null + return buildFreshFallback() } + const sleepingProviderSession = normalizeAgentProviderSession(sleepingRecord?.providerSession) const matchingSleepingLaunchConfig = sleepingRecord?.launchConfig && (!useLiveEntry || (sleepingRecord.agent === agent && - sleepingRecord.providerSession.key === providerSession.key && - sleepingRecord.providerSession.id === providerSession.id)) + sleepingProviderSession?.key === providerSession.key && + sleepingProviderSession?.id === providerSession.id)) ? sleepingRecord.launchConfig : undefined const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? + (liveEntry ? state.getAgentLaunchConfigForStatusEntry(liveEntry) : undefined) ?? matchingSleepingLaunchConfig const resumePlatform = getColdRestoreAgentResumePlatform() const startupPlan = buildAgentResumeStartupPlan({ @@ -3439,7 +3550,7 @@ export function connectPanePty( platform: resumePlatform }) if (!startupPlan) { - return null + return buildFreshFallback() } const coldRestoreLaunchToken = createBrowserUuid() // Why: cold restore means the PTY process is gone but the agent provider From 9886eb507ad8ab42f75a0f728a502b288825f5e5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:15:30 -0700 Subject: [PATCH 119/141] Improve Chinese and Spanish translations (#7408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refine Spanish and Chinese locale translations - Update Spanish strings for developer tools, notifications, and session history scopes. - Standardize Chinese terminology by converting "令牌" to "token" and correcting color translations (e.g., "橙子" to "橙色"). - Adjust Chinese UI phrasing, such as mapping issue states from "进行中" to "开放" and "资源管���器" to "文件管理器". * test: update MR Open state filter expectation to 开放 The zh locale intentionally remapped the GitLab MR 'Open' filter from 进行中 to 开放; align the test expectation with the translation. Co-authored-by: Orca * Improve Chinese and Spanish translations - Translate "Current workspace" to Spanish. - Correct the translation of "Atlassian" and expand "PR" to "拉取请求" in Chinese locale. - Fix spacing around English terms like "token" in Chinese text in Chinese. --------- Co-authored-by: Orca --- .../smart-workspace-localized-options.test.ts | 2 +- src/renderer/src/i18n/locales/es.json | 70 +++---- src/renderer/src/i18n/locales/zh.json | 178 +++++++++--------- 3 files changed, 125 insertions(+), 125 deletions(-) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts index a30a0344bbb..601b838f649 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts @@ -52,7 +52,7 @@ describe('smart-workspace-localized-options', () => { await i18n.changeLanguage('zh') expect(getMrStateFilters().map((filter) => filter.label)).toEqual([ - '进行中', + '开放', '合并', '已关闭', '全部' diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 51f3c236b5d..07a1201658d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -651,8 +651,8 @@ "dev": "Dev Tools", "devDescription": "Herramientas solo para dev para probar estados de UI.", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlayground": "Panel de prueba de notificaciones", + "devSearchNotificationPlaygroundDescription": "Activa estados representativos de notificaciones y toasts en la interfaz.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -8673,34 +8673,34 @@ "advanced": "Avanzado" }, "DevToolsPane": { - "nativeActionLayoutCheck": "Native action layout check", - "secondary": "Secondary", - "secondaryActionClicked": "Secondary action clicked", - "primaryAction": "Primary action", - "primaryActionClicked": "Primary action clicked", + "nativeActionLayoutCheck": "Comprobación de diseño de acción nativa", + "secondary": "Secundario", + "secondaryActionClicked": "Acción secundaria pulsada", + "primaryAction": "Acción principal", + "primaryActionClicked": "Acción principal pulsada", "branchHasChanges": "la rama tiene cambios", - "viewChangesClicked": "View Changes clicked", - "forceDeleteClicked": "Force Delete clicked", - "devOnlyCallback": "Dev-only callback.", - "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", - "localCanaryBehind": "Local canary is behind origin/canary", - "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", - "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", - "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", - "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", - "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", - "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", - "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", - "notificationPlayground": "Notification playground", - "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "viewChangesClicked": "Ver cambios pulsado", + "forceDeleteClicked": "Forzar eliminación pulsado", + "devOnlyCallback": "Callback solo para desarrolladores.", + "infoToast": "Toast informativo", + "infoToastDescription": "Texto informativo largo sin acción explícita.", + "localCanaryBehind": "La rama canary local está detrás de origin/canary", + "successToast": "Toast de éxito", + "successToastDescription": "Estado de confirmación breve.", + "settingsSaved": "Ajustes guardados", + "shortSuccessCopy": "Texto de éxito breve.", + "errorToast": "Toast de error", + "errorToastDescription": "Texto de error largo sin acciones de recuperación.", + "failedToSyncWorkspaceMetadata": "Error al sincronizar metadatos del espacio de trabajo", + "nativeActionToast": "Toast de acción nativa", + "nativeActionToastDescription": "Utiliza las ranuras de acción y cancelación de Sonner.", + "deleteFailureToast": "Toast de error al eliminar", + "deleteFailureToastDescription": "Pie personalizado con Ver y Forzar eliminación.", + "behindBaseRefToast": "Toast de retraso respecto a la rama base", + "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", + "notificationPlayground": "Área de prueba de notificaciones", + "notificationPlaygroundDescription": "Disparadores solo para desarrolladores para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", + "devOnly": "Solo desarrollo" }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -9622,7 +9622,7 @@ "refreshSessionHistory": "Actualizar historial de sesiones", "searchSessions": "Buscar sesiones", "clearSearch": "Limpiar búsqueda", - "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", + "remoteBrowseLocalHistory": "Los espacios de trabajo remotos pueden explorar el historial local. Las acciones de reanudación se ejecutan desde espacios de trabajo locales.", "transcriptsSkipped": "{{count}} transcripciones omitidas", "noAgentSessionsFound": "No se encontraron sesiones de agentes", "noSessionsMatchFilters": "Ninguna sesión coincide con los filtros actuales", @@ -9642,13 +9642,13 @@ "scanningSessions": "Buscando sesiones", "scopeAriaLabel": "Ámbito del historial de sesiones: {{value0}}", "currentWorkspaceLower": "workspace actual", - "currentWorktreeLower": "current worktree", + "currentWorktreeLower": "worktree actual", "allSessionsLower": "todas las sesiones", - "thisScope": "This", + "thisScope": "Este", "allScope": "Todo", - "scope": "Scope", - "currentWorkspace": "Current workspace", - "allSessions": "All sessions", + "scope": "Ámbito", + "currentWorkspace": "Espacio de trabajo actual", + "allSessions": "Todas las sesiones", "viewOptionsAriaLabel": "Opciones de vista del historial de sesiones", "viewOptions": "Opciones de vista", "agents": "Agentes", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5a539073c54..75667d4483e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20,10 +20,10 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "显示 Claude 令牌和成本使用情况。", - "codexToggleDescription": "显示 Codex 令牌和成本使用情况。", - "geminiToggleDescription": "显示 Gemini 令牌和成本使用情况。", - "opencodeGoToggleDescription": "显示 OpenCode Go 令牌和成本使用情况。", + "claudeToggleDescription": "显示当前工作区的 Claude token和成本使用情况。", + "codexToggleDescription": "显示当前工作区的 Codex token和成本使用情况。", + "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", + "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", @@ -46,7 +46,7 @@ "showStatusBar": "显示状态栏", "showTasksButton": "显示任务按钮", "showAutomationsButton": "显示自动化按钮", - "showMobileButton": "显示 Orca 手机端 按钮", + "showMobileButton": "显示 Orca 手机端按钮", "showTitlebarAppName": "显示标题栏应用名称", "view": "查看", "reload": "重新加载", @@ -141,7 +141,7 @@ "api": { "31bfe8ae1a": "在网络客户端中不可用。", "67ec964791": "Web 客户端不支持 Cookie 导入。", - "275a776357": "抓取元素在 Web 客户端中不可用。", + "275a776357": "悬停提取在 Web 客户端中不可用。", "8dfcb7a351": "选择屏幕截图在 Web 客户端中不可用。", "31bea294d5": "抓取模式在 Web 客户端中不可用。", "b8a1618172": "PR详细信息生成在 Web 客户端中不可用。", @@ -1395,13 +1395,13 @@ "TaskPage": { "513cddfa7a": "正在验证...", "ff69a30681": "取消", - "2abe22ef76": "您的令牌通过操作系统钥匙串加密并存储在本地。", + "2abe22ef76": "您的token通过操作系统钥匙串加密并存储在本地。", "246c2b3dd3": "Atlassian 账户设置", - "59c14d34a2": "创建一个令牌", - "b95623e93f": "Atlassian API 令牌", + "59c14d34a2": "创建一个token", + "b95623e93f": "Atlassian API token", "68df347677": "你@example.com", "163df31e0e": "https://example.atlassian.net", - "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API 令牌来浏览议题。", + "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API token来浏览议题。", "60f806ce99": "连接 Jira 站点", "8ff6fdc368": "创建中…", "fc0d8a1fa4": "提交。", @@ -1458,7 +1458,7 @@ "be8cf68d9f": "查看议题", "67662ade50": "项目议题", "6244a02f46": "在 Linear 中打开", - "606a85c774": "进行中", + "606a85c774": "开放", "5e8061b088": "从 {{value0}} {{value1}} 开始工作区", "592a55611b": "尝试选择更多的团队或者刷新;团队筛选条件适用于当前获取的议题集。", "618107fab3": "没有获取到与所选团队匹配的议题", @@ -1659,7 +1659,7 @@ "e104fa3d3d": "从议题开始工作区", "2193a99ec1": "打开议题所附的工作区", "7deb9e59a5": "更多 PR 操作", - "7753652524": "恢复", + "7753652524": "继续", "e4b29c5bcf": "从 PR 启动工作区", "67d881244c": "恢复关联 PR 的工作区", "6430594b18": "作者不详", @@ -1767,7 +1767,7 @@ "ac037cfac2": "手机", "75499e01d9": "关闭", "66b5a67bee": "Esc键", - "45def60329": "进行中", + "45def60329": "开放", "f65d992a11": "进入", "c5081f2814": "当前工作树", "52404f8096": "当前选项卡", @@ -1943,7 +1943,7 @@ "71fb69926c": "刷新", "a8fa0d2bf5": "刷新中", "fd15491034": "在 GitHub 中打开视图", - "22df63c393": "您的令牌无法获取子议题数据。", + "22df63c393": "您的token无法获取子议题数据。", "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", "1aa7c952b9": "项目视图", @@ -2040,7 +2040,7 @@ "e0002f1eba": "已选择", "2b2f019091": "任意状态", "0fd3249e2e": "已关闭", - "d78b60b5c2": "进行中", + "d78b60b5c2": "开放", "bd162b7d5a": "合并", "2e639b84fa": "评审人", "712c5abdbf": "标签", @@ -2502,7 +2502,7 @@ "88e34d00eb": "顶级会话分支在新工作区中打开", "fd3d12a1e1": "无法创建 fork 工作区。", "38e41edc6e": "该工作区无法分叉为 git 工作树。", - "f867385bb5": "找不到此分支的源工作区。", + "f867385bb5": "找不到此 fork 的源工作区。", "046e8d853c": "没有要 fork 的终端上下文", "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。", "f62b40e2c7": "没有可复制的终端上下文", @@ -2658,11 +2658,11 @@ "845576bed1": "青色", "be905e9b0a": "绿色的", "69682e2ce4": "黄色的", - "a47629b3cf": "橙子", + "a47629b3cf": "橙色", "620aec6729": "红色的", "03cf6dab1a": "粉色的", "c2d8b0991f": "紫色的", - "cb3eadefd2": "Blue", + "cb3eadefd2": "蓝色", "20baa43c05": "没有任何", "60f958ec75": "引脚标签", "417722e9c2": "取消固定标签", @@ -3109,7 +3109,7 @@ "61c58f8976": "缓存读取", "7d2efeff5e": "输出", "d7fb787e6b": "输入", - "a7902d3c1d": "令牌", + "a7902d3c1d": "token", "059945f71d": "按天汇总的输入、输出、缓存读取和缓存写入。", "c9f7cd30e9": "每日使用量" }, @@ -3129,15 +3129,15 @@ "7dc9e5613b": "按项目", "c3fdbc5474": "常用模型:", "0f394c24e3": "按模型", - "51ae85fa00": "缓存复用率 = 缓存读取令牌 /(输入令牌 + 缓存读取令牌)。", + "51ae85fa00": "缓存复用率 = 缓存读取token /(输入token + 缓存读取token)。", "b26d4ddb58": "预估 API 等效成本", "0f3e696ca9": "会话/轮次", "8cc23be4a3": "零缓存读取轮次", "1634c4f404": "缓存复用率", "b786fb4a70": "缓存写入", "268cf0af51": "缓存读取", - "2b8a2f14aa": "输出令牌", - "ea71fae8fc": "输入令牌", + "2b8a2f14aa": "输出token", + "ea71fae8fc": "输入token", "7dde9331fd": "此范围内尚无本地 Claude 使用记录。", "424cd50412": "启用 Claude 使用量分析", "8d18bbb771": "刷新", @@ -3147,7 +3147,7 @@ "dd29209b21": "筛选", "e9bf9fce0e": "Claude 使用量选项", "6afacbee37": "Claude 使用量跟踪", - "0cb1a36d7d": "读取本地 Claude 使用日志,显示令牌、模型和会话统计。", + "0cb1a36d7d": "读取本地 Claude 使用日志,显示token、模型和会话统计。", "5ce4842c2c": "所有本地 Claude 使用情况", "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", @@ -3163,7 +3163,7 @@ "c646e1783c": "缓存输入", "7b596a88b2": "输出", "99a91d3143": "输入", - "e4bdcf0071": "令牌", + "e4bdcf0071": "token", "c756cda6a8": "按天汇总的输入、缓存输入、输出和推理。", "609aa96e8b": "每日使用量" }, @@ -3183,13 +3183,13 @@ "b98718aaab": "按项目", "95d2d89285": "常用模型:", "5a0d1d69cd": "按模型", - "94ac1f1ee7": "显示推理令牌便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", + "94ac1f1ee7": "显示推理token便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", "1a18fbd56b": "预估 API 等效成本", "907b31865f": "会话/事件", "6e18146e9b": "推理输出", "a9ac0f423a": "缓存输入", - "5d8eba87bd": "输出令牌", - "e365eaa6fd": "输入令牌", + "5d8eba87bd": "输出token", + "e365eaa6fd": "输入token", "4c865393b4": "此范围内尚无本地 Codex 使用记录。", "f7c1affbd5": "启用 Codex 使用量分析", "3022cda443": "刷新", @@ -3199,12 +3199,12 @@ "1af1a39b2f": "筛选", "70b5b8581f": "Codex 使用量选项", "408210470c": "Codex 使用量跟踪", - "13badcd8f2": "读取本地 Codex 使用日志,显示令牌、模型和会话统计。", + "13badcd8f2": "读取本地 Codex 使用日志,显示token、模型和会话统计。", "4fe8820098": "所有本地 Codex 使用情况", "201766b754": "仅 Orca 工作树", "bf6cf2d4dd": "未知", "ae255c3dba": "不适用", - "247c93ca92": "• 推断定价", + "247c93ca92": "• 推理定价", "8a6655f7a2": "• 上次扫描错误:{{value0}}", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", @@ -3232,8 +3232,8 @@ "7e9433469a": "会话/活动", "5a65d68b77": "推理输出", "603504ee3b": "缓存输入", - "7aa4d8ce35": "输出令牌", - "d637a892ed": "输入令牌", + "7aa4d8ce35": "输出token", + "d637a892ed": "输入token", "bb6363e08c": "尚未找到此范围的本地 OpenCode 用法。", "f04131b3be": "启用 OpenCode 使用情况分析", "603cd138dc": "刷新", @@ -3243,7 +3243,7 @@ "01583b30aa": "筛选条件", "230d6de108": "OpenCode 使用选项", "bea80ceae0": "OpenCode 使用情况跟踪", - "b8b3522436": "读取本地 OpenCode 使用日志以显示令牌、模型和会话统计信息。", + "b8b3522436": "读取本地 OpenCode 使用日志以显示token、模型和会话统计信息。", "144a6050e9": "所有本地 OpenCode 使用情况", "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", @@ -3263,10 +3263,10 @@ }, "ShareUsageCard": { "4a4c6c79a3": "会话·", - "66c83284cf": "每日令牌", - "b760c0b622": "顶级模特", - "2d9eb39264": "令牌总数", - "beb6f24f37": "预计。成本", + "66c83284cf": "每日token", + "b760c0b622": "顶级模型", + "2d9eb39264": "token总数", + "beb6f24f37": "预计成本", "da62578d9d": "用法", "0eb31e79ee": "Orca IDE", "960324e9b8": "事件", @@ -3292,16 +3292,16 @@ "444585cb41": "有数据", "ecb0cd8a4c": "已启用 -", "33f7b043d2": "供应商", - "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入令牌日志后填充。", + "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入token日志后填充。", "70f36452d4": "缓存共享", "327603fe8b": "活跃天数", - "0eaf937335": "预计。成本", - "3887b94ce5": "令牌总数", + "0eaf937335": "预计成本", + "3887b94ce5": "token总数", "2d13e57f72": "启用OpenCode", "2f1ee2878b": "启用 Codex", "0ea0cae435": "启用 Claude", - "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的令牌账本。", - "49405ccc8d": "开始跟踪令牌", + "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的token账本。", + "49405ccc8d": "开始跟踪token", "ca6bc5fded": "刷新", "e06d1baf5c": "刷新使用概览", "c760c481c5": "使用概述", @@ -3349,23 +3349,23 @@ }, "sections": { "9564a3b21b": "会话 -", - "32330a6e66": "{{value0}}:{{value1}} 令牌", + "32330a6e66": "{{value0}}:{{value1}} token", "57d1448ef8": "启用", "f6df0d7d6d": "更多的", "1dd166c920": "较少的", - "52d9221dc0": "近期令牌活动热图", + "52d9221dc0": "近期token活动热图", "c424eb3f8e": "最好的:", - "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合令牌活动。", + "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合token活动。", "69e2b50427": "每日强度", - "3a795542fa": "综合令牌分布", + "3a795542fa": "综合token分布", "e65084cb4b": "推理", - "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存令牌。", - "4ff104da47": "令牌分布", + "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存token。", + "4ff104da47": "token分布", "0015facc1f": "缓存", "7f270458af": "输出", "9365b14a4e": "新输入", "3de9bf87fc": "还没有型号", - "6762f6a682": "令牌", + "6762f6a682": "token", "a7f937fb29": "{{value0}} 个会话 - {{value1}} {{value2}}", "c8f3a2d1e0b4": "轮次", "d9a4b3e2f1c5": "事件" @@ -3374,7 +3374,7 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• 推断价格" + "247c93ca92": "• 推理价格" }, "UsageSessionsTable": { "1afc25eb06": "轮次", @@ -3880,7 +3880,7 @@ "4afcca1d24": "重新连接", "11552bf786": "SSH 已断开", "cb5938ae79": "正在重新连接...", - "disconnected": "This remote repository is not connected.", + "disconnected": "此 SSH 主机未连接。", "reconnecting": "正在重新连接远程主机...", "reconnectionFailed": "重新连接到远程主机失败。", "authFailed": "远程主机身份验证失败。" @@ -3942,7 +3942,7 @@ "01f45d3d8a": "侧边栏", "93aebe4529": "文件夹", "0d224eff10": "主工作树", - "ca74db7550": "通过 SSH 远程项目", + "ca74db7550": "SSH 主机上的项目", "021538e1d1": "SSH 已断开连接", "runtimeHostDisconnected": "服务器已断开连接", "runtimeHostProject": "Orca 服务器上的项目", @@ -4197,7 +4197,7 @@ "669e12dd97": "本地文件夹和 Git 存储库", "ffc769ca29": "删除文件夹以添加项目", "740e8d0d46": "使用“添加项目”作为服务器路径", - "e344666fb8": "服务器运行时处于活动状态", + "e344666fb8": "服务器运行时处于激活状态", "d0f8943f8b": "准备项目添加流程", "18d3cf40e9": "检查文件夹" } @@ -4564,7 +4564,7 @@ "8951c5309f": "auth=Fe26.2**…", "338820326a": ")或完整 cookie 请求头(例如", "922b51e02d": "Fe26.2**…", - "0023cc336e": "粘贴原始令牌值(例如", + "0023cc336e": "粘贴原始token值(例如", "a7e38affcd": "Fe26.2**… token 或 auth=Fe26.2**… header", "67e3c33670": "OpenCode Go 会话 cookie", "b2b1aa936d": "粘贴您的 opencode.ai 会话 cookie 以进行速率限制获取。", @@ -4752,8 +4752,8 @@ "0cd9b8228f": "选择 Dock 和窗口切换器中显示的应用程序图标。", "ca1590d42f": "应用程序图标", "61d842eca0": "在侧边栏中显示 Orca 手机端 快捷方式。它仍然可以从工具箱中获取。", - "9da1020447": "显示 Orca 手机端 按钮", - "5db6ba961f": "在左侧边栏顶部显示 Orca 手机端 按钮。", + "9da1020447": "显示 Orca 手机端按钮", + "5db6ba961f": "在左侧边栏顶部显示 Orca 手机端按钮。", "fa882a3e6b": "在左侧边栏顶部显示“自动化”按钮。", "511f270ebb": "显示自动化按钮", "661942ab7f": "在左侧边栏顶部显示“任务”按钮。", @@ -5056,7 +5056,7 @@ "ComputerUsePane": { "1735461723": "使智能体能够检查和操作本地桌面应用程序。", "93255aaf18": "计算机控制技能", - "45f8e22c2e": "进行中", + "45f8e22c2e": "开放", "d95d1cfab8": "刷新", "0c29da5805": "就绪", "3383ea1aab": "无法重置计算机使用权限", @@ -5362,14 +5362,14 @@ "264a9b6128": "Linear", "4831ba1083": "重新检查", "01f6c7582e": "了解更多", - "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "5a1f86225a": "仅当 Orca 无法从远程获取 API URL 时。", "6193444689": "ORCA_GITEA_API_BASE_URL", "2c0330ec3e": "对于私有存储库,并设置", "e678d89e8c": "ORCA_GITEA_TOKEN", "d9467ab026": "公共仓库通过其 git 远程检测。设置", "4ab9b96925": "Gitea", - "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6f317f5132": "仅当 Orca 无法从 git 远程派生 API 基本 URL 时。", "ae6b7f5f40": "ORCA_AZURE_DEVOPS_API_BASE_URL", "67a9f26a80": "。设置", @@ -5378,7 +5378,7 @@ "5ee6ef6405": "ORCA_AZURE_DEVOPS_TOKEN", "4ee74d1470": "设置", "5efce6953d": "Azure DevOps", - "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查令牌和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查 token 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6e0ff3403e": "ORCA_BITBUCKET_ACCESS_TOKEN", "44cde4aa01": "ORCA_BITBUCKET_API_TOKEN", "a6c2816115": "和", @@ -5415,11 +5415,11 @@ "6355fe585e": "已检测的存储库的PR和 commit 状态", "1fac9b4910": "{{value0}} · PR和 commit 状态", "f92fbf11aa": "未配置", - "6791d7af95": "通过 Azure DevOps REST API 令牌PR和构建状态。", + "6791d7af95": "通过 Azure DevOps REST API token 获取拉取请求和构建状态。", "e3d5a24979": "已检测的 Azure Repos 的PR和构建状态", "277fc23929": "{{value0}} · PR和构建状态", "295154e54e": "已连接", - "0879860c58": "通过 Bitbucket Cloud API 令牌PR和构建状态。", + "0879860c58": "通过 Bitbucket Cloud API token 获取拉取请求和构建状态。", "9707523939": "PR和构建状态", "a565377c38": "未安装", "15cf990798": "未经过验证", @@ -5481,7 +5481,7 @@ }, "McpConfigFileRow": { "b145eb6009": "环境:", - "e720c139cd": "进行中", + "e720c139cd": "开放", "845ae248e8": "有效的" }, "McpConfigSection": { @@ -5568,7 +5568,7 @@ "254a6d09e4": "配对", "d7ce676270": "配对设备", "e778ecb209": "或者将此代码粘贴到手机应用程序中:", - "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备令牌。", + "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备token。", "870e1b5ca5": "撤销设备失败", "2e3dd0bc29": "设备已撤销", "711231348f": "复制配对码失败", @@ -5590,8 +5590,8 @@ "c8491c17ef": "通过扫描二维码从手机控制 Orca。 Beta/早期预览 - 预计会出现错误和重大更改。从以下位置获取 iOS 应用程序", "e7a3ae8c4e": "移动端", "174f4a3c6d": "通过手机控制终端和智能体。", - "1de96ec8a6": "显示 Orca 手机端 按钮", - "682293cadf": "在左侧边栏顶部显示 Orca 手机端 按钮。", + "1de96ec8a6": "显示 Orca 手机端按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", "d4f2b65f30": "在边栏中显示 Orca 手机端 快捷入口。" }, "NotificationsPane": { @@ -6878,7 +6878,7 @@ "appearance": { "search": { "468448bba4": "水彩", - "f586abfa35": "Blue", + "f586abfa35": "蓝色", "651f35b2c6": "切换器", "e5bc35d59e": "窗户", "d18b54ca90": "码头", @@ -6893,8 +6893,8 @@ "5bff6a2ef0": "侧边栏", "5e5b8878bf": "手机", "74618577c7": "移动端", - "682293cadf": "在左侧边栏顶部显示 Orca 手机端 按钮。", - "1de96ec8a6": "显示 Orca 手机端 按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", + "1de96ec8a6": "显示 Orca 手机端按钮", "4c920ab2d1": "日程", "58f4e22fa2": "自动化", "b186f3cefb": "自动化", @@ -7545,23 +7545,23 @@ "16a486a49d": "连接 Linear 以浏览和链接议题。", "b027b4b318": "Linear 集成", "20540996ef": "凭据", - "2ec2bd328c": "API 令牌", - "7345b7c3e6": "阿特拉斯语", + "2ec2bd328c": "API token", + "7345b7c3e6": "Atlassian", "e1263dd748": "Jira", - "76f6af7c57": "连接 Jira Cloud 或更新 Jira API 令牌凭据。", + "76f6af7c57": "连接 Jira Cloud 或更新 Jira API token 凭据。", "617603509b": "Jira 集成", "8c568d761c": "PR", "33180e8c10": "自托管", "129fc59aa8": "Gitea", - "d0d019dc29": "通过 API 令牌环境变量进行 Gitea 身份验证。", + "d0d019dc29": "通过 API token环境变量进行 Gitea 身份验证。", "aab86d64e5": "Gitea 集成", "03a7b275be": "ADO", "ed63380247": "天蓝色 repos", "b38b5d27f1": "天蓝色 devops", - "7b1f3984bb": "通过令牌环境变量进行 Azure DevOps Repos 身份验证。", + "7b1f3984bb": "通过token环境变量进行 Azure DevOps Repos 身份验证。", "af6611fa6e": "Azure DevOps 集成", "50d20817f7": "Bitbucket", - "c97d58a0f3": "通过 API 令牌环境变量进行 Bitbucket Cloud 身份验证。", + "c97d58a0f3": "通过 API token环境变量进行 Bitbucket Cloud 身份验证。", "67a2a0e868": "Bitbucket 集成", "371ee914d2": "合并请求", "581844769a": "MR", @@ -7578,15 +7578,15 @@ "jira": { "integration": { "card": { - "8ff73fef62": "Jira 令牌由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API 令牌。", + "8ff73fef62": "Jira token由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API token。", "9046a20d4c": "断开 {{value0}}", "eaffa454e9": "更新", "cec06a0f79": "测试...", "ab350991b8": "已验证", "d914d7ab70": "正在验证...", "5936977fcd": "取消", - "1666f8d562": "创建 Atlassian API 令牌", - "1ab7f551f3": "Atlassian API 令牌", + "1666f8d562": "创建 Atlassian API token", + "1ab7f551f3": "Atlassian API token", "09d310e42d": "你@example.com", "27dae4ab60": "https://example.atlassian.net", "a28f417220": "连接 Jira", @@ -8872,7 +8872,7 @@ "f61af83316": "新建文件夹", "37c875d827": "新文件", "b3e288bf41": "无法下载“{{value0}}”。", - "1a3df04ae1": "进行中", + "1a3df04ae1": "开放", "bce4d4e44f": "下载“{{value0}}”", "e26010014a": "被 .gitignore 忽略", "a06551beee": "进入", @@ -8890,8 +8890,8 @@ "FileExplorerToolbar": { "d238264654": "显示 Git 忽略的文件", "78f133232c": "显示点文件", - "31b4c3195d": "更多资源管理器操作", - "d95e30fe28": "刷新资源管理器", + "31b4c3195d": "更多文件管理器操作", + "d95e30fe28": "刷新文件管理器", "6026b16950": "全部折叠", "693cbeadd0": "搜索", "c1f3f3ec70": "搜索文件内容" @@ -10080,7 +10080,7 @@ "26824f60dd": "全部", "6fad211c66": "已关闭", "2319d87718": "合并", - "622864b52a": "进行中", + "622864b52a": "开放", "fda67f0b61": "当前项目", "3e8bb1176a": "在设置中连接 Linear 以搜索议题。", "69ce292138": "Linear", @@ -10579,7 +10579,7 @@ "TasksAnimatedVisual": { "efba6f77eb": "阅读议题 #", "b68c92fbdc": "启动工作区", - "4331c4d0f8": "进行中", + "4331c4d0f8": "开放", "b13375617e": "工作树选择器截断名称", "72f9e516a3": "紧迫", "fe47c9c9e8": "工作区准备就绪", @@ -11514,7 +11514,7 @@ "109a791e7b": "保存", "bb0a55f856": "保存…", "0203bed775": "取消", - "6978871a3d": "进行中", + "6978871a3d": "开放", "cce596969e": "删除注释", "cad3384faa": "编辑备注", "508ee678a5": "在浏览器中打开" @@ -11835,7 +11835,7 @@ "AutomationDetail": { "007c8ad874": "提示词", "a1d52c2189": "使用范围", - "449fc83bf7": "令牌", + "449fc83bf7": "token", "401f40ae79": "预计。花费", "a7c312430d": "最后一次运行", "2df8970cd5": "智能体", @@ -11915,7 +11915,7 @@ "AutomationRunHistory": { "402651bfb6": "还没有运行。", "9974a2b429": "状态", - "13988187b3": "令牌", + "13988187b3": "token", "86a248187e": "花费", "149c0b49c7": "工作区", "8faaa00726": "跑步", @@ -12192,14 +12192,14 @@ "4a2ab52781": "正在验证…", "79e7aaed39": "取消", "fdd26d81cc": "Atlassian 账户设置", - "8090504a3e": "在此创建令牌:", - "7b3967c12f": "Atlassian API 令牌", - "3d81bf3ab3": "API 令牌", + "8090504a3e": "在此创建 token:", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", - "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API 令牌来浏览议题。", + "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点" } } From 8dbe6bf372da2fa98844e6192c8d3b3f9b1534bc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:25:10 -0700 Subject: [PATCH 120/141] Improve agent history indentation and layout (#7412) * Hide redundant current worktree info in workspace vault scope When the AI Vault is scoped to the current workspace, showing the "Current worktree" status line or badge is redundant since the sessions are already filtered to this workspace. - Add helper to hide the worktree line and status badge when the vault is in 'workspace' scope and status is 'current'. - Improve alignment and metadata layout in session rows using CSS grid. - Update tests to verify worktree line and badge visibility rules. * Indent AI vault session worktree line Add left padding to the session worktree line to improve its visual indentation inside the AI vault session row. --- src/main/ipc/repos-remote.test.ts | 27 ++-- .../components/right-sidebar/AiVaultPanel.tsx | 1 + .../right-sidebar/AiVaultSessionDetails.tsx | 15 ++- .../right-sidebar/AiVaultSessionRow.tsx | 116 +++-------------- .../AiVaultSessionVirtualList.tsx | 11 +- .../ai-vault-session-row-display.tsx | 115 ++++++++++++++++ .../ai-vault-session-worktree-affordances.ts | 123 ++++++++++++++++++ .../ai-vault-session-worktree.test.ts | 22 ++++ .../ai-vault-session-worktree.ts | 107 ++------------- 9 files changed, 321 insertions(+), 216 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 2a9a66df94d..bd2541c7ebd 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => { // independent of which Promise in the Promise.all resolves first. type ExecResponse = { stdout: string; stderr: string } type ExecRule = { - match: (argv: string[]) => boolean + matches: (argv: string[]) => boolean respond: () => Promise } const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { return (argv: string[]) => { for (const rule of rules) { - if (rule.match(argv)) { + if (rule.matches(argv)) { return rule.respond() } } @@ -2438,17 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) } ]) @@ -2474,17 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.reject(new Error('relay exec failed')) } ]) @@ -2512,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ // symbolic-ref rejects (no origin/HEAD on the remote) - { match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, + { matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, // probe 1: refs/remotes/origin/main — rejects { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.reject(new Error('missing')) }, // probe 2: refs/remotes/origin/master — succeeds { - match: isRevParseFor('refs/remotes/origin/master'), + matches: isRevParseFor('refs/remotes/origin/master'), respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' }) }, - { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) } + { + matches: isRemoteList, + respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) + } ]) ) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 3fd7ae88809..8442e73f54e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -355,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} + vaultScope={scope} buildResumeStartup={buildResumeStartup} getSessionResumeState={getSessionResumeState} getSessionResumeActions={getSessionResumeActions} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index d5a538b44fd..85f5b2a5759 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { sessionDetailConversationTurns } from './ai-vault-session-display' import { aiVaultWorktreeCompactPath, aiVaultWorktreeStatusLabel, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -17,6 +18,7 @@ export function SessionInlineDetails({ id, session, worktreeInfo, + vaultScope, resumeActions, onResumeInWorktree, onResumeInNewTab, @@ -25,6 +27,7 @@ export function SessionInlineDetails({ id: string session: AiVaultSession worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope resumeActions: { worktree: { worktreeId: string | null; disabled: boolean } newTab: { worktreeId: string | null; disabled: boolean } @@ -78,7 +81,7 @@ export function SessionInlineDetails({ )} - {worktreeDisplay ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( } label={translate( @@ -86,7 +89,7 @@ export function SessionInlineDetails({ 'Worktree' )} > - + ) : null}
@@ -202,9 +205,11 @@ function ConversationTurnCard({ } function WorktreeMetadataLines({ - worktreeInfo + worktreeInfo, + vaultScope }: { worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope }): React.JSX.Element { const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) const pathLine = @@ -214,7 +219,7 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 0826eed9509..b0d88016327 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,37 +1,36 @@ import { useCallback } from 'react' import type React from 'react' import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' -import { Badge } from '@/components/ui/badge' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import { useRepoById } from '@/store/selectors' -import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' +import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { - aiVaultWorktreeStatusLabel, - shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + conversationRoleLabel, + getSessionDetailsId, + SessionMetadata, + SessionWorktreeLine +} from './ai-vault-session-row-display' export function VaultSessionRow({ session, resumeStartup, worktreeInfo, + vaultScope, detailsExpanded, resumeDisabled, onToggleDetails, @@ -53,6 +52,7 @@ export function VaultSessionRow({ session: AiVaultSession resumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void @@ -154,9 +154,9 @@ export function VaultSessionRow({ onOpenCwd={onOpenCwd} />
- {detailsExpanded && worktreeInfo ? ( + {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
- +
) : null} {!detailsExpanded ? ( @@ -180,6 +180,7 @@ export function VaultSessionRow({ session={session} updatedAt={updatedAt} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} /> ) : null} @@ -188,6 +189,7 @@ export function VaultSessionRow({ id={detailsId} session={session} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} @@ -216,91 +218,3 @@ export function VaultSessionRow({ ) } - -function getSessionDetailsId(sessionId: string): string { - return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` -} - -function SessionMetadata({ - session, - updatedAt, - worktreeInfo -}: { - session: AiVaultSession - updatedAt: string - worktreeInfo: AiVaultSessionWorktreeInfo | null -}) { - return ( -
-
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - -
- {worktreeInfo ? : null} -
- ) -} - -function SessionWorktreeLine({ - worktreeInfo -}: { - worktreeInfo: AiVaultSessionWorktreeInfo -}): React.JSX.Element { - const repoId = worktreeInfo.worktreeId - ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) - : null - const repo = useRepoById(repoId) - - return ( -
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( - - {worktreeStatusLabel(worktreeInfo.status)} - - ) : null} - - - -
- ) -} - -function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { - return aiVaultWorktreeStatusLabel(status) -} - -function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { - if (role === 'user') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') - } - if (role === 'assistant') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') - } - if (role === 'tool') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') - } - if (role === 'system') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') - } - return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 3d2bdde0384..a8de4d84313 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,6 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -41,6 +41,7 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -63,6 +64,7 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -185,6 +187,7 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} + vaultScope={vaultScope} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getWorktreeInfo={getWorktreeInfo} @@ -217,6 +220,7 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -241,6 +245,7 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -266,8 +271,7 @@ function AiVaultVirtualRow({ const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op the "Current worktree" badge - // already conveys. + // worktree on screen — jumping there is a no-op. const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) const worktreeJumpId = showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) @@ -300,6 +304,7 @@ function AiVaultVirtualRow({ session={row.session} resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} detailsExpanded={expandedSessionIds.has(row.session.id)} resumeDisabled={resumeState?.blocked ?? true} resumeLabel={resumeLabel} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx new file mode 100644 index 00000000000..1d3972a6fac --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -0,0 +1,115 @@ +import type React from 'react' +import { Badge } from '@/components/ui/badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { AgentIcon } from '@/lib/agent-catalog' +import { useRepoById } from '@/store/selectors' +import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { SessionTime } from './AiVaultSessionDetails' +import { agentLabel } from './ai-vault-session-filters' +import { + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +export function getSessionDetailsId(sessionId: string): string { + return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` +} + +export function SessionMetadata({ + session, + updatedAt, + worktreeInfo, + vaultScope +}: { + session: AiVaultSession + updatedAt: string + worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope +}) { + return ( +
+ + + +
+ {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( +
+ +
+ ) : null} +
+ ) +} + +export function SessionWorktreeLine({ + worktreeInfo, + vaultScope +}: { + worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { + return aiVaultWorktreeStatusLabel(status) +} + +export function conversationRoleLabel( + role: AiVaultSession['previewMessages'][number]['role'] +): string { + if (role === 'user') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') + } + if (role === 'assistant') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') + } + if (role === 'tool') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') + } + if (role === 'system') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') + } + return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts new file mode 100644 index 00000000000..6551d961bd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts @@ -0,0 +1,123 @@ +import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultScope } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import type { + AiVaultSessionWorktreeInfo, + AiVaultSessionWorktreeStatus +} from './ai-vault-session-worktree' + +export function canJumpToAiVaultSessionWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return Boolean( + worktreeInfo?.worktreeId && + worktreeInfo.status !== 'archived' && + worktreeInfo.status !== 'unavailable' + ) +} + +// Why: a session in the worktree you're already viewing has nowhere to jump, +// so we hide the affordance rather than offering a self-jump (the "Current +// worktree" badge already signals where it lives). +export function isAiVaultSessionInCurrentWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return worktreeInfo?.status === 'current' +} + +export function aiVaultWorktreeJumpTooltip( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): string { + if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', + 'Jump to Worktree' + ) + } + if (!worktreeInfo) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', + 'No worktree was recorded for this session.' + ) + } + if (worktreeInfo.status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', + 'This session is in an archived worktree.' + ) + } + if (worktreeInfo.status === 'unavailable') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', + 'No active worktree matches this session.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', + 'No active worktree is available.' + ) +} + +export function aiVaultWorktreeCompactPath(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function shouldShowAiVaultSessionWorktreeLine( + worktreeInfo: AiVaultSessionWorktreeInfo | null, + options?: { vaultScope?: AiVaultScope } +): worktreeInfo is AiVaultSessionWorktreeInfo { + if (!worktreeInfo) { + return false + } + // Why: workspace scope already limits history to the active workspace; the + // worktree row adds no value when the session lives in the worktree on screen. + if (options?.vaultScope === 'workspace' && worktreeInfo.status === 'current') { + return false + } + return true +} + +export function shouldShowAiVaultWorktreeStatusBadge( + status: AiVaultSessionWorktreeStatus, + options?: { vaultScope?: AiVaultScope } +): boolean { + // Why: "active" repeats the branch label without adding scan value in dense rows. + if (status === 'active') { + return false + } + // Why: workspace scope already filters to the active workspace, so "Current + // worktree" is redundant in the default history view. + if (status === 'current' && options?.vaultScope === 'workspace') { + return false + } + return true +} + +export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { + if (status === 'current') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', + 'Current worktree' + ) + } + if (status === 'active') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', + 'Active worktree' + ) + } + if (status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', + 'Archived worktree' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', + 'Unavailable worktree' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 58687977a33..56ba2f246f7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -10,6 +10,7 @@ import { resolveAiVaultSessionWorktreeDisplay, resolveAiVaultSessionWorktreeInfo, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -269,6 +270,21 @@ describe('aiVaultWorktreeCompactPath', () => { }) }) +describe('shouldShowAiVaultSessionWorktreeLine', () => { + it('hides the worktree row for the current worktree in workspace scope', () => { + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'workspace' }) + ).toBe(false) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'all' }) + ).toBe(true) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('active'), { vaultScope: 'workspace' }) + ).toBe(true) + expect(shouldShowAiVaultSessionWorktreeLine(null, { vaultScope: 'workspace' })).toBe(false) + }) +}) + describe('shouldShowAiVaultWorktreeStatusBadge', () => { it('hides the generic active badge but keeps meaningful states', () => { expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false) @@ -276,6 +292,12 @@ describe('shouldShowAiVaultWorktreeStatusBadge', () => { expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true) expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true) }) + + it('hides the current badge in workspace scope', () => { + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'workspace' })).toBe(false) + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'all' })).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('archived', { vaultScope: 'workspace' })).toBe(true) + }) }) describe('aiVaultWorktreeJumpTooltip', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index 31222bec1b5..670e2deecc1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -10,12 +10,21 @@ import { import { isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo, Worktree } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import { aiVaultWorktreeCompactPath } from './ai-vault-session-worktree-affordances' + +export { + aiVaultWorktreeCompactPath, + aiVaultWorktreeJumpTooltip, + aiVaultWorktreeStatusLabel, + canJumpToAiVaultSessionWorktree, + isAiVaultSessionInCurrentWorktree, + shouldShowAiVaultSessionWorktreeLine, + shouldShowAiVaultWorktreeStatusBadge +} from './ai-vault-session-worktree-affordances' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -155,58 +164,6 @@ export function useAiVaultSessionWorktreeMap({ ) } -export function canJumpToAiVaultSessionWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return Boolean( - worktreeInfo?.worktreeId && - worktreeInfo.status !== 'archived' && - worktreeInfo.status !== 'unavailable' - ) -} - -// Why: a session in the worktree you're already viewing has nowhere to jump, -// so we hide the affordance rather than offering a self-jump (the "Current -// worktree" badge already signals where it lives). -export function isAiVaultSessionInCurrentWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return worktreeInfo?.status === 'current' -} - -export function aiVaultWorktreeJumpTooltip( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): string { - if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', - 'Jump to Worktree' - ) - } - if (!worktreeInfo) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', - 'No worktree was recorded for this session.' - ) - } - if (worktreeInfo.status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', - 'This session is in an archived worktree.' - ) - } - if (worktreeInfo.status === 'unavailable') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', - 'No active worktree matches this session.' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', - 'No active worktree is available.' - ) -} - function buildWorktreeCandidates( worktrees: readonly Worktree[], repos: readonly Pick[] @@ -270,21 +227,6 @@ function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandi return left.source === 'current-path' ? -1 : 1 } -export function aiVaultWorktreeCompactPath(pathValue: string): string { - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - -export function shouldShowAiVaultWorktreeStatusBadge( - status: AiVaultSessionWorktreeStatus -): boolean { - // Why: "active" repeats the branch label without adding scan value in dense rows. - return status !== 'active' -} - function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo { return { status: 'unavailable', @@ -296,28 +238,3 @@ function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo function compactPathLabel(pathValue: string): string { return aiVaultWorktreeCompactPath(pathValue) } - -export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { - if (status === 'current') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', - 'Current worktree' - ) - } - if (status === 'active') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', - 'Active worktree' - ) - } - if (status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', - 'Archived worktree' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', - 'Unavailable worktree' - ) -} From a5cdb7711d5de7eb2d9a7f99e7087eaab3c4743b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:15:16 -0700 Subject: [PATCH 121/141] Add MiniMax rate-limit tracking and secure cookie storage (#7411) * Add MiniMax rate-limit tracking and secure cookie storage * Securely store MiniMax session cookies using an encrypted envelope format and local file hardening. * Fetch rate limits in an isolated session partition and clear the cookie jar before and after requests to prevent leakage. * Add a default-on "minimax" status bar item to display subscription usage. * Expose minimax configuration settings (group ID and models) in settings panes and sync them via the runtime client. * Isolate MiniMax config resolver and decryption failures from affecting other rate-limit providers. * Redact MiniMax secrets with whitespace around colons Update redactMiniMaxSecret to allow and match optional whitespace surrounding the colon when redacting quoted cookie values. This matches the spacing tolerance used during parsing. * Address PR review: harden cookie read, validate IPC, add tests Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/minimax-credentials.test.ts | 52 +++++++- src/main/ipc/minimax-credentials.ts | 33 ++++- src/main/minimax/minimax-cookie-store.test.ts | 100 +++++++++++--- src/main/minimax/minimax-cookie-store.ts | 125 ++++++++++++++++-- src/main/rate-limits/minimax-fetcher.test.ts | 77 +++++++++++ src/main/rate-limits/minimax-fetcher.ts | 17 ++- .../minimax-request-context.test.ts | 104 ++++++++++++++- .../rate-limits/minimax-request-context.ts | 97 +++++++++----- src/main/rate-limits/service.test.ts | 52 ++++++++ src/main/rate-limits/service.ts | 77 +++++++++-- src/main/runtime/orca-runtime.test.ts | 35 +++-- src/main/runtime/orca-runtime.ts | 12 +- .../runtime/rpc/methods/client-ui.test.ts | 16 ++- src/main/runtime/rpc/methods/client-ui.ts | 17 ++- .../src/components/settings/AccountsPane.tsx | 39 +++--- .../settings/AppearancePane.test.tsx | 29 +++- .../AppearanceWindowSidebarSection.tsx | 9 +- .../appearance-status-bar-search.test.ts | 33 +++++ .../settings/appearance-status-bar-search.ts | 35 +++++ .../src/components/status-bar/StatusBar.tsx | 2 + src/renderer/src/i18n/locales/en.json | 16 ++- src/renderer/src/i18n/locales/es.json | 20 ++- src/renderer/src/i18n/locales/ja.json | 44 +++--- src/renderer/src/i18n/locales/ko.json | 44 +++--- src/renderer/src/i18n/locales/zh.json | 54 ++++---- src/renderer/src/store/slices/ui.test.ts | 16 ++- src/renderer/src/store/slices/ui.ts | 16 ++- src/renderer/src/web/web-preload-api.test.ts | 112 ++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 23 ++++ src/shared/types.ts | 2 + 30 files changed, 1093 insertions(+), 215 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-status-bar-search.test.ts diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts index b34d85ad176..77e6f592de0 100644 --- a/src/main/ipc/minimax-credentials.test.ts +++ b/src/main/ipc/minimax-credentials.test.ts @@ -15,6 +15,7 @@ vi.mock('electron', () => ({ const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false)) +const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve())) vi.mock('../minimax/minimax-cookie-store', () => ({ saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock, @@ -22,16 +23,26 @@ vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock })) +vi.mock('../rate-limits/minimax-request-context', () => ({ + clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock +})) + import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' import type { RateLimitService } from '../rate-limits/service' import type { RateLimitState } from '../../shared/rate-limit-types' function makeRefreshMock(): { refresh: ReturnType - service: Pick + invalidateMiniMaxCredentialState: ReturnType + service: Pick } { const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) - return { refresh, service: { refresh } } + const invalidateMiniMaxCredentialState = vi.fn() + return { + refresh, + invalidateMiniMaxCredentialState, + service: { refresh, invalidateMiniMaxCredentialState } + } } async function invoke(channel: string, ...args: unknown[]): Promise { @@ -47,6 +58,8 @@ describe('registerMiniMaxCredentialsHandlers', () => { ipcState.handleHandlers.clear() saveMiniMaxSessionCookieMock.mockReset() clearMiniMaxSessionCookieMock.mockReset() + clearMiniMaxSessionCookieJarMock.mockReset() + clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined) hasMiniMaxSessionCookieMock.mockReset() hasMiniMaxSessionCookieMock.mockReturnValue(false) }) @@ -81,12 +94,13 @@ describe('registerMiniMaxCredentialsHandlers', () => { }) it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => { - const { refresh, service } = makeRefreshMock() + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() registerMiniMaxCredentialsHandlers(service as RateLimitService) await invoke('minimaxCredentials:saveCookie', '_token=abc') // Why: the save handler is fire-and-forget — wait a microtask cycle so // the queued `void rateLimits?.refresh()` resolves before we assert. await new Promise((resolve) => setImmediate(resolve)) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) expect(refresh).toHaveBeenCalledTimes(1) }) @@ -96,24 +110,50 @@ describe('registerMiniMaxCredentialsHandlers', () => { }) it('clears the cookie and triggers a refresh on clearCookie', async () => { - const { refresh, service } = makeRefreshMock() + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) registerMiniMaxCredentialsHandlers(service as RateLimitService) const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) + expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1) expect(status).toEqual({ configured: false }) await new Promise((resolve) => setImmediate(resolve)) expect(refresh).toHaveBeenCalledTimes(1) }) + it('still refreshes and reports cleared when session jar cleanup rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() + clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom')) + hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) + registerMiniMaxCredentialsHandlers(service as RateLimitService) + + const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') + + expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) + expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1) + expect(status).toEqual({ configured: false }) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to clear session cookie jar after credential clear'), + expect.any(Error) + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + it('logs but does not throw when the post-save rate-limit refresh rejects', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const refresh = vi.fn(() => Promise.reject(new Error('refresh boom'))) + const invalidateMiniMaxCredentialState = vi.fn() registerMiniMaxCredentialsHandlers({ - refresh - } as Pick as RateLimitService) + refresh, + invalidateMiniMaxCredentialState + } as Pick as RateLimitService) await invoke('minimaxCredentials:saveCookie', '_token=abc') await new Promise((resolve) => setImmediate(resolve)) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('failed to trigger rate-limit refresh after save'), expect.any(Error) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts index 9f773762151..97eefcd7116 100644 --- a/src/main/ipc/minimax-credentials.ts +++ b/src/main/ipc/minimax-credentials.ts @@ -4,6 +4,7 @@ import { hasMiniMaxSessionCookie, saveMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context' import type { RateLimitService } from '../rate-limits/service' export type MiniMaxCredentialsStatus = { @@ -14,20 +15,38 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { return { configured: hasMiniMaxSessionCookie() } } +// Why: fire-and-forget — callers get the persisted cookie status immediately; +// the rate-limit refresh runs in the background and only logs on failure. +function refreshAfterMiniMaxCredentialChange( + rateLimits: RateLimitService | null, + action: 'save' | 'clear' +): void { + rateLimits?.invalidateMiniMaxCredentialState() + void rateLimits?.refresh().catch((error: unknown) => { + console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error) + }) +} + export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus()) ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => { + // Validate the IPC argument in the main process; the renderer-declared type + // is compile-time only and the value arrives as unknown over IPC. + if (typeof cookie !== 'string') { + throw new Error('MiniMax session cookie must be a string') + } saveMiniMaxSessionCookie(cookie) - void rateLimits?.refresh().catch((error: unknown) => { - console.error('[minimax] failed to trigger rate-limit refresh after save:', error) - }) + refreshAfterMiniMaxCredentialChange(rateLimits, 'save') return getMiniMaxCredentialsStatus() }) - ipcMain.handle('minimaxCredentials:clearCookie', () => { + ipcMain.handle('minimaxCredentials:clearCookie', async () => { clearMiniMaxSessionCookie() - void rateLimits?.refresh().catch((error: unknown) => { - console.error('[minimax] failed to trigger rate-limit refresh after clear:', error) - }) + try { + await clearMiniMaxSessionCookieJar() + } catch (error) { + console.error('[minimax] failed to clear session cookie jar after credential clear:', error) + } + refreshAfterMiniMaxCredentialChange(rateLimits, 'clear') return getMiniMaxCredentialsStatus() }) } diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts index 436add5a854..4362dd2df93 100644 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -14,18 +14,16 @@ const electronMock = vi.hoisted(() => ({ vi.mock('electron', () => electronMock) const existsSyncMock = vi.fn() -const mkdirSyncMock = vi.fn() const readFileSyncMock = vi.fn() const rmSyncMock = vi.fn() -const writeFileSyncMock = vi.fn() +const hardenExistingSecureFileMock = vi.fn() +const writeSecureFileMock = vi.fn() const homedirMock = vi.fn(() => '/home/test') vi.mock('node:fs', () => ({ existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, readFileSync: readFileSyncMock, - rmSync: rmSyncMock, - writeFileSync: writeFileSyncMock + rmSync: rmSyncMock })) vi.mock('node:os', () => ({ @@ -36,7 +34,14 @@ vi.mock('node:path', () => ({ join: (...parts: string[]) => parts.join('/') })) +vi.mock('../../shared/secure-file', () => ({ + hardenExistingSecureFile: hardenExistingSecureFileMock, + writeSecureFile: writeSecureFileMock +})) + const storePath = '/home/test/.orca/minimax-session-cookie.enc' +const envelope = (kind: 'encrypted' | 'plaintext', value: string): string => + `orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}` async function loadStore(): Promise { return await import('./minimax-cookie-store') @@ -45,10 +50,10 @@ async function loadStore(): Promise { describe('minimax-cookie-store', () => { beforeEach(() => { existsSyncMock.mockReset() - mkdirSyncMock.mockReset() readFileSyncMock.mockReset() rmSyncMock.mockReset() - writeFileSyncMock.mockReset() + hardenExistingSecureFileMock.mockReset() + writeSecureFileMock.mockReset() safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.encryptString.mockReset() safeStorageMock.decryptString.mockReset() @@ -65,6 +70,29 @@ describe('minimax-cookie-store', () => { existsSyncMock.mockReturnValue(false) const store = await loadStore() expect(store.hasMiniMaxSessionCookie()).toBe(false) + expect(hardenExistingSecureFileMock).not.toHaveBeenCalled() + }) + + it('hardens the cookie file when checking status for an existing cookie', async () => { + existsSyncMock.mockReturnValue(true) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(true) + expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath) + }) + + it('still reports an existing cookie when status-path hardening fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + existsSyncMock.mockReturnValue(true) + hardenExistingSecureFileMock.mockImplementation(() => { + throw new Error('permission denied') + }) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(true) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to harden MiniMax cookie file'), + expect.any(Error) + ) + warn.mockRestore() }) it('writes the cookie using safeStorage when encryption is available', async () => { @@ -72,12 +100,10 @@ describe('minimax-cookie-store', () => { const store = await loadStore() store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42') expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') - expect(writeFileSyncMock).toHaveBeenCalledWith( + expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - Buffer.from('_token=abc; minimax_group_id_v2=42'), - { mode: 0o600 } + envelope('encrypted', '_token=abc; minimax_group_id_v2=42') ) - expect(mkdirSyncMock).toHaveBeenCalledWith('/home/test/.orca', { recursive: true }) }) it('warns and writes plaintext when safeStorage is unavailable', async () => { @@ -86,10 +112,7 @@ describe('minimax-cookie-store', () => { existsSyncMock.mockReturnValue(false) const store = await loadStore() store.saveMiniMaxSessionCookie('_token=abc') - expect(writeFileSyncMock).toHaveBeenCalledWith(storePath, '_token=abc', { - encoding: 'utf8', - mode: 0o600 - }) + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) warn.mockRestore() }) @@ -101,14 +124,17 @@ describe('minimax-cookie-store', () => { it('reads decrypted cookie from disk and caches it', async () => { existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9') const store = await loadStore() const first = store.readMiniMaxSessionCookie() const second = store.readMiniMaxSessionCookie() expect(first).toBe('_token=cached; minimax_group_id_v2=9') expect(second).toBe(first) + expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1) + expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath) expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1) + expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload')) }) it('returns null when no file exists', async () => { @@ -117,17 +143,51 @@ describe('minimax-cookie-store', () => { expect(store.readMiniMaxSessionCookie()).toBeNull() }) - it('returns plaintext when safeStorage is unavailable and reads succeed', async () => { + it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(Buffer.from('_token=plaintext')) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext'))) const store = await loadStore() expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext') }) - it('throws when decryption fails', async () => { + it('reads legacy plaintext cookies when decrypting is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy')) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy') + }) + + it('reads legacy plaintext cookies when decrypting fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy')) + safeStorageMock.decryptString.mockImplementation(() => { + throw new Error('boom') + }) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy') + }) + + it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('throws for encrypted envelopes when safeStorage is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('throws when decryption fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockImplementation(() => { throw new Error('boom') }) @@ -137,7 +197,7 @@ describe('minimax-cookie-store', () => { it('clears the cached cookie and removes the file', async () => { existsSyncMock.mockReturnValueOnce(true) - readFileSyncMock.mockReturnValueOnce(Buffer.from('encrypted-payload')) + readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear') const store = await loadStore() expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear') diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 31102e4bbbe..3864f892bfb 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -1,10 +1,18 @@ import { safeStorage } from 'electron' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' +const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:' let cachedMiniMaxCookie: string | null = null +let warnedMiniMaxCookieStatusHardenFailure = false + +type MiniMaxCookieEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer +} function getOrcaDir(): string { return join(homedir(), '.orca') @@ -14,15 +22,94 @@ function getMiniMaxCookiePath(): string { return join(getOrcaDir(), MINIMAX_COOKIE_FILE) } -function ensureOrcaDir(): void { - const dir = getOrcaDir() - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) +function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string { + return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` +} + +function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null { + const text = raw.toString('utf8') + if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) { + return null + } + const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length) + const separator = rest.indexOf(':') + if (separator < 0) { + throw new Error('MiniMax session cookie could not be decrypted') + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error('MiniMax session cookie could not be decrypted') + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') } } +// Why: migrates cookies saved before the envelope format existed. Older files +// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content +// to tell the two apart rather than removing this as seemingly dead code. +function looksLikeCookieHeader(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index) + if (code < 32 || code === 127) { + return false + } + } + return ( + /^Cookie:\s*\S+/i.test(trimmed) || + /(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) || + /(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed) + ) +} + +function readEnvelope(envelope: MiniMaxCookieEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('MiniMax session cookie could not be decrypted') + } + return safeStorage.decryptString(envelope.payload) +} + +function readLegacyCookie(raw: Buffer): string { + if (safeStorage.isEncryptionAvailable()) { + try { + return safeStorage.decryptString(raw) + } catch { + const plaintext = raw.toString('utf8') + if (looksLikeCookieHeader(plaintext)) { + return plaintext + } + throw new Error('MiniMax session cookie could not be decrypted') + } + } + const plaintext = raw.toString('utf8') + if (looksLikeCookieHeader(plaintext)) { + return plaintext + } + throw new Error('MiniMax session cookie could not be decrypted') +} + export function hasMiniMaxSessionCookie(): boolean { - return existsSync(getMiniMaxCookiePath()) + const keyPath = getMiniMaxCookiePath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedMiniMaxCookieStatusHardenFailure) { + warnedMiniMaxCookieStatusHardenFailure = true + console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error) + } + } + return true } export function saveMiniMaxSessionCookie(cookie: string): void { @@ -30,14 +117,19 @@ export function saveMiniMaxSessionCookie(cookie: string): void { if (!trimmed) { throw new Error('MiniMax session cookie is required') } - ensureOrcaDir() if (safeStorage.isEncryptionAvailable()) { - writeFileSync(getMiniMaxCookiePath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + writeSecureFile( + getMiniMaxCookiePath(), + encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed)) + ) cachedMiniMaxCookie = trimmed return } console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext') - writeFileSync(getMiniMaxCookiePath(), trimmed, { encoding: 'utf8', mode: 0o600 }) + writeSecureFile( + getMiniMaxCookiePath(), + encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + ) cachedMiniMaxCookie = trimmed } @@ -49,13 +141,20 @@ export function readMiniMaxSessionCookie(): string | null { if (!existsSync(keyPath)) { return null } + // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL + // failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie). + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error) + } try { const raw = readFileSync(keyPath) - cachedMiniMaxCookie = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) - : raw.toString('utf8') + const envelope = decodeCookieEnvelope(raw) + cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw) return cachedMiniMaxCookie - } catch { + } catch (error) { + console.error('[minimax] failed to decode/decrypt session cookie', error) throw new Error('MiniMax session cookie could not be decrypted') } } diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts index 89c095de959..18221fd750c 100644 --- a/src/main/rate-limits/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -134,6 +134,42 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.error).toMatch(/500/) }) + it('aborts a hung fetch after the timeout and classifies it as network', async () => { + netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const abort = (): void => { + const error = new Error('The operation was aborted') + error.name = 'AbortError' + reject(error) + } + // The manual-cookie-header fallback receives the already-aborted signal. + if (init.signal.aborted) { + abort() + return + } + init.signal.addEventListener('abort', abort) + }) + }) + const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + await vi.advanceTimersByTimeAsync(15_000) + const result = await promise + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('network') + }) + + it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => { + netFetchMock + .mockRejectedValueOnce(new Error('cookie jar transport boom')) + .mockResolvedValueOnce(makeResponse(makeOkPayload(70))) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(netFetchMock).toHaveBeenCalledTimes(2) + // First (session-cookie-jar) transport sends no Cookie header; the fallback does. + expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined() + expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=') + }) + it('returns ok with session window mapping remaining to usedPercent', async () => { netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35))) const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) @@ -287,6 +323,20 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.error).toContain('unauth') }) + it('classifies malformed MiniMax JSON responses as parse failures', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token <') + } + } as unknown as Response) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toContain('Unexpected token') + }) + it('returns error when model_remains is empty', async () => { netFetchMock.mockResolvedValueOnce( makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) @@ -346,6 +396,33 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.session?.usedPercent).toBe(60) }) + it('treats a blank model list as the default general model', async () => { + const payload = makeOkPayload(25) + ;(payload as { model_remains: unknown[] }).model_remains = [ + { + model_name: 'unrelated', + current_interval_remaining_percent: 10, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + }, + { + model_name: 'general', + current_interval_remaining_percent: 25, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: ' ' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(75) + }) + it('redacts _token in any error path that includes payload text', async () => { const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } } netFetchMock.mockResolvedValueOnce(makeResponse(payload)) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts index a04ba89db55..5f1c1445707 100644 --- a/src/main/rate-limits/minimax-fetcher.ts +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -81,13 +81,15 @@ function makeError( function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] { if (Array.isArray(models)) { - return models.map((model) => model.trim()).filter(Boolean) + const parsed = models.map((model) => model.trim()).filter(Boolean) + return parsed.length > 0 ? parsed : ['general'] } if (typeof models === 'string') { - return models + const parsed = models .split(',') .map((model) => model.trim()) .filter(Boolean) + return parsed.length > 0 ? parsed : ['general'] } return ['general'] } @@ -242,7 +244,13 @@ export async function fetchMiniMaxRateLimits( if (httpError) { return httpError } - const payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + let payload: MiniMaxUsageResponse + try { + payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response' + return makeError(redactMiniMaxSecret(message), 'parse') + } const payloadError = handleMiniMaxPayloadError(fetchResult, payload) if (payloadError) { return payloadError @@ -268,8 +276,7 @@ export async function fetchMiniMaxRateLimits( } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error' - const failureKind = message.toLowerCase().includes('json') ? 'parse' : 'network' - return makeError(redactMiniMaxSecret(message), failureKind) + return makeError(redactMiniMaxSecret(message), 'network') } finally { clearTimeout(timeout) } diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts index 15caa55214a..9b01b4ff013 100644 --- a/src/main/rate-limits/minimax-request-context.test.ts +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + clearMiniMaxSessionCookieJar, extractMiniMaxCookieValue, fetchMiniMaxWithManualCookieHeader, fetchMiniMaxWithSessionCookieJar, @@ -107,6 +108,12 @@ describe('redactMiniMaxSecret', () => { expect(redacted).not.toContain('x.y') }) + it('redacts quoted values with whitespace around the colon', () => { + const redacted = redactMiniMaxSecret('_token : "spaced.secret"') + expect(redacted).not.toContain('spaced.secret') + expect(redacted).toContain('_token:[REDACTED]') + }) + it('redacts minimax_group_id_v2', () => { expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') }) @@ -171,7 +178,7 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { vi.restoreAllMocks() }) - it('uses a dedicated MiniMax partition and clears cookies before fetching', async () => { + it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -185,6 +192,50 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { signal: controller.signal }) expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + }) + + it('attempts the final session cleanup when the pre-fetch clear rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + clearStorageDataMock + .mockRejectedValueOnce(new Error('pre-clear boom')) + .mockResolvedValueOnce(undefined) + const controller = new AbortController() + + await expect( + fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + ).rejects.toThrow('pre-clear boom') + + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(netFetchMock).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + }) + + it('clears the dedicated MiniMax partition on demand', async () => { + await clearMiniMaxSessionCookieJar() + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') expect(clearStorageDataMock).toHaveBeenCalledWith({ origin: 'https://platform.minimax.io', storages: ['cookies'] @@ -251,14 +302,22 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { describe('fetchMiniMaxWithManualCookieHeader', () => { beforeEach(() => { + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) }) afterEach(() => { vi.restoreAllMocks() }) - it('attaches the full Cookie header and X-Group-Id via net.fetch', async () => { + it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -272,6 +331,9 @@ describe('fetchMiniMaxWithManualCookieHeader', () => { signal: controller.signal }) expect(result.transport).toBe('manual-cookie-header') + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledTimes(1) const [url, init] = netFetchMock.mock.calls[0] expect(url).toBe(MINIMAX_USAGE_ENDPOINT) @@ -296,6 +358,44 @@ describe('fetchMiniMaxWithManualCookieHeader', () => { const [, init] = netFetchMock.mock.calls[0] expect(init.headers['X-Group-Id']).toBeUndefined() }) + + it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithManualCookieHeader({ + cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"', + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + const [, init] = netFetchMock.mock.calls[0] + expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw') + }) + + it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + clearStorageDataMock + .mockRejectedValueOnce(new Error('manual pre-clear boom')) + .mockResolvedValueOnce(undefined) + const controller = new AbortController() + + await expect( + fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + ).rejects.toThrow('manual pre-clear boom') + + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(netFetchMock).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + }) }) describe('logMiniMaxFetchFailure', () => { diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts index 3953ab53e6f..34dcd47130b 100644 --- a/src/main/rate-limits/minimax-request-context.ts +++ b/src/main/rate-limits/minimax-request-context.ts @@ -1,4 +1,4 @@ -import { net, session } from 'electron' +import { session, type Session } from 'electron' export const MINIMAX_USAGE_ENDPOINT = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -26,6 +26,8 @@ export type MiniMaxFetchResponse = { transport: MiniMaxFetchTransport } +// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real +// per-platform Firefox UA instead of a custom agent string. Don't "clean up". function getMiniMaxBrowserUserAgent(): string { if (process.platform === 'win32') { return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0' @@ -83,7 +85,8 @@ export function redactMiniMaxSecret(value: string): string { for (const name of SENSITIVE_COOKIE_NAMES) { redacted = redacted .replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`) - .replace(new RegExp(`${name}:["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) + // Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too. + .replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) } return redacted } @@ -101,6 +104,14 @@ export function makeMiniMaxRequestHeaders(groupId: string | null): Record { + await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) +} + +export async function clearMiniMaxSessionCookieJar(): Promise { + await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION)) +} + export async function fetchMiniMaxWithSessionCookieJar(args: { cookie: string endpoint: string @@ -109,28 +120,34 @@ export async function fetchMiniMaxWithSessionCookieJar(args: { }): Promise { const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) const cookiePairs = parseCookiePairs(args.cookie) - await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) - await Promise.all( - cookiePairs.map((pair) => - miniMaxSession.cookies.set({ - url: MINIMAX_ORIGIN, - name: pair.name, - value: pair.value, - secure: true, - path: '/' - }) + try { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession) + await Promise.all( + cookiePairs.map((pair) => + miniMaxSession.cookies.set({ + url: MINIMAX_ORIGIN, + name: pair.name, + value: pair.value, + secure: true, + path: '/' + }) + ) ) - ) - const headers = makeMiniMaxRequestHeaders(args.groupId) - return { - response: await miniMaxSession.fetch(args.endpoint, { - method: 'GET', - headers, - signal: args.signal - }), - requestHeaderNames: Object.keys(headers), - cookieNames: getUniqueMiniMaxCookieNames(args.cookie), - transport: 'session-cookie-jar' + const headers = makeMiniMaxRequestHeaders(args.groupId) + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'session-cookie-jar' + } + } finally { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => { + console.warn('[minimax] failed to clear session cookie jar after fetch', error) + }) } } @@ -140,19 +157,27 @@ export async function fetchMiniMaxWithManualCookieHeader(args: { groupId: string | null signal: AbortSignal }): Promise { - const headers = { - ...makeMiniMaxRequestHeaders(args.groupId), - Cookie: args.cookie - } - return { - response: await net.fetch(args.endpoint, { - method: 'GET', - headers, - signal: args.signal - }), - requestHeaderNames: Object.keys(headers), - cookieNames: getUniqueMiniMaxCookieNames(args.cookie), - transport: 'manual-cookie-header' + const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) + try { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession) + const headers = { + ...makeMiniMaxRequestHeaders(args.groupId), + Cookie: normalizeMiniMaxCookieHeader(args.cookie) + } + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'manual-cookie-header' + } + } finally { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => { + console.warn('[minimax] failed to clear session cookie jar after fetch', error) + }) } } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 796d7d516ef..082dcf282a9 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1124,6 +1124,42 @@ describe('RateLimitService', () => { expect(state.minimax?.session?.usedPercent).toBe(10) }) + it('does not apply an in-flight MiniMax result after credential invalidation', async () => { + const service = new RateLimitService() + const firstMiniMax = deferred() + const secondMiniMax = deferred() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models: 'general' + })) + vi.mocked(fetchMiniMaxRateLimits) + .mockImplementationOnce(() => firstMiniMax.promise) + .mockImplementationOnce(() => secondMiniMax.promise) + + const firstRefresh = service.refresh() + await Promise.resolve() + + service.invalidateMiniMaxCredentialState() + const queuedRefresh = service.refresh() + await Promise.resolve() + + firstMiniMax.resolve(okProvider('minimax', 50, Date.now())) + await Promise.resolve() + await Promise.resolve() + + expect(service.getState().minimax?.status).toBe('fetching') + expect(service.getState().minimax?.session).toBeNull() + + secondMiniMax.resolve(okProvider('minimax', 10, Date.now())) + await firstRefresh + await queuedRefresh + + const state = service.getState() + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2) + expect(state.minimax?.session?.usedPercent).toBe(10) + }) + it('isolates MiniMax failures from other providers', async () => { const service = new RateLimitService() service.setMiniMaxConfigResolver(() => ({ @@ -1141,4 +1177,20 @@ describe('RateLimitService', () => { expect(state.minimax?.error).toBe('minimax down') expect(state.claude?.status).toBe('ok') }) + + it('isolates MiniMax config resolver failures from other providers', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => { + throw new Error('MiniMax session cookie could not be decrypted') + }) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + const state = service.getState() + expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() + expect(state.minimax?.status).toBe('error') + expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted') + expect(state.claude?.status).toBe('ok') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 7e2bf948148..d270c6773a8 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -51,6 +51,11 @@ type MiniMaxRateLimitConfig = { models: string } +type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + type GeminiCliOAuthEnabledResolver = () => boolean // Why: Claude's subscription usage endpoint has a tight request budget. Quota @@ -94,6 +99,10 @@ function isSystemDefaultClaudeAuth( return provenance === 'system' || Boolean(provenance?.endsWith(':system')) } +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -282,6 +291,16 @@ export class RateLimitService { return this.getState() } + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving or forgetting the browser cookie can race an in-flight usage + // fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + async refreshForCodexAccountChange( outgoingAccountId?: string | null, target?: CodexAccountSelectionTarget @@ -968,6 +987,42 @@ export class RateLimitService { return process.platform !== 'win32' } + private resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable browser cookie must not abort every provider's + // quota refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } + + private getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' @@ -1004,10 +1059,10 @@ export class RateLimitService { const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfig = this.miniMaxConfigResolver?.() - const miniMaxCookie = miniMaxConfig?.sessionCookie ?? '' - const miniMaxGroupId = miniMaxConfig?.groupId ?? '' - const miniMaxModels = miniMaxConfig?.models ?? 'general' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Detect if configuration changed — if it did, we must discard any stale @@ -1020,7 +1075,7 @@ export class RateLimitService { } const opencodeGeneration = this.opencodeFetchGeneration - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}` + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash if (miniMaxConfigChanged) { this.lastMiniMaxConfigHash = currentMiniMaxConfigHash @@ -1066,11 +1121,13 @@ export class RateLimitService { fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), fetchKimiRateLimits(), - fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) ]) if (signal.aborted) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5bc855ee7c4..4be25f39e2b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1228,27 +1228,33 @@ computeWorktreePathMock.mockImplementation( ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) describe('OrcaRuntimeService', () => { - it('projects worktree card display settings to paired clients', () => { + it('projects runtime-backed settings to paired clients', () => { const runtime = new OrcaRuntimeService({ ...store, getSettings: () => ({ ...store.getSettings(), experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) } as never) expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) }) - it('accepts worktree card display setting updates from paired clients', () => { + it('accepts runtime-backed setting updates from paired clients', () => { let settings = { ...store.getSettings(), experimentalNewWorktreeCardStyle: false, - compactWorktreeCards: false + compactWorktreeCards: false, + minimaxGroupId: '', + minimaxUsageModels: 'general' } const updateSettings = vi.fn((updates: Partial) => { settings = { ...settings, ...updates } @@ -1263,19 +1269,30 @@ describe('OrcaRuntimeService', () => { expect( runtime.updateClientSettings({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) ).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) expect(updateSettings).toHaveBeenCalledWith( - { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true }, + { + experimentalNewWorktreeCardStyle: true, + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + }, { notifyListeners: true } ) expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12d6c3e9f37..053e2028750 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -796,6 +796,8 @@ type RuntimeStore = { githubProjects?: GlobalSettings['githubProjects'] experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] + minimaxGroupId?: GlobalSettings['minimaxGroupId'] + minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null @@ -2395,6 +2397,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') @@ -2414,7 +2418,9 @@ export class OrcaRuntimeService { defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, - compactWorktreeCards: settings.compactWorktreeCards === true + compactWorktreeCards: settings.compactWorktreeCards === true, + minimaxGroupId: settings.minimaxGroupId ?? '', + minimaxUsageModels: settings.minimaxUsageModels ?? 'general' } } @@ -2434,6 +2440,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > ): Pick< GlobalSettings, @@ -2451,6 +2459,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4baba6aef9a..4c692264cc0 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -22,6 +22,8 @@ describe('client UI RPC methods', () => { defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', githubProjects: { pinned: [], recent: [], @@ -77,6 +79,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -91,6 +95,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -176,7 +182,10 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -212,7 +221,10 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a18dfb3bd0f..b5991f669e4 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -40,7 +40,17 @@ const WorktreeCardProperties = z .array(LegacyWorktreeCardProperty) .transform((value) => normalizeWorktreeCardProperties(value)) const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage']) +const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'opencode-go', + 'kimi', + 'minimax', + 'ssh', + 'resource-usage', + 'ports' +]) const WorkspaceStatusDefinition = z.object({ id: z.string(), label: z.string(), @@ -142,6 +152,8 @@ const SettingsUpdate = z defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), githubProjects: GitHubProjectSettings.optional() }) .strict() @@ -188,6 +200,9 @@ const UiUpdate = z _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index aac01de620f..993914c89ae 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -116,7 +116,7 @@ function MiniMaxCookieHelpPopover(): React.JSX.Element {

{translate( 'auto.components.settings.AccountsPane.4e32e030b2', - 'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.' + 'Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.' )}

@@ -340,7 +340,6 @@ export function AccountsPane({ const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) - const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) @@ -425,11 +424,18 @@ export function AccountsPane({ } setMiniMaxCredentialBusy(true) try { - const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft) + const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft.trim()) + if (!status.configured) { + throw new Error( + translate( + 'auto.components.settings.AccountsPane.8e6f0cb1d8', + 'MiniMax cookie was not saved.' + ) + ) + } setMiniMaxConfigured(status.configured) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') - void refreshRateLimits() toast.success( translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.') ) @@ -453,7 +459,6 @@ export function AccountsPane({ setMiniMaxConfigured(status.configured) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') - void refreshRateLimits() } catch (error) { toast.error( translate( @@ -1544,15 +1549,14 @@ export function AccountsPane({ />

- {translate( - 'auto.components.settings.AccountsPane.0b8c1c7e02', - miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set' - )} + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.0b8c1c7e02', 'Stored locally') + : translate('auto.components.settings.AccountsPane.1fd1b1b6b4', 'Cookie not set')}

{translate( 'auto.components.settings.AccountsPane.5e08b0fe57', - 'Sent only to platform.minimax.io. Never leaves Orca.' + 'Stored locally and sent only to platform.minimax.io for usage refreshes.' )}

@@ -1635,7 +1639,7 @@ export function AccountsPane({ disabled={miniMaxCredentialBusy} className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground" > - {translate('auto.components.settings.AccountsPane.b398b834c9', 'Forget cookie')} + {translate('auto.components.settings.AccountsPane.316ca4e610', 'Forget cookie')} ) : null} @@ -1677,17 +1681,6 @@ export function AccountsPane({ )}

- {miniMaxConfigured ? ( - - ) : null} updateSettings({ minimaxGroupId: e.target.value })} placeholder={translate( 'auto.components.settings.AccountsPane.0747d6391a', - settings.minimaxGroupId ? 'From cookie' : 'Use group ID from cookie' + 'Use group ID from cookie' )} spellCheck={false} className="text-xs" diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index e26cffcccde..714844932f1 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -6,13 +6,13 @@ import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { availableStatusBarToggles: [] as { description: string - id: 'ports' + id: StatusBarItem keywords: string[] title: string toggleDescription: string @@ -364,6 +364,31 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { + mocks.state.availableStatusBarToggles = [ + { + id: 'minimax', + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.', + keywords: ['status bar', 'minimax', 'usage'] + } + ] + mocks.state.settingsSearchQuery = 'minimax' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const miniMaxSwitch = container.querySelector( + 'button[role="switch"][aria-label="MiniMax Usage"]' + ) + + expect(miniMaxSwitch).not.toBeNull() + await act(async () => { + miniMaxSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax') + }) + it('collapses sibling sections so only the Interface section is expanded by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 065056fbe6f..2173196651a 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -37,7 +37,14 @@ function recordStatusBarToggleInteraction( recordFeatureInteraction('ports') } else if (id === 'ssh') { recordFeatureInteraction('ssh') - } else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') { + } else if ( + id === 'claude' || + id === 'codex' || + id === 'gemini' || + id === 'opencode-go' || + id === 'kimi' || + id === 'minimax' + ) { recordFeatureInteraction('usage-tracking') } } diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts new file mode 100644 index 00000000000..18a218aca4c --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getStatusBarToggles } from './appearance-status-bar-search' + +describe('getStatusBarToggles', () => { + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { + const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') + + expect(miniMaxToggle).toMatchObject({ + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.' + }) + expect(miniMaxToggle?.keywords).toEqual( + expect.arrayContaining(['status bar', 'minimax', 'usage', 'subscription', 'cookie']) + ) + }) +}) diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index c97f198da24..673deda4b54 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -161,6 +161,41 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Kimi subscription usage for the active workspace.' ) }, + { + id: 'minimax', + title: translate('auto.components.settings.appearance.search.0f08f6b483', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.appearance.search.e46178eb1b', + 'Show MiniMax subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d16378a88f', + 'minimax' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d9e7cef86f', + 'cookie' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.25e51b62ee', + 'rate limit' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.minimaxToggleDescription', + 'Show MiniMax subscription usage for the active workspace.' + ) + }, { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 6fcbc68801d..6dd7c102d18 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1864,6 +1864,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating + // doesn't apply (same rationale as OpenCode Go below). const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d07151569b4..f00e6bd8e92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", + "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." @@ -4571,6 +4572,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "Find this in the URL after logging into opencode.ai (e.g.", "b398b834c9": "Clear", + "316ca4e610": "Forget cookie", "a122332371": "wrk_… (leave blank for automatic lookup)", "dbdb0b0bd8": "Workspace ID override", "d70a5287a4": "Optional workspace ID override if the automatic lookup fails.", @@ -4638,6 +4640,7 @@ "350b2a1aa7": "Use your current", "e05d0ff737": "Use your current {{value0}} Claude login.", "2f24f244a4": "MiniMax cookie is required.", + "8e6f0cb1d8": "MiniMax cookie was not saved.", "8d61637a77": "MiniMax cookie saved.", "b43e761fe5": "MiniMax cookie update failed.", "5d63bbfbec": "MiniMax", @@ -4659,13 +4662,13 @@ "5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.", "3c92b0d31c": "general", "0d8e77bc40": "Open console", - "0b8c1c7e02": "Encrypted locally", - "5e08b0fe57": "Sent only to platform.minimax.io. Never leaves Orca.", + "0b8c1c7e02": "Stored locally", + "1fd1b1b6b4": "Cookie not set", + "5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.", "43d7a45b97": "How to copy", "b8a4f21c3e": "Paste the Cookie header from DevTools", "53f7b8c7a2": "Last refresh: {{value0}}", "31d24a4e87": "Cookie expires when you sign out in the browser.", - "42c2cb21cf": "Forget cookie", "3a30aaf526": "just now", "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", "24560fe830": "Open DevTools.", @@ -4675,7 +4678,7 @@ "435df0ee51": "Under Request Headers, copy the Cookie value.", "7492fb3bba": "Paste it here and click Save.", "9fec52de4b": "How to copy the cookie", - "4e32e030b2": "The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes." + "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -7017,6 +7020,11 @@ "40e5c3c285": "kimi", "c927a155d5": "Show Kimi subscription usage in the status bar.", "3a6c028ea8": "Kimi Usage", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Show MiniMax subscription usage in the status bar.", + "0f08f6b483": "MiniMax Usage", "edbf0f63a0": "cost", "afbb6a3767": "tokens", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 07a1201658d..b2c5b024265 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "Muestra el consumo de tokens y el costo de Gemini para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el consumo de tokens y el costo de OpenCode Go para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", + "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos." @@ -2936,8 +2937,8 @@ "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", "c0e972d726": "Cancelar", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "Abrir detalles de uso de MiniMax", + "3bbf140864": "Uso de MiniMax" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4571,6 +4572,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk__…/go", "51c9104e13": "Encuéntralo en la URL después de iniciar sesión en opencode.ai (p. ej.", "b398b834c9": "Limpiar", + "316ca4e610": "Olvidar cookie", "a122332371": "wrk_… (dejar en blanco para búsqueda automática)", "dbdb0b0bd8": "Override de Workspace ID", "d70a5287a4": "Override opcional de Workspace ID si falla la búsqueda automática.", @@ -4638,6 +4640,7 @@ "350b2a1aa7": "Usa tu actual", "e05d0ff737": "Usa tu login actual de Claude de {{value0}}.", "2f24f244a4": "La cookie de MiniMax es obligatoria.", + "8e6f0cb1d8": "La cookie de MiniMax no se guardó.", "8d61637a77": "Cookie de MiniMax guardada.", "b43e761fe5": "No se pudo actualizar la cookie de MiniMax.", "5d63bbfbec": "MiniMax", @@ -4659,13 +4662,13 @@ "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", "3c92b0d31c": "general", "0d8e77bc40": "Abrir consola", - "0b8c1c7e02": "Cifrado localmente", - "5e08b0fe57": "Solo se envía a platform.minimax.io. Nunca sale de Orca.", + "0b8c1c7e02": "Almacenado localmente", + "1fd1b1b6b4": "Cookie no configurada", + "5e08b0fe57": "Se almacena localmente y se envía solo a platform.minimax.io para actualizar el uso.", "43d7a45b97": "Cómo copiar", "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", "53f7b8c7a2": "Última actualización: {{value0}}", "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", - "42c2cb21cf": "Olvidar cookie", "3a30aaf526": "ahora mismo", "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", "24560fe830": "Abre DevTools.", @@ -4675,7 +4678,7 @@ "435df0ee51": "En Request Headers, copia el valor de Cookie.", "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", "9fec52de4b": "Cómo copiar la cookie", - "4e32e030b2": "La cookie permanece en este dispositivo. Orca solo la envía a platform.minimax.io para actualizar el uso." + "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", @@ -6978,6 +6981,11 @@ "40e5c3c285": "kimi", "c927a155d5": "Muestra el uso de la suscripción de Kimi en la barra de estado.", "3a6c028ea8": "Uso de Kimi", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Muestra el uso de la suscripción de MiniMax en la barra de estado.", + "0f08f6b483": "Uso de MiniMax", "edbf0f63a0": "costo", "afbb6a3767": "tokens", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index be351905992..d5d6043054b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", + "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" @@ -2936,8 +2937,8 @@ "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", "f077f586db": "今後表示しない", "c0e972d726": "キャンセル", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "MiniMax の使用量詳細を開く", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai にログインした後、URL でこれを見つけます (例:", "b398b834c9": "クリア", + "316ca4e610": "Cookie を削除", "a122332371": "wrk_… (自動検索の場合は空白のままにします)", "dbdb0b0bd8": "ワークスペースIDのオーバーライド", "d70a5287a4": "自動検索が失敗した場合は、オプションのワークスペース ID をオーバーライドします。", @@ -4622,18 +4624,19 @@ "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "MiniMax Cookie が必要です。", + "8e6f0cb1d8": "MiniMax Cookie は保存されませんでした。", + "8d61637a77": "MiniMax Cookie を保存しました。", + "b43e761fe5": "MiniMax Cookie の更新に失敗しました。", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "platform.minimax.io から MiniMax の使用量追跡を設定します。", + "21d6eb141e": "MiniMax セッション Cookie", + "33bba5ad83": "ローカルのレート制限取得用に MiniMax セッション Cookie を貼り付けます。", + "73ea15f24b": "保存済み", + "23afe8f226": "未保存", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", + "f38b9cc4bd": "置き換え", + "590a3130f9": "保存", "79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。", "9dd50d3f75": "詳細設定", "174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。", @@ -4644,13 +4647,13 @@ "5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。", "3c92b0d31c": "general", "0d8e77bc40": "コンソールを開く", - "0b8c1c7e02": "ローカルで暗号化", - "5e08b0fe57": "platform.minimax.io のみに送信されます。Orca の外部に出ることはありません。", + "0b8c1c7e02": "ローカルに保存済み", + "1fd1b1b6b4": "Cookie は未設定です", + "5e08b0fe57": "ローカルに保存され、使用量の更新のために platform.minimax.io のみに送信されます。", "43d7a45b97": "コピー方法", "b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け", "53f7b8c7a2": "最終更新: {{value0}}", "31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。", - "42c2cb21cf": "Cookie を削除", "3a30aaf526": "たった今", "f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。", "24560fe830": "DevTools を開きます。", @@ -4660,7 +4663,7 @@ "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", "7492fb3bba": "ここに貼り付けて保存をクリックします。", "9fec52de4b": "Cookie のコピー方法", - "4e32e030b2": "Cookie はこのデバイスにのみ保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" + "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -6829,8 +6832,8 @@ "593720c17f": "場所", "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", "d09fb5ca92": "アカウントの場所", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。" } }, "advanced": { @@ -7000,6 +7003,11 @@ "40e5c3c285": "kimi", "c927a155d5": "ステータス バーに Kimi サブスクリプションの使用状況を表示します。", "3a6c028ea8": "Kimi 使用量", + "25e51b62ee": "レート制限", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "ステータスバーに MiniMax サブスクリプションの使用状況を表示します。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "料金", "afbb6a3767": "トークン", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ef3d48a60bb..419d2229dcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", + "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." @@ -2936,8 +2937,8 @@ "6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.", "f077f586db": "다시 묻지 않기", "c0e972d726": "취소", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "MiniMax 사용량 세부 정보 열기", + "3bbf140864": "MiniMax 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai에 로그인한 후 URL에서 이를 찾으세요(예:", "b398b834c9": "지우기", + "316ca4e610": "쿠키 잊기", "a122332371": "wrk_… (자동 조회를 위해 비워두세요)", "dbdb0b0bd8": "워크스페이스 ID 재정의", "d70a5287a4": "자동 조회가 실패할 때 사용할 선택적 워크스페이스 ID 재정의입니다.", @@ -4622,18 +4624,19 @@ "b11078a9c2": "wsl", "350b2a1aa7": "현재 사용 중인", "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "MiniMax Cookie가 필요합니다.", + "8e6f0cb1d8": "MiniMax Cookie가 저장되지 않았습니다.", + "8d61637a77": "MiniMax Cookie가 저장되었습니다.", + "b43e761fe5": "MiniMax Cookie 업데이트에 실패했습니다.", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "platform.minimax.io에서 MiniMax 사용량 추적을 설정합니다.", + "21d6eb141e": "MiniMax 세션 Cookie", + "33bba5ad83": "로컬 rate-limit 가져오기에 사용할 MiniMax 세션 Cookie를 붙여넣으세요.", + "73ea15f24b": "저장됨", + "23afe8f226": "저장되지 않음", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", + "f38b9cc4bd": "교체", + "590a3130f9": "저장", "79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.", "9dd50d3f75": "고급", "174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.", @@ -4644,13 +4647,13 @@ "5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.", "3c92b0d31c": "general", "0d8e77bc40": "콘솔 열기", - "0b8c1c7e02": "로컬에서 암호화됨", - "5e08b0fe57": "platform.minimax.io에만 전송됩니다. Orca 외부로 나가지 않습니다.", + "0b8c1c7e02": "로컬에 저장됨", + "1fd1b1b6b4": "Cookie가 설정되지 않음", + "5e08b0fe57": "로컬에 저장되며 사용량 새로 고침을 위해 platform.minimax.io로만 전송됩니다.", "43d7a45b97": "복사 방법", "b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기", "53f7b8c7a2": "마지막 새로 고침: {{value0}}", "31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.", - "42c2cb21cf": "Cookie 삭제", "3a30aaf526": "방금 전", "f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.", "24560fe830": "DevTools를 엽니다.", @@ -4660,7 +4663,7 @@ "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", "9fec52de4b": "Cookie 복사 방법", - "4e32e030b2": "Cookie는 이 기기에만 보관됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." + "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -6792,8 +6795,8 @@ "593720c17f": "위치", "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", "d09fb5ca92": "계정 위치", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 사용량", + "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요." } }, "advanced": { @@ -6963,6 +6966,11 @@ "40e5c3c285": "kimi", "c927a155d5": "상태 표시줄에 Kimi 구독 사용량을 표시합니다.", "3a6c028ea8": "Kimi 사용량", + "25e51b62ee": "사용 한도", + "d9e7cef86f": "쿠키", + "d16378a88f": "minimax", + "e46178eb1b": "상태 표시줄에 MiniMax 구독 사용량을 표시합니다.", + "0f08f6b483": "MiniMax 사용량", "edbf0f63a0": "비용", "afbb6a3767": "토큰", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 75667d4483e..56c381482f7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", + "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" @@ -2936,8 +2937,8 @@ "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", "f077f586db": "不再询问", "c0e972d726": "取消", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "打开 MiniMax 使用量详情", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "登录 opencode.ai 后在 URL 中找到此内容(例如", "b398b834c9": "清除", + "316ca4e610": "忘记 Cookie", "a122332371": "wrk_…(留空自动查找)", "dbdb0b0bd8": "工作区 ID 覆盖", "d70a5287a4": "如果自动查找失败,可选的工作区 ID 覆盖。", @@ -4622,45 +4624,46 @@ "b11078a9c2": "wsl", "350b2a1aa7": "用你已有的", "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "需要 MiniMax Cookie。", + "8e6f0cb1d8": "MiniMax Cookie 未保存。", + "8d61637a77": "MiniMax Cookie 已保存。", + "b43e761fe5": "MiniMax Cookie 更新失败。", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "从 platform.minimax.io 配置 MiniMax 使用量跟踪。", + "21d6eb141e": "MiniMax 会话 Cookie", + "33bba5ad83": "粘贴 MiniMax 会话 Cookie 以在本地获取速率限制。", + "73ea15f24b": "已保存", + "23afe8f226": "未保存", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", - "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", + "f38b9cc4bd": "替换", + "590a3130f9": "保存", + "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", "9dd50d3f75": "高级", - "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", + "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", "bf160bb6c0": "Group ID 覆盖", "b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。", "0747d6391a": "使用 Cookie 中的 Group ID", "4ff2af7524": "使用模型名称", - "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", + "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", "3c92b0d31c": "general", "0d8e77bc40": "打开控制台", - "0b8c1c7e02": "本地加密", - "5e08b0fe57": "仅发送到 platform.minimax.io。不会离开 Orca。", + "0b8c1c7e02": "已存储在本地", + "1fd1b1b6b4": "未设置 Cookie", + "5e08b0fe57": "存储在本地,仅发送到 platform.minimax.io 以刷新使用量。", "43d7a45b97": "如何复制", "b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头", "53f7b8c7a2": "上次刷新: {{value0}}", - "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", - "42c2cb21cf": "忘记 Cookie", + "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", "3a30aaf526": "刚刚", "f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。", "24560fe830": "打开 DevTools。", "4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。", "bee4e63e1c": "重新加载页面。", "87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。", - "435df0ee51": "在请求头中,复制 Cookie 的值。", + "435df0ee51": "在请求头中,复制 Cookie 的值。", "7492fb3bba": "在此粘贴并点击保存。", "9fec52de4b": "如何复制 Cookie", - "4e32e030b2": "Cookie 仅保留在此设备上。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" + "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -6792,8 +6795,8 @@ "593720c17f": "位置", "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", "d09fb5ca92": "账户位置", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。" } }, "advanced": { @@ -6963,6 +6966,11 @@ "40e5c3c285": "基米", "c927a155d5": "在状态栏中显示 Kimi 订阅使用情况。", "3a6c028ea8": "基米用法", + "25e51b62ee": "速率限制", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "在状态栏中显示 MiniMax 订阅使用情况。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "成本", "afbb6a3767": "Token", "d77537b580": "opencode-go", diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4b64524c786..64899d1a169 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1162,11 +1162,18 @@ describe('createUISlice hydratePersistedUI', () => { }) ) - expect(store.getState().statusBarItems).toEqual(['claude', 'resource-usage', 'ports', 'kimi']) + expect(store.getState().statusBarItems).toEqual([ + 'claude', + 'resource-usage', + 'ports', + 'kimi', + 'minimax' + ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi'], + statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) }) @@ -1179,7 +1186,8 @@ describe('createUISlice hydratePersistedUI', () => { makePersistedUI({ statusBarItems: ['claude', 'resource-usage'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 5e0c78c2945..c8e5f112e39 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -263,6 +263,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) @@ -2218,15 +2219,22 @@ export const createUISlice: StateCreator = (set, get) ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') ? statusBarItemsWithPorts : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] + const statusBarItemsWithMiniMax = + ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') + ? statusBarItems + : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] if ( - (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded) && + (!ui._portsStatusBarDefaultAdded || + !ui._kimiStatusBarDefaultAdded || + !ui._minimaxStatusBarDefaultAdded) && typeof window !== 'undefined' ) { window.api.ui .set({ - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) .catch(console.error) } @@ -2286,7 +2294,7 @@ export const createUISlice: StateCreator = (set, get) workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 18f64e1266d..0dd6b240f40 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -410,6 +410,47 @@ describe('web settings preload API', () => { expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) }) + it('hydrates MiniMax usage settings from a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) + }) + it('forwards compact worktree card updates to a paired runtime', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ @@ -478,6 +519,77 @@ describe('web settings preload API', () => { { method: 'settings.update', params: { experimentalNewWorktreeCardStyle: true } } ]) }) + + it('forwards MiniMax usage setting updates to a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.set({ + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + }) + + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([ + { + method: 'settings.update', + params: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + } + ]) + }) +}) + +describe('web MiniMax preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => { + const { api } = await installApi('Linux') + + await expect(api.minimaxCredentials.getStatus()).resolves.toEqual({ configured: false }) + await expect(api.minimaxCredentials.saveCookie('_token=abc')).rejects.toThrow(/desktop app/i) + await expect(api.minimaxCredentials.clearCookie()).resolves.toEqual({ configured: false }) + }) }) describe('web UI preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 9ad9b1e0ba3..892b47706d2 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -638,6 +638,7 @@ function createWebPreloadApi(): Partial { preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), + minimaxCredentials: createMiniMaxCredentialsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), @@ -2476,6 +2477,16 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { } } +function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + function createAccountsApi(): never { const empty = { accounts: [], @@ -2850,6 +2861,12 @@ async function getRuntimeBackedStoredSettings(): Promise { if (typeof result.settings.compactWorktreeCards === 'boolean') { runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } const next = mergeSettings(local, runtimeSettings) writeJson(SETTINGS_STORAGE_KEY, next) return next @@ -2873,6 +2890,12 @@ async function syncRuntimeBackedSettings( if (typeof updates.compactWorktreeCards === 'boolean') { runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } if (Object.keys(runtimeUpdates).length === 0) { return localNext } diff --git a/src/shared/types.ts b/src/shared/types.ts index 8d3268035aa..3592987167f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3227,6 +3227,8 @@ export type PersistedUIState = { _portsStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Kimi status item. */ _kimiStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on MiniMax status item. */ + _minimaxStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null From 812f24bd19b72740afb0d12322951541dc2b8e3c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:36:24 -0700 Subject: [PATCH 122/141] fix(terminal): clear PTY-side buffers on Ctrl+K so the prompt stops repainting at a stale row (#7413) --- src/main/daemon/headless-emulator.ts | 17 +++ src/main/daemon/pty-subprocess.ts | 10 ++ src/main/daemon/session.test.ts | 102 +++++++++++++++++- src/main/daemon/session.ts | 34 ++++++ src/main/ipc/pty.ts | 11 ++ src/main/providers/local-pty-provider.ts | 15 ++- src/main/runtime/orca-runtime.ts | 4 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 7 ++ .../components/terminal-pane/TerminalPane.tsx | 8 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/shell-process-detection.ts | 18 ++++ 12 files changed, 224 insertions(+), 5 deletions(-) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index f5c2bb34049..fb0a9c190d4 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -172,6 +172,23 @@ export class HeadlessEmulator { return this.terminal.buffer.active.type === 'alternate' } + /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with + * pending input it re-renders at a cached buffer row that ConPTY's fixed + * viewport doesn't track, painting the input well below the prompt. The + * cursor line counts as an empty prompt when everything before the cursor + * ends with a single '>' and nothing follows it ('>>' is PowerShell's + * continuation prompt, i.e. a multiline edit in flight). */ + isCursorOnEmptyPromptLine(): boolean { + const buffer = this.terminal.buffer.active + const line = buffer.getLine(buffer.baseY + buffer.cursorY) + if (!line) { + return false + } + const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd() + const fullLine = line.translateToString(true).trimEnd() + return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>') + } + getVisibleLines(): string[] { const buffer = this.terminal.buffer.active const lines: string[] = [] diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 35e92157ba4..b9800941b7e 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1030,6 +1030,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true } }, + clear: () => { + if (dead) { + return + } + try { + proc.clear() + } catch { + // Best-effort: a clear on a just-exited PTY must not kill the handle. + } + }, kill: () => { if (dead) { return diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index fb5b961cc98..478b4ea99f0 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -9,6 +9,7 @@ function createMockSubprocess() { let onData: ((data: string) => void) | null = null let onExit: ((code: number) => void) | null = null let killed = false + let clearCalls = 0 let pid = 12345 return { @@ -20,13 +21,20 @@ function createMockSubprocess() { get pid() { return pid }, + foregroundProcess: null as string | null, getForegroundProcess(): string | null { - return null + return this.foregroundProcess }, write(data: string) { written.push(data) }, resize(_cols: number, _rows: number) {}, + get clearCalls() { + return clearCalls + }, + clear() { + clearCalls++ + }, kill() { killed = true // Simulate async exit @@ -426,6 +434,98 @@ describe('Session', () => { }) }) + describe('clearScrollback', () => { + function withPlatform(platform: NodeJS.Platform, run: () => void): void { + const original = process.platform + Object.defineProperty(process, 'platform', { value: platform }) + try { + run() + } finally { + Object.defineProperty(process, 'platform', { value: original }) + } + } + + it('resyncs the native PTY screen state alongside the emulator clear', () => { + createSession() + session.clearScrollback() + // Why: without the subprocess clear, ConPTY keeps a stale cursor row and + // the next prompt repaint lands below a blank gap on Windows. + expect(subprocess.clearCalls).toBe(1) + const take = session.takePendingOutput(false) + expect(take?.records).toContainEqual({ kind: 'clear' }) + }) + + it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + // Why: the ConPTY clear cannot reach PSReadLine's cached cursor row; + // Ctrl+L makes PSReadLine repaint the prompt at the true origin. + expect(subprocess.written).toEqual(['\x0c']) + }) + + it('does not send a form feed while input is pending at the prompt', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> fd') + await vi.advanceTimersByTimeAsync(10) + // Why: PSReadLine repaints pending input at a stale cached row that + // ConPTY's fixed viewport doesn't track, so the nudge must be skipped. + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send or queue a form feed before shell-ready', async () => { + createSession({ shellReadySupported: true }) + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + // Why: a queued form feed would flush after the startup command at an + // arbitrary later moment, when the prompt gates no longer hold. + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed at a PowerShell continuation prompt', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed while a command owns the foreground', async () => { + createSession() + subprocess.foregroundProcess = 'node' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed on POSIX platforms', async () => { + createSession() + subprocess.foregroundProcess = 'pwsh' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('linux', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not touch the subprocess after dispose', () => { + createSession() + session.dispose() + session.clearScrollback() + expect(subprocess.clearCalls).toBe(0) + }) + }) + describe('snapshot', () => { it('returns a terminal snapshot', async () => { createSession() diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b4d3433a5f8..d156aeb4802 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -8,6 +8,7 @@ import { scanForShellReady, type ShellReadyScanState } from '../shell-ready-marker-scanner' +import { isPowerShellProcess } from '../../shared/shell-process-detection' import type { PendingOutputRecord, SessionState, @@ -41,6 +42,10 @@ export type SubprocessHandle = { startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void + /** Resync the native PTY's own screen state after a frontend clear. + * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes + * the next prompt repaint land below a blank gap. */ + clear?(): void kill(): void forceKill(): void signal(sig: string): void @@ -285,6 +290,35 @@ export class Session { } this.emulator.clearScrollback() this.recordPendingOutput({ kind: 'clear' }) + this.subprocess.clear?.() + this.#nudgePowerShellPromptRepaint() + } + + /** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row, + * so PowerShell's first Enter after a clear would still repaint the prompt + * at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes + * PSReadLine itself repaint at the true origin. Gated to a PowerShell + * foreground so a running command or TUI never gets a stray 0x0C, and to + * an empty prompt because PSReadLine repaints pending input at a stale + * cached row that ConPTY's fixed viewport doesn't track. */ + #nudgePowerShellPromptRepaint(): void { + if (process.platform !== 'win32') { + return + } + // Why: before shell-ready, write() would queue the form feed behind the + // buffered startup command and deliver it at an arbitrary later moment, + // when the foreground/prompt gates below no longer hold. The nudge is + // cosmetic — skip it rather than defer it. + if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { + return + } + if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) { + return + } + if (!this.emulator.isCursorOnEmptyPromptLine()) { + return + } + this.subprocess.write('\x0c') } prepareForFinalSnapshot(): string { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8a2c109f744..c43104d3b03 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3509,6 +3509,17 @@ export function registerPtyHandlers( .catch(() => {}) }) + ipcMain.removeAllListeners('pty:clearBuffer') + ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + // Why: the renderer already cleared its own xterm buffer. This clears the + // PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer) + // so the next prompt repaint doesn't land at a stale cursor row. + tryGetProviderForPty(args.id) + ?.clearBuffer(args.id) + .catch(() => {}) + runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + }) + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0d2eb19f1a6..81f9cbcd3b2 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -898,8 +898,19 @@ export class LocalPtyProvider implements IPtyProvider { async getInitialCwd(_id: string): Promise { return '' } - async clearBuffer(_id: string): Promise { - /* handled client-side in xterm.js */ + async clearBuffer(id: string): Promise { + // Why: xterm.js clear() only resets the renderer. ConPTY keeps its own + // screen buffer, so without this its stale cursor row makes the next + // prompt repaint land below a blank gap. No-op on POSIX. + // + // Unlike the daemon session, no PSReadLine form-feed nudge here: it is + // only safe at an empty prompt, and without a headless emulator this + // provider cannot tell whether input is pending. + try { + ptyProcesses.get(id)?.clear() + } catch { + /* PTY may have just exited */ + } } acknowledgeDataEvent(_id: string, _charCount: number): void { /* no flow control for local */ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 053e2028750..962a72dccdb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5955,7 +5955,9 @@ export class OrcaRuntimeService { }) } - private async clearHeadlessTerminalBuffer(ptyId: string): Promise { + // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile + // mirror or a resubscribing mobile client resurrects the cleared scrollback. + async clearHeadlessTerminalBuffer(ptyId: string): Promise { const state = this.headlessTerminals.get(ptyId) if (!state) { return diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cb9cd51aa20..02a70ec0e01 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1150,6 +1150,7 @@ export type PreloadApi = { resize: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void + clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 680c8b8d88c..ba7760a5bf9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -812,6 +812,13 @@ const api = { ipcRenderer.send('pty:signal', { id, signal }) }, + /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host (ConPTY, + * daemon emulator, SSH host buffer) keeps its own screen state and would + * repaint the next prompt at the stale cursor row. */ + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index afc5f3f70c5..7ed49ac0da0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1048,7 +1048,13 @@ export default function TerminalPane({ // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - clearWebRuntimeTerminalBuffer(ptyId) + const clearedRemoteHostBuffer = clearWebRuntimeTerminalBuffer(ptyId) + if (!clearedRemoteHostBuffer && ptyId) { + // Why: local/daemon/SSH PTYs keep their own screen state (ConPTY on + // Windows), and a stale host cursor row makes the next prompt repaint + // land below a blank gap after a frontend-only clear. + window.api.pty.clearBuffer(ptyId) + } persistLayoutSnapshot() }, [paneTransportsRef, persistLayoutSnapshot] diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 892b47706d2..8647c9b0d86 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2552,6 +2552,8 @@ function createPtyApi(): NonNullable['pty']> { resize: () => {}, reportGeometry: () => {}, signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts index 381b3bc42a4..0ae76e42673 100644 --- a/src/shared/shell-process-detection.ts +++ b/src/shared/shell-process-detection.ts @@ -21,3 +21,21 @@ export function isShellProcess(processName: string): boolean { SHELL_NAMES.has(basenameWithoutWindowsExtension) ) } + +// Why: a ConPTY-side buffer clear cannot reach PSReadLine's cached cursor +// row, so the first Enter after a terminal clear still repaints the prompt at +// the stale row. Only the PowerShell family binds Ctrl+L (form feed) to a +// full prompt repaint, so the post-clear nudge must stay scoped to it. +const POWERSHELL_NAMES = new Set(['powershell', 'pwsh']) + +export function isPowerShellProcess(processName: string | null | undefined): boolean { + if (!processName) { + return false + } + const normalized = processName + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase() + const basename = normalized.split(/[\\/]/).pop() ?? normalized + return POWERSHELL_NAMES.has(basename.replace(WINDOWS_PROCESS_EXTENSION_RE, '')) +} From 9981c3b82764d7f0cf7ab4256abb3243da05030e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:53:17 -0700 Subject: [PATCH 123/141] Check for Updates: cmd/ctrl-click fetches latest perf-tagged prerelease (#7278) Co-authored-by: Orca --- src/main/menu/register-app-menu.test.ts | 39 ++++-- src/main/menu/register-app-menu.ts | 16 ++- src/main/updater-prerelease-feed.test.ts | 80 ++++++++++++ src/main/updater-prerelease-feed.ts | 24 +++- src/main/updater.test.ts | 91 +++++++++++++ src/main/updater.ts | 122 ++++++++++++++---- .../window/attach-main-window-services.ts | 8 +- src/preload/api-types.ts | 3 +- .../settings/GeneralUpdateSettingsSection.tsx | 14 +- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 59 ++++++++- .../sidebar/SidebarSettingsHelpMenu.tsx | 22 +++- .../lib/update-check-click-options.test.ts | 53 ++++++++ .../src/lib/update-check-click-options.ts | 24 ++++ src/shared/types.ts | 5 + 14 files changed, 500 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/lib/update-check-click-options.test.ts create mode 100644 src/renderer/src/lib/update-check-click-options.ts diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 39b266b6846..fa636e8623c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -139,7 +139,7 @@ describe('registerAppMenu', () => { expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 }) }) - it('includes prereleases when Check for Updates is clicked with shift held', () => { + it('routes Check for Updates modifier clicks to prerelease and perf checks', () => { const options = buildMenuOptions() registerAppMenu(options) @@ -152,19 +152,42 @@ describe('registerAppMenu', () => { ) item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent) + item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) item?.click?.( {} as never, undefined as never, - { metaKey: true, shiftKey: true } as Electron.KeyboardEvent + { + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + { + triggeredByAccelerator: true, + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent ) - item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) - item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent) expect(options.onCheckForUpdates.mock.calls).toEqual([ - [{ includePrerelease: true }], - [{ includePrerelease: true }], - [{ includePrerelease: false }], - [{ includePrerelease: false }] + [{ includePrerelease: true, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: true, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index a5390e36afc..c36aca6973a 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -5,6 +5,7 @@ import { type KeybindingActionId, type KeybindingOverrides } from '../../shared/keybindings' +import type { UpdateCheckOptions } from '../../shared/types' import { translateMain } from '../i18n/main-i18n' export type AppearanceMenuState = { @@ -26,7 +27,7 @@ type RegisterAppMenuOptions = { onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void onOpenCrashReport: (window?: Electron.BaseWindow | null) => void - onCheckForUpdates: (options: { includePrerelease: boolean }) => void + onCheckForUpdates: (options: UpdateCheckOptions) => void onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void onZoomIn: () => void onZoomOut: () => void @@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { webContents.reload() } - // Why: holding Shift while clicking Check for Updates opts this check into - // the release-candidate channel. Extracted so both the macOS app-menu entry - // and the Windows/Linux Help-menu entry share the exact same behavior. + // Why: modifier-click update checks are hidden power-user affordances. + // Extracted so the macOS app-menu entry and Windows/Linux Help entry share + // identical RC/perf channel routing. const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = ( _menuItem, _window, event ) => { - const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true - onCheckForUpdates({ includePrerelease }) + const modifierClick = !event.triggeredByAccelerator + const includePerfPrerelease = + modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) + const includePrerelease = modifierClick && event.shiftKey === true + onCheckForUpdates({ includePrerelease, includePerfPrerelease }) } const checkForUpdatesItem: Electron.MenuItemConstructorOptions = { diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index e9f40a72a09..f30fa276b65 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => { expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1') }) + it('ignores perf-tagged prereleases for regular RC checks', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe( + 'v1.4.121-rc.6' + ) + }) + + it('reports no RC update when only perf-tagged prereleases are newer', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + + it('picks the semver-newest perf-tagged prerelease', async () => { + respondWithAtom([ + 'v1.4.121-rc.6.perf', + 'v1.4.121-rc.7', + 'v1.4.121-rc.5.perf', + 'v1.4.121-rc.6', + 'v1.4.122' + ]) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('matches only literal rc.N.perf prerelease tags for perf checks', async () => { + respondWithAtom([ + 'v1.4.121-rc.7.performance', + 'v1.4.121-beta.7.perf', + 'v1.4.121-rc.7.perf.extra', + 'v1.4.121-rc.6.perf' + ]) + + const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed') + + expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false) + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('reports no perf update instead of falling back to stable or RC tags', async () => { + respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.120', 1, { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + it('returns a bounded fallback candidate after the newest newer tag', async () => { respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index ec328db10eb..55f49a77282 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -43,6 +43,18 @@ type ReleaseFeedTag = { version: string } +export function isPerfPrereleaseTag(tag: string): boolean { + const version = normalizeTagToVersion(tag) + const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/) + const identifiers = match?.[1]?.split('.') ?? [] + return ( + identifiers.length === 3 && + identifiers[0] === 'rc' && + /^\d+$/.test(identifiers[1]) && + identifiers[2] === 'perf' + ) +} + async function fetchReleaseFeedTags(): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) @@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { */ type FetchNewerReleaseTagOptions = { includePrerelease?: boolean + releaseFilter?: 'perf' } export type FetchNewerReleaseTagsResult = { @@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness( return { tags: [], state: 'unavailable' } } - const candidates = includePrerelease - ? tags - : tags.filter(({ version }) => !isPrereleaseVersion(version)) + // Why: perf builds are explicit opt-in; regular prerelease checks should + // stay on the main RC/stable series even though perf tags are semver-newer. + const candidates = + options.releaseFilter === 'perf' + ? tags.filter(({ tag }) => isPerfPrereleaseTag(tag)) + : includePrerelease + ? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag)) + : tags.filter(({ version }) => !isPrereleaseVersion(version)) const newestNewerIndex = candidates.findIndex( ({ version }) => compareVersions(version, currentVersion) > 0 ) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 60ff680a012..b4c1a47c54b 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1012,6 +1012,97 @@ describe('updater', () => { expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1) }) + it('pins the generic feed to a perf-tagged prerelease when requested', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf' + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowPrerelease).toBe(true) + }) + + it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true + }) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls) + }) + + it('keeps background retries on the stable channel after a perf publishing-window miss', async () => { + vi.useFakeTimers() + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) + .mockResolvedValueOnce(['v1.4.121']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, { + includePrerelease: false + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121' + }) + }) + }) + it('leaves the feed URL alone for a normal user-initiated check', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const mainWindow = { webContents: { send: vi.fn() } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 0d383880088..99553eadfb8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, powerMonitor } from 'electron' import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' -import type { UpdateStatus } from '../shared/types' +import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } type PrimaryEventSuppression = { failureKey: string; error: unknown } +type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' } let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void | Promise) | null = null let autoUpdaterInitialized = false -// Why: Shift-clicking "Check for Updates" opts the user into the RC release -// channel for the rest of this process. The generic feed still gets pinned to -// a concrete tag on every check so cancelled RCs without manifests are skipped. +// Why: modifier-clicking "Check for Updates" can target prerelease manifests. +// The generic feed still gets pinned to a concrete tag on every check so +// cancelled prereleases without manifests are skipped. let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null @@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null let activeUpdateCheckLaunchAttemptId: number | null = null let activeUpdateCheckEventAttemptId: number | null = null let updateAvailableEventPendingAttemptId: number | null = null -let pendingPrereleaseUserInitiatedCheckAfterInFlight = false +let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false @@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { - const shouldLaunchPendingPrereleaseCheck = - pendingPrereleaseUserInitiatedCheckAfterInFlight && + const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && (status.state === 'idle' || status.state === 'not-available' || status.state === 'available' || @@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void { ) { downloadInFlight = false } - if (shouldLaunchPendingPrereleaseCheck) { - launchPendingPrereleaseUserInitiatedCheckAfterInFlight() + if (shouldLaunchPendingUserInitiatedCheck) { + launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) return } if (statusesEqual(currentStatus, decoratedStatus)) { @@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } -function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void { - pendingPrereleaseUserInitiatedCheckAfterInFlight = false +function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } +} + +function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + return 'default' +} + +function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + pendingUserInitiatedCheckAfterInFlight = null setTimeout(() => { // Why: electron-updater clears its in-flight promise after emitting the - // terminal event. Deferring one tick lets the queued RC check start fresh - // instead of being deduped into the just-finished stable check. + // terminal event. Deferring one tick lets the queued modifier check start + // fresh instead of being deduped into the just-finished stable check. if (currentStatus.state === 'checking') { currentStatus = { state: 'idle' } } - checkForUpdatesFromMenu({ includePrerelease: true }) + checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) }, 0) } @@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v ) } -async function pinDefaultReleaseFeed(): Promise { +async function pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' +): Promise { const autoUpdater = getAutoUpdater() // Why: the /releases/latest/download/ redirect can move between the update // check and the later manual download click. Pinning to the concrete tag @@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise { // Prerelease users still need any-channel resolution so they can move to a // newer RC or the next stable. Stable users should only resolve stable tags. const currentVersion = app.getVersion() - const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion) + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( currentVersion, includePrerelease ? 2 : 1, { - includePrerelease + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) } ) const newerTag = releaseTagsResult.tags[0] ?? null @@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } else if (releaseTagsResult.state === 'not-ready') { clearPrereleaseFallbackContext() if (releaseTagsResult.lastGoodTag) { @@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise { ) publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } autoUpdater.setFeedURL({ provider: 'generic', url }) - return + return 'ready' } clearPublishingWindowLastGoodCheck() console.info( `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` ) throw new Error('Latest release assets are still publishing') + } else if (isPerfCheck) { + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') } else { clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() @@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } } @@ -972,6 +1013,10 @@ export function checkForUpdates(): void { }) } +function enablePrereleaseManifestChecks(): void { + getAutoUpdater().allowPrerelease = true +} + function enableIncludePrerelease(): void { if (includePrereleaseActive) { return @@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void { // accept a prerelease manifest for users who intentionally Shift-clicked. // We keep using the manifest-probed generic feed instead of the native // GitHub provider because cancelled RC releases can appear without assets. - getAutoUpdater().allowPrerelease = true + enablePrereleaseManifestChecks() includePrereleaseActive = true } /** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ -export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void { +export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available', userInitiated: true }) return } - if (options?.includePrerelease) { + const checkVariant = getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { clearPrereleaseFallbackContext() enableIncludePrerelease() + } else if (checkVariant === 'perf') { + clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests for this check, but must not + // opt future default/background checks into the RC channel. + enablePrereleaseManifestChecks() } const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' @@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean if (checkAlreadyInFlight) { backgroundCheckPromotedToUserInitiated = true rearmActiveUpdateCheckStallTimer() - if (options?.includePrerelease) { - // Why: the in-flight check may have already pinned the stable feed. - // Queue a fresh RC check so Shift-click doesn't inherit a stable result. - pendingPrereleaseUserInitiatedCheckAfterInFlight = true + if (checkVariant !== 'default') { + // Why: the in-flight check may have already pinned the stable feed. Queue + // a fresh modifier check so it doesn't inherit a stale-channel result. + pendingUserInitiatedCheckAfterInFlight = checkVariant } return } @@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean markUpdateCheckLaunched(attemptId) return autoUpdater.checkForUpdates() } - const run = pinDefaultReleaseFeed().then(launch) + const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!isActiveUpdateCheckAttempt(attemptId)) { + return false + } + userInitiatedCheck = false + finishActiveUpdateCheckAttempt() + recordCompletedUpdateCheck() + sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) + .then((launchResult) => { + if (launchResult === false) { + return + } + handleSettledUpdateCheckPromise(attemptId) + }) .catch((err) => { if (!isActiveUpdateCheckAttempt(attemptId)) { return diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 674bd39e3f2..2d34a4c9fd0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' +import type { + CreateWorktreeResult, + UpdateCheckOptions, + WorktreeStartupLaunch +} from '../../shared/types' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' @@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) - ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => { + ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => { ensureAutoUpdaterConfigured() return checkForUpdatesFromMenu(options) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 02a70ec0e01..a6cf04e7d75 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -162,6 +162,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + UpdateCheckOptions, UpdateStatus, Worktree, WorktreeBaseStatusEvent, @@ -2160,7 +2161,7 @@ export type PreloadApi = { updater: { getVersion: () => Promise getStatus: () => Promise - check: (options?: { includePrerelease?: boolean }) => Promise + check: (options?: UpdateCheckOptions) => Promise download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index c67c40f8b73..51668b1ac12 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -7,6 +7,7 @@ import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -36,6 +37,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { } const [appVersion, setAppVersion] = useState(null) + const updateCheckHint = getUpdateCheckHint() useEffect(() => { let cancelled = false @@ -87,14 +89,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { ), @@ -250,6 +268,43 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Check for Updates menu item', () => { const html = renderToStaticMarkup() expect(html).toContain('Check for Updates') + expect(html).toMatch(/(⇧\+click|Shift\+click) checks the latest RC/) + expect(html).toMatch(/(⌘\+click|Ctrl\+click) checks the latest perf build/) + }) + + it('passes update-check modifier options through the updater bridge', async () => { + const container = await renderMenu() + const checkButton = findMenuItem(container, 'Check for Updates') + const primaryModifier = navigator.userAgent.includes('Mac') + ? { metaKey: true } + : { ctrlKey: true } + + await act(async () => { + checkButton.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, shiftKey: true })) + checkButton.click() + }) + await act(async () => { + checkButton.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, ...primaryModifier }) + ) + checkButton.click() + }) + await act(async () => { + checkButton.click() + }) + + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(1, { + includePrerelease: true, + includePerfPrerelease: false + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(2, { + includePrerelease: false, + includePerfPrerelease: true + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(3, { + includePrerelease: false, + includePerfPrerelease: false + }) }) it('renders shortcut keys in the settings tooltip', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index d74fcfb11aa..1c6e282be74 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -33,12 +33,14 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress' import { SidebarFeedbackDialog } from './SidebarFeedbackDialog' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' const DOCS_URL = 'https://www.onorca.dev/docs' const CHANGELOG_URL = 'https://onorca.dev/changelog' const GITHUB_URL = 'https://github.com/stablyai/orca' const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' const X_URL = 'https://x.com/orca_build' +const NO_UPDATE_CHECK_MODIFIERS = { ctrlKey: false, metaKey: false, shiftKey: false } function openExternalUrl(url: string): void { void window.api.shell.openUrl(url) @@ -91,13 +93,16 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const [showAdminOptions, setShowAdminOptions] = useState(false) const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) + const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) const mountedRef = useMountedRef() + const updateCheckHint = getUpdateCheckHint() const showMilestones = setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal const handleMenuOpenChange = (open: boolean): void => { setMenuOpen(open) + updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS if (!open) { setShowAdminOptions(false) } @@ -147,9 +152,18 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { openSettingsPage() } - const handleCheckForUpdates = (event: Event): void => { - const shiftKey = (event as PointerEvent).shiftKey - void window.api.updater.check({ includePrerelease: shiftKey }) + const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => { + updateCheckModifiersRef.current = { + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey + } + } + + const handleCheckForUpdates = (): void => { + const modifiers = updateCheckModifiersRef.current + updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS + void window.api.updater.check(getUpdateCheckClickOptions(modifiers)) } const openMilestones = (): void => { @@ -299,7 +313,9 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { {updateStatus.state === 'checking' ? ( diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts new file mode 100644 index 00000000000..02f66401301 --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options' + +function clickEvent(overrides: Partial>) { + return { + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides + } as Pick +} + +describe('getUpdateCheckClickOptions', () => { + it('uses Cmd on macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('uses Ctrl outside macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('keeps Shift as the RC prerelease modifier', () => { + expect( + getUpdateCheckClickOptions(clickEvent({ shiftKey: true, ctrlKey: true }), false) + ).toEqual({ + includePrerelease: true, + includePerfPrerelease: true + }) + }) + + it('formats the tooltip hint by platform', () => { + expect(getUpdateCheckHint(true)).toBe( + '⇧+click checks the latest RC; ⌘+click checks the latest perf build.' + ) + expect(getUpdateCheckHint(false)).toBe( + 'Shift+click checks the latest RC; Ctrl+click checks the latest perf build.' + ) + }) +}) diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts new file mode 100644 index 00000000000..2ee4191c34b --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -0,0 +1,24 @@ +import type { UpdateCheckOptions } from '../../../shared/types' +import { getShortcutPlatform } from './shortcut-platform' + +type UpdateCheckClickEvent = Pick + +function isMacShortcutPlatform(): boolean { + return getShortcutPlatform() === 'darwin' +} + +export function getUpdateCheckHint(isMac = isMacShortcutPlatform()): string { + const rcClickLabel = isMac ? '⇧+click' : 'Shift+click' + const perfClickLabel = isMac ? '⌘+click' : 'Ctrl+click' + return `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.` +} + +export function getUpdateCheckClickOptions( + event: UpdateCheckClickEvent, + isMac = isMacShortcutPlatform() +): UpdateCheckOptions { + return { + includePrerelease: event.shiftKey, + includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3592987167f..55ed3007608 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2190,6 +2190,11 @@ export type ChangelogData = { releasesBehind: number | null } +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean +} + export type UpdateStatus = | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } From 492bbe31b678a0062034cfa778e2b83c6d7ae472 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:45:05 +0000 Subject: [PATCH 124/141] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f5d3d95b9d0..abc2d902e9a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.5m + + downloads: 3.6m @@ -15,7 +15,7 @@ downloads downloads - 3.5m - 3.5m + 3.6m + 3.6m From e7d1fa4a21e2198a1dc9d70b07dbfff8cf83191b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:05:58 -0700 Subject: [PATCH 125/141] Disable auto-merge for unstable GitHub PRs (#7415) Prevent enabling auto-merge when a PR is in an UNSTABLE merge state. GitHub auto-merge mutations reject UNSTABLE PRs directly instead of allowing them to wait, so we should suppress the option. --- .../pr-sidebar/pr-auto-merge-availability.ts | 8 +++++++- .../src/components/github-pr-merge-state.test.ts | 12 ++++++++++++ src/shared/github-pr-auto-merge-availability.test.ts | 8 +++++++- src/shared/github-pr-auto-merge-availability.ts | 8 +++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts index d6040f87e2b..5453a0209b6 100644 --- a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts +++ b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts @@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean { return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY' } +function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean { + return item.mergeStateStatus === 'UNSTABLE' +} + function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean { return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED' } @@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean } function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean { - if (item.state !== 'open' || isConflicting(item)) { + // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected + // by the mutation rather than becoming a waitable auto-merge request. + if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) { return false } if (item.mergeQueueRequired === true) { diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index 9284f52729e..a2f52f8fd31 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -62,6 +62,18 @@ describe('presentGitHubPRMergeState', () => { }) }) + it('does not offer enable auto-merge for GitHub unstable PRs', () => { + expect( + presentGitHubPRMergeState( + pr({ + mergeable: 'UNKNOWN', + mergeStateStatus: 'UNSTABLE', + checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } + }) + ).autoMergeAction + ).toBeNull() + }) + it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => { expect( presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) diff --git a/src/shared/github-pr-auto-merge-availability.test.ts b/src/shared/github-pr-auto-merge-availability.test.ts index 47989de6b6d..2e22a3bd3f2 100644 --- a/src/shared/github-pr-auto-merge-availability.test.ts +++ b/src/shared/github-pr-auto-merge-availability.test.ts @@ -48,7 +48,7 @@ describe('github PR auto-merge availability', () => { expect(canShowGitHubPRAutoMergeControl(pr({ autoMergeEnabled: true }))).toBe(true) }) - it('suppresses closed, draft, disallowed, and conflicting PRs', () => { + it('suppresses closed, draft, disallowed, conflicting, and unstable PRs', () => { expect( canShowGitHubPRAutoMergeControl(pr({ state: 'draft', mergeStateStatus: 'BLOCKED' })) ).toBe(false) @@ -63,5 +63,11 @@ describe('github PR auto-merge availability', () => { expect( canShowGitHubPRAutoMergeControl(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) ).toBe(false) + expect( + canEnableGitHubPRAutoMerge(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) + expect( + canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) }) }) diff --git a/src/shared/github-pr-auto-merge-availability.ts b/src/shared/github-pr-auto-merge-availability.ts index 5c7417d622f..67b6e25ad72 100644 --- a/src/shared/github-pr-auto-merge-availability.ts +++ b/src/shared/github-pr-auto-merge-availability.ts @@ -18,6 +18,10 @@ function isConflicting(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY' } +function isUnstable(item: GitHubPRAutoMergeAvailabilityInput): boolean { + return item.mergeStateStatus === 'UNSTABLE' +} + function hasReviewRequirement(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED' } @@ -30,7 +34,9 @@ function canMergeImmediately(item: GitHubPRAutoMergeAvailabilityInput): boolean } function canRequestWhenReady(item: GitHubPRAutoMergeAvailabilityInput): boolean { - if (!isOpenPR(item) || isConflicting(item)) { + // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected + // by the mutation rather than becoming a waitable auto-merge request. + if (!isOpenPR(item) || isConflicting(item) || isUnstable(item)) { return false } if (item.mergeQueueRequired === true) { From e5008b1f3c3c5c623bbd6a68fe782151c9299f8d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:07:52 -0700 Subject: [PATCH 126/141] Clear PR comment selection on AI launch and defer mismatching clears (#7416) - Clear sent comments from selection queue when confirming AI resolution. - Keep selection clear requests pending until the matching review context is mounted, preventing them from being ignored on context changes. - Fix calculation of skipped threads when comment resolution is launched. --- .../components/right-sidebar/ChecksPanel.tsx | 29 ++++++++- .../right-sidebar/checks-panel-content.tsx | 9 ++- .../pr-comments-list-selection.test.tsx | 62 ++++++++++++++++++- .../pr-comments-list-selection.ts | 11 ++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 5ccc1b9f4ec..0682c1b0790 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -49,6 +49,10 @@ import { PRCommentsList, PRTriageStrip } from './checks-panel-content' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, @@ -435,6 +439,9 @@ export default function ChecksPanel(): React.JSX.Element { const [comments, setComments] = useState([]) const [commentsLoading, setCommentsLoading] = useState(false) const commentsRef = useRef([]) + const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] = + useState(null) + const commentsSelectionClearTokenRef = useRef(0) const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const refreshInFlightRef = useRef(false) @@ -2789,6 +2796,15 @@ export default function ChecksPanel(): React.JSX.Element { ] ) + const clearSentCommentSelection = useCallback((reviewContextKey: string): void => { + clearPRCommentsListSelection(reviewContextKey) + commentsSelectionClearTokenRef.current += 1 + setCommentsSelectionClearRequest({ + contextKey: reviewContextKey, + token: commentsSelectionClearTokenRef.current + }) + }, []) + const refreshCommentsAfterBulkResolve = useCallback( async (provider: ChecksPanelReview['provider']): Promise => { if (provider === 'gitlab') { @@ -2802,9 +2818,14 @@ export default function ChecksPanel(): React.JSX.Element { const resolveSelectedThreadsAfterLaunch = useCallback( async (resolution: NonNullable) => { + clearSentCommentSelection(resolution.reviewContextKey) let resolved = 0 - let skipped = 0 + let skipped = Math.max( + 0, + resolution.selectedGroups.length - resolution.selectedThreadIds.length + ) let failed = 0 + let attemptedThreadCount = 0 if (resolution.selectedThreadIds.length === 0) { toast.success( translate( @@ -2816,9 +2837,10 @@ export default function ChecksPanel(): React.JSX.Element { } for (const threadId of resolution.selectedThreadIds) { if (asyncResultKeyRef.current !== resolution.reviewContextKey) { - skipped += resolution.selectedThreadIds.length - resolved - skipped - failed + skipped += resolution.selectedThreadIds.length - attemptedThreadCount break } + attemptedThreadCount += 1 const currentGroup = groupPRComments(commentsRef.current).find( (group) => group.kind === 'thread' && group.threadId === threadId ) @@ -2856,7 +2878,7 @@ export default function ChecksPanel(): React.JSX.Element { ) ) }, - [handleResolve, refreshCommentsAfterBulkResolve] + [clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve] ) const handleFixChecksWithAI = useCallback(async (): Promise => { @@ -3780,6 +3802,7 @@ export default function ChecksPanel(): React.JSX.Element { commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} + selectionClearRequest={commentsSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 30a78f5391b..43ca52db9ea 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -94,7 +94,10 @@ import { RightPanelCommentComposer, type RightPanelCommentSubmitResult } from './right-panel-comment-composer' -import { usePRCommentsListSelection } from './pr-comments-list-selection' +import { + usePRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { translate } from '@/i18n/i18n' import { useActiveWorktree } from '@/store/selectors' import { useAppStore } from '@/store' @@ -2171,6 +2174,7 @@ export function PRCommentsList({ commentsDisabled, commentsDisabledReason, selectionContextKey, + selectionClearRequest, resolveCommentsWithAIDisabled, resolveCommentsWithAIDisabledReason, onAddComment, @@ -2186,6 +2190,7 @@ export function PRCommentsList({ commentsDisabled?: boolean commentsDisabledReason?: string selectionContextKey?: string + selectionClearRequest?: PRCommentsListSelectionClearRequest | null resolveCommentsWithAIDisabled?: boolean resolveCommentsWithAIDisabledReason?: string onAddComment?: (body: string) => Promise @@ -2212,7 +2217,7 @@ export function PRCommentsList({ addGroupToSelection, clearSelection, toggleGroupSelection - } = usePRCommentsListSelection(comments, selectionContextKey) + } = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest) const visibleComments = React.useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index 69d07fff0a2..61924494157 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -45,7 +45,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' -import { clearPRCommentsListSelection } from './pr-comments-list-selection' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' let container: HTMLDivElement @@ -79,7 +82,9 @@ function comment(overrides: Partial): PRComment { function renderList(props: { comments: PRComment[] + contextKey?: string onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void + clearRequest?: PRCommentsListSelectionClearRequest | null }): void { act(() => { root.render( @@ -87,7 +92,8 @@ function renderList(props: { @@ -297,6 +303,58 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('clears sent standalone bot comments from the queue when the parent confirms launch', () => { + const comments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments, + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + + it('keeps a clear request pending until its review context is mounted', () => { + const queuedComments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments: queuedComments, contextKey: 'review:42' }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments: [comment({ id: 2, body: 'Other review comment.' })], + contextKey: 'review:99', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + renderList({ + comments: queuedComments, + contextKey: 'review:42', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + it('exits selection mode when refresh leaves no eligible loaded threads', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 01bfeddf103..71b51ad5b3f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -65,7 +65,7 @@ export function usePRCommentsListSelection( selectionContextKey: string | undefined, clearRequest?: PRCommentsListSelectionClearRequest | null ): PRCommentsListSelection { - const lastClearRequestTokenRef = useRef(clearRequest?.token ?? null) + const lastClearRequestTokenRef = useRef(null) const [selectionState, setSelectionState] = useState(() => createSelectionState(selectionContextKey) ) @@ -77,13 +77,14 @@ export function usePRCommentsListSelection( }, [selectionContextKey]) useEffect(() => { - if (!clearRequest || clearRequest.token === lastClearRequestTokenRef.current) { + if ( + !clearRequest || + clearRequest.contextKey !== selectionContextKey || + clearRequest.token === lastClearRequestTokenRef.current + ) { return } lastClearRequestTokenRef.current = clearRequest.token - if (clearRequest.contextKey !== selectionContextKey) { - return - } const next = { contextKey: selectionContextKey, isSelectingForAI: false, From 4b2e4735d7b2a916b04d6d076eb3a708e6e90996 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:29:31 +0000 Subject: [PATCH 127/141] release: v1.4.124-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb6139b6668..9f5b0b3245c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.123-rc.1", + "version": "1.4.124-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 509c41e2bf51d2bab3d78be2e746e6bd9c1da6c1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:50:27 -0700 Subject: [PATCH 128/141] Relocate node-pty ConPTY runtime outside the Windows install dir (fixes update-time terminal loss) (#7421) --- config/patches/node-pty@1.1.0.patch | 24 +++ pnpm-lock.yaml | 6 +- src/main/index.ts | 4 + .../pty/node-pty-runtime-relocation.test.ts | 162 ++++++++++++++++++ src/main/pty/node-pty-runtime-relocation.ts | 137 +++++++++++++++ 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/main/pty/node-pty-runtime-relocation.test.ts create mode 100644 src/main/pty/node-pty-runtime-relocation.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index e100def9346..8d6f8b7d460 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -384,3 +384,27 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 } } #endif +diff --git a/lib/utils.js b/lib/utils.js +--- a/lib/utils.js ++++ b/lib/utils.js +@@ -19,7 +19,20 @@ function loadNativeModule(name) { + var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch]; + // Check relative to the parent dir for unbundled and then the current dir for bundled + var relative = ['..', '.']; + var lastError; ++ // Why (Orca): the Windows updater force-closes every process whose image ++ // lives under the app install dir; this env var relocates the native ++ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY ++ // console hosts run outside it and survive updates. ++ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR; ++ if (overrideDir) { ++ try { ++ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") }; ++ } ++ catch (e) { ++ lastError = e; ++ } ++ } + for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) { + var d = dirs_1[_i]; + for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12052fba2e0..d8aff1b4b9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ patchedDependencies: hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch node-pty@1.1.0: - hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 + hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88 path: config/patches/node-pty@1.1.0.patch importers: @@ -54,7 +54,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) + version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -11747,7 +11747,7 @@ snapshots: undici: 6.27.0 which: 6.0.1 - node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): + node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/index.ts b/src/main/index.ts index 902a01a8d06..a361df3e3ac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' import { killAllPty } from './ipc/pty' +import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation' import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' @@ -575,6 +576,9 @@ if (hasSingleInstanceLock) { initClaudeUsagePath() initCodexUsagePath() initOpenCodeUsagePath() + // Why: must run before anything loads node-pty (first PTY spawn) so main + // and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR. + installRelocatedNodePtyNativeRuntime() crashReports = CrashReportStore.fromUserData() recordCrashBreadcrumb('app_started', { packaged: app.isPackaged, diff --git a/src/main/pty/node-pty-runtime-relocation.test.ts b/src/main/pty/node-pty-runtime-relocation.test.ts new file mode 100644 index 00000000000..1dfbe033885 --- /dev/null +++ b/src/main/pty/node-pty-runtime-relocation.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => '/unused'), + getVersion: vi.fn(() => '0.0.0-test') + } +})) + +import { + ensureRelocatedNodePtyNativeRuntime, + resolveNodePtyNativeSourceDir +} from './node-pty-runtime-relocation' + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-')) +}) + +afterEach(() => { + // Why: the loader-override test dlopens conpty.node from tempDir, and a + // loaded native module stays image-locked until the process exits. + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} +}) + +function seedSourceDir(dir: string): void { + mkdirSync(join(dir, 'conpty'), { recursive: true }) + writeFileSync(join(dir, 'conpty.node'), 'binding') + writeFileSync(join(dir, 'conpty_console_list.node'), 'listing') + writeFileSync(join(dir, 'pty.node'), 'winpty-binding') + writeFileSync(join(dir, 'winpty-agent.exe'), 'agent') + writeFileSync(join(dir, 'conpty.pdb'), 'symbols') + writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll') + writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host') +} + +describe('resolveNodePtyNativeSourceDir', () => { + it('prefers the rebuilt build/Release binding over prebuilds', () => { + const pkg = join(tempDir, 'node-pty') + mkdirSync(join(pkg, 'build', 'Release'), { recursive: true }) + mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true }) + writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x') + writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x') + expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release')) + }) + + it('falls back to the platform prebuild dir', () => { + const pkg = join(tempDir, 'node-pty') + mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true }) + writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x') + expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`)) + }) + + it('returns null when no conpty binding exists', () => { + const pkg = join(tempDir, 'node-pty') + mkdirSync(join(pkg, 'build', 'Release'), { recursive: true }) + expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull() + }) +}) + +describe('ensureRelocatedNodePtyNativeRuntime', () => { + it('copies the runtime tree (without symbols) and returns the version dir', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + + const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + + expect(destDir).toBe(join(destRoot, '1.2.3')) + expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') + expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host') + expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll') + expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false) + }) + + it('skips recopying once the completion marker exists', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + + writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy') + const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + + expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') + }) + + it('redoes an interrupted copy that has no completion marker', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + mkdirSync(join(destRoot, '1.2.3'), { recursive: true }) + writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy') + + const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + + expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') + }) + + it('removes stale version dirs but keeps the current one', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' }) + + const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' }) + + expect(destDir).toBe(join(destRoot, '2.0.0')) + expect(existsSync(join(destRoot, '1.0.0'))).toBe(false) + expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true) + }) + + it('fails open when the source dir is missing', () => { + expect( + ensureRelocatedNodePtyNativeRuntime({ + sourceDir: join(tempDir, 'does-not-exist'), + destRoot: join(tempDir, 'dest'), + version: '1.2.3' + }) + ).toBeNull() + }) +}) + +// Loads the real win32 conpty binding, so it cannot run on other platforms. +describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => { + it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => { + const requireFromHere = createRequire(import.meta.url) + const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json')) + const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir) + expect(sourceDir).not.toBeNull() + + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir: sourceDir!, + destRoot: join(tempDir, 'relocated-runtime'), + version: 'loader-test' + }) + expect(destDir).not.toBeNull() + + const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR + process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir! + try { + const utils = requireFromHere('node-pty/lib/utils.js') + const loaded = utils.loadNativeModule('conpty') + expect(loaded.dir).toBe(destDir) + expect(typeof loaded.module.startProcess).toBe('function') + } finally { + if (previousNativeDir === undefined) { + delete process.env.ORCA_NODE_PTY_NATIVE_DIR + } else { + process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir + } + } + }) +}) diff --git a/src/main/pty/node-pty-runtime-relocation.ts b/src/main/pty/node-pty-runtime-relocation.ts new file mode 100644 index 00000000000..a3821dde5d4 --- /dev/null +++ b/src/main/pty/node-pty-runtime-relocation.ts @@ -0,0 +1,137 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { app } from 'electron' + +/** + * Relocates node-pty's Windows native runtime (conpty.node, conpty.dll, + * OpenConsole.exe, winpty binaries) from the install directory to userData. + * + * Why: the NSIS update installer force-closes every process whose image path + * is under the install directory before replacing files. conpty.dll spawns + * OpenConsole.exe from beside itself, so while these binaries live in the + * install dir every live terminal's console host is killed mid-update. + * Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override + * patched into node-pty's loader) takes them out of the installer's kill + * zone, and the detached daemon inherits the env var so its PTYs are covered. + */ +export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR' + +const RELOCATION_COMPLETE_MARKER = '.relocation-complete' + +export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null { + // Packaged builds carry the rebuilt binding in build/Release; dev installs + // (and the forced-relocation test path) load from prebuilds. + const candidates = [ + join(nodePtyPackageDir, 'build', 'Release'), + join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`) + ] + for (const candidate of candidates) { + if (existsSync(join(candidate, 'conpty.node'))) { + return candidate + } + } + return null +} + +function copyRuntimeTree(sourceDir: string, destDir: string): void { + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + const destPath = join(destDir, entry.name) + if (entry.isDirectory()) { + copyRuntimeTree(sourcePath, destPath) + } else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) { + copyFileSync(sourcePath, destPath) + } + } +} + +function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void { + let entries + try { + entries = readdirSync(destRoot, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === keepVersion) { + continue + } + // Why: an adopted daemon from a previous version may still run (or later + // respawn) binaries out of its own version dir. Renaming a directory + // fails on Windows while anything inside is open, so a successful rename + // proves the dir is unused and safe to delete. + const doomedPath = join(destRoot, `${entry.name}.stale`) + try { + renameSync(join(destRoot, entry.name), doomedPath) + rmSync(doomedPath, { recursive: true, force: true }) + } catch { + // Still in use (or already being cleaned) — retry on a future launch. + } + } +} + +export function ensureRelocatedNodePtyNativeRuntime(options: { + sourceDir: string + destRoot: string + version: string +}): string | null { + const { sourceDir, destRoot, version } = options + const destDir = join(destRoot, version) + try { + // Why: the marker is written only after a full copy, so a crash mid-copy + // leaves no marker and the next launch redoes the copy from scratch. + if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) { + rmSync(destDir, { recursive: true, force: true }) + copyRuntimeTree(sourceDir, destDir) + writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '') + } + removeStaleRuntimeVersions(destRoot, version) + return destDir + } catch { + // Fail open: node-pty keeps loading from the install dir, which is the + // pre-relocation behavior (sessions then don't survive updates). + return null + } +} + +export function installRelocatedNodePtyNativeRuntime(): void { + if (process.platform !== 'win32') { + return + } + if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') { + return + } + // Note: a pre-set env var is deliberately NOT honored here. The update + // relaunch chain (old app -> installer -> new app) inherits the previous + // version's value, which would pin the new process to stale binaries and + // skip copying the current version. + let nodePtyPackageDir: string + try { + const requireFromHere = createRequire(import.meta.url) + nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json')) + } catch { + return + } + const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir) + if (!sourceDir) { + return + } + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot: join(app.getPath('userData'), 'node-pty-runtime'), + version: app.getVersion() + }) + if (destDir) { + process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir + } +} From dbf1da0f44302bab373337bcc3bff8d3b258aa71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:51:20 +0000 Subject: [PATCH 129/141] release: v1.4.124-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f5b0b3245c..eb76d17279f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.0", + "version": "1.4.124-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 72a23809fa8d6a4e12b432e9bfe05beac53df635 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:39:38 -0700 Subject: [PATCH 130/141] Prevent OMP terminals from being mislabeled as Gemini (#7447) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 36 ++++++++++++++++++- src/renderer/src/lib/agent-status.test.ts | 27 ++++++++++++++ src/shared/agent-detection.ts | 27 ++++++++------ src/shared/agent-title-owner.ts | 6 ++-- src/shared/pi-compatible-synthetic-title.ts | 6 ++++ 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c8e90d3d6f2..ae4e0bbebd6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -247,9 +247,10 @@ function notifyStoreSubscribers(): void { vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal>() + const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean return { ...actual, - isGeminiTerminalTitle: vi.fn(() => false), + isGeminiTerminalTitle: vi.fn((title: string) => isGeminiTerminalTitle(title)), isClaudeAgent: vi.fn(() => false), detectAgentStatusFromTitle: vi.fn((title: string) => { if (/Claude (working|done)/.test(title)) { @@ -12612,6 +12613,39 @@ describe('connectPanePty', () => { ) }) + it('keeps GPU rendering enabled for OMP titles whose cwd is Gemini', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-omp-gemini-cwd') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + manager.setPaneGpuRendering.mockClear() + + titleHandler('\u280b Pi', '\u280b π: gemini') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledTimes(1) + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') + }) + it('leaves local IPC OSC 9999 status ownership in the main runtime', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-local') diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 0b6bdd30eee..5c5d5e96f69 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -397,6 +397,22 @@ describe('normalizeTerminalTitle', () => { it('collapses Pi spinner and idle titles to stable labels', () => { expect(normalizeTerminalTitle('⠋ π - my-project')).toBe('⠋ Pi') expect(normalizeTerminalTitle('π - my-project')).toBe('Pi') + expect(normalizeTerminalTitle('⠋ π: my-project')).toBe('⠋ Pi') + expect(normalizeTerminalTitle('π: my-project')).toBe('Pi') + expect(normalizeTerminalTitle('π -')).toBe('Pi') + expect(normalizeTerminalTitle('π:')).toBe('Pi') + expect(normalizeTerminalTitle('π ')).toBe('Pi') + }) + + it('does not collapse Pi-compatible titles whose cwd mentions Gemini', () => { + expect(normalizeTerminalTitle('⠋ π - gemini')).toBe('⠋ Pi') + expect(normalizeTerminalTitle('π - gemini')).toBe('Pi') + expect(normalizeTerminalTitle('⠋ π: gemini')).toBe('⠋ Pi') + expect(normalizeTerminalTitle('π: gemini')).toBe('Pi') + expect(normalizeTerminalTitle('⠋ π gemini')).toBe('⠋ Pi') + expect(normalizeTerminalTitle('π gemini')).toBe('Pi') + expect(normalizeTerminalTitle('⠋ π - gemini-project')).toBe('⠋ Pi') + expect(normalizeTerminalTitle('π - gemini-project')).toBe('Pi') }) }) @@ -409,6 +425,17 @@ describe('isGeminiTerminalTitle', () => { it('does not match other terminal titles', () => { expect(isGeminiTerminalTitle('⠂ Claude Code')).toBe(false) + expect(isGeminiTerminalTitle('⠋ π - gemini')).toBe(false) + expect(isGeminiTerminalTitle('π - gemini')).toBe(false) + expect(isGeminiTerminalTitle('⠋ π: gemini')).toBe(false) + expect(isGeminiTerminalTitle('π: gemini')).toBe(false) + expect(isGeminiTerminalTitle('⠋ π gemini')).toBe(false) + expect(isGeminiTerminalTitle('π gemini')).toBe(false) + expect(isGeminiTerminalTitle('π -')).toBe(false) + expect(isGeminiTerminalTitle('π:')).toBe(false) + expect(isGeminiTerminalTitle('π ')).toBe(false) + expect(isGeminiTerminalTitle('⠋ π - gemini-project')).toBe(false) + expect(isGeminiTerminalTitle('/tmp/gemini/working')).toBe(false) expect(isGeminiTerminalTitle('bash')).toBe(false) }) }) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 196d6eadb39..80f934b3c31 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -16,7 +16,8 @@ import { } from './agent-name-token-match' import { getPiCompatibleSyntheticAgentLabel, - getPiCompatibleSyntheticAgentStatus + getPiCompatibleSyntheticAgentStatus, + isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' // Re-export so existing `agent-detection` importers keep working. @@ -96,26 +97,30 @@ export const STRONG_WORKING_KEYWORDS_RE = new RegExp( // compounds like "is-working-cap" that `detectAgentStatusFromTitle` would // correctly refuse to classify as working. const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi') -const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format) - export function isGeminiTerminalTitle(title: string): boolean { - return ( + // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. + if ( title.includes(GEMINI_PERMISSION) || title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING) || - title.includes(GEMINI_IDLE) || - title.toLowerCase().includes('gemini') - ) + title.includes(GEMINI_IDLE) + ) { + return true + } + // Why: Pi/OMP titles include cwd/session text; substring matching made + // paths like "gemini-project" masquerade as Gemini CLI. + if (isPiAgentTitle(title)) { + return false + } + return titleHasAgentName(title, 'gemini') } export function isPiTerminalTitle(title: string): boolean { - return title.startsWith(PI_IDLE_PREFIX) + return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) } function isPiAgentTitle(title: string): boolean { - return ( - isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX)) - ) + return isLegacyPiCompatibleTitle(title) } function containsBrailleSpinner(title: string): boolean { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 4e1dfda55af..4de8c0cbdd7 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -5,13 +5,13 @@ import { SYNTHETIC_AGENT_TITLE_PROFILES, type SyntheticAgentTitleProfile } from './synthetic-agent-title' +import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' type TitleProfileMatch = { profile: SyntheticAgentTitleProfile } const COMPATIBLE_IDLE_TITLE_RE = /(? Date: Sun, 5 Jul 2026 15:22:38 -0700 Subject: [PATCH 131/141] Improve sidebar resize handle drag target and visual indicators (#7454) - Widen the resize handle hit target to 12px (straddling the edge) to make it easier to grab, centering a thin 1px visual guide line. - Highlight the divider line on hover and active states, and shade the handle background during active dragging. - Adjust the sidebar webkit scrollbar border to prevent a transparent gap from appearing next to the drag handle. - Update unit tests to match the new styling and sizing classes. --- src/renderer/src/assets/main.css | 4 +++- src/renderer/src/components/sidebar/index.tsx | 20 ++++++++++++++----- .../sidebar/sidebar-resize-handle.test.ts | 5 +++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index c26cd144a0f..6140ed22101 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -478,7 +478,9 @@ .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb { background-color: transparent; - border-width: 3px 3px 3px 0; + /* Why: no right inset — a 3px transparent right border left a visible strip + beside the sidebar resize seam that read like a gap in the drag handle. */ + border-width: 3px 0 3px 0; } .scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar, diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 00001b9d1a6..577eaa929ae 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -24,9 +24,12 @@ const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 -// Why: match the right sidebar's 4px resize target; a 1px seam is too hard to acquire. +// Why: straddle the sidebar/terminal seam so the divider sits on the border-l +// instead of leaving a blank strip between the hover target and the edge. export const WORKTREE_SIDEBAR_RESIZE_HANDLE_CLASS_NAME = - 'absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-ring/20 active:bg-ring/30' + 'group absolute -right-1.5 top-0 z-10 flex h-full w-3 cursor-col-resize items-stretch justify-center' +export const WORKTREE_SIDEBAR_RESIZE_HANDLE_LINE_CLASS_NAME = + 'h-full w-px bg-transparent transition-colors group-hover:bg-ring/50 group-active:bg-ring' type SidebarProps = { worktreeScrollOffsetRef: React.MutableRefObject @@ -116,7 +119,7 @@ function Sidebar({ } }, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen]) - const { containerRef, onResizeStart } = useSidebarResize({ + const { containerRef, onResizeStart, isResizing } = useSidebarResize({ isOpen: sidebarOpen, width: sidebarWidth, minWidth: MIN_WIDTH, @@ -184,9 +187,16 @@ function Sidebar({ {sidebarOpen && (
+ > +
+
)}
diff --git a/src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts b/src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts index 4d2b33e2fe9..9733f352da8 100644 --- a/src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts @@ -13,9 +13,10 @@ function getWorktreeSidebarScrollbarPaddingRight(): number { } describe('worktree sidebar resize handle', () => { - it('keeps the hover target as wide as the right sidebar handle', () => { + it('keeps a wide hit target that straddles the sidebar seam', () => { const classes = new Set(WORKTREE_SIDEBAR_RESIZE_HANDLE_CLASS_NAME.split(/\s+/)) - expect(classes.has('w-1')).toBe(true) + expect(classes.has('w-3')).toBe(true) + expect(classes.has('-right-1.5')).toBe(true) expect(classes.has('w-px')).toBe(false) }) From 8aecc0bf933961a510d8968a41710c443aa29c67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:23:11 -0700 Subject: [PATCH 132/141] Fix folder workspace PR status icon from branch cache (#7449) --- .../FolderWorkspacePrChecksRow.test.tsx | 2 + .../parent-pr-checks-github-pr-cache.ts | 90 +++++ .../parent-pr-checks-hosted-review-cache.ts | 83 ++++ .../parent-pr-checks-refresh.test.ts | 1 + .../right-sidebar/parent-pr-checks-refresh.ts | 2 + .../parent-pr-checks-row-types.ts | 3 + .../parent-pr-checks-rows.test.ts | 374 +++++++++++++++++- .../right-sidebar/parent-pr-checks-rows.ts | 76 ++-- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../folder-workspace-card-pr-display.test.ts | 106 ++++- .../folder-workspace-card-pr-display.ts | 187 ++------- 11 files changed, 728 insertions(+), 200 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts create mode 100644 src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx index 2cf19599bc3..de88bd8454d 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx @@ -95,9 +95,11 @@ function makeRow( group: 'passing', checkTone: 'success', title: 'Review title', + reviewNumber: 12, reviewLabel: '#12', reviewUrl: 'https://example.test/pr/12', reviewState: 'open', + reviewStatus: 'success', provider: 'github', summary: 'Checks passing', detailNames: [], diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts new file mode 100644 index 00000000000..8487c6619ef --- /dev/null +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts @@ -0,0 +1,90 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import { isCachedMergedBranchPRCurrentForWorktree } from '@/components/sidebar/worktree-card-pr-display' +import type { AppState } from '@/store/types' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import type { ParentPrChecksCacheEntry } from './parent-pr-checks-row-types' + +export function canUseParentPrChecksGitHubPRCacheEntry( + worktree: Worktree, + prEntry: ParentPrChecksCacheEntry | undefined, + hostedReviewEntry: ParentPrChecksCacheEntry | undefined +): prEntry is ParentPrChecksCacheEntry & { + data: NonNullable['data']> +} { + const pr = prEntry?.data + if (!pr) { + return false + } + const prFetchedAt = prEntry.fetchedAt + const hasLinkedGitHubPR = worktree.linkedPR !== null + if (hasLinkedGitHubPR && pr.number !== worktree.linkedPR) { + return false + } + if (!hasLinkedGitHubPR && hasNonGitHubLinkedReview(worktree)) { + return false + } + const mergedPrMatchesCurrentHead = isCachedMergedBranchPRCurrentForWorktree(pr, worktree) + if (pr.state === 'merged' && !mergedPrMatchesCurrentHead) { + return false + } + // Why: a newer hosted-review miss should suppress older branch PR cache unless + // a merged PR is proven to still describe the checked-out worktree head. + if ( + hostedReviewEntry?.data === null && + !mergedPrMatchesCurrentHead && + prFetchedAt <= hostedReviewEntry.fetchedAt + ) { + return false + } + return true +} + +export function getParentPrChecksGitHubPRCacheEntry({ + prCache, + repo, + branch, + settings +}: { + prCache: Record> + repo: Repo + branch: string + settings: AppState['settings'] +}): ParentPrChecksCacheEntry | undefined { + const currentKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId, + true + ) + const executionHostId = normalizeExecutionHostId(repo.executionHostId) + const canUseLegacyPRCache = + !repo.connectionId && (!executionHostId || executionHostId === LOCAL_EXECUTION_HOST_ID) + const legacyRepoKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) + : '' + const legacyPathKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) + : '' + return ( + prCache[currentKey] ?? + (legacyRepoKey ? prCache[legacyRepoKey] : undefined) ?? + (legacyPathKey ? prCache[legacyPathKey] : undefined) + ) +} + +function hasNonGitHubLinkedReview(worktree: Worktree): boolean { + return ( + worktree.linkedGitLabMR != null || + worktree.linkedBitbucketPR != null || + worktree.linkedAzureDevOpsPR != null || + worktree.linkedGiteaPR != null + ) +} diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts new file mode 100644 index 00000000000..e6db09a2736 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-hosted-review-cache.ts @@ -0,0 +1,83 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Worktree } from '../../../../shared/types' +import { + getWorktreeCardPrDisplay, + isCachedMergedBranchPRCurrentForWorktree +} from '@/components/sidebar/worktree-card-pr-display' +import type { ParentPrChecksCacheEntry } from './parent-pr-checks-row-types' + +export function canUseParentPrChecksHostedReviewCacheEntry( + worktree: Worktree, + review: HostedReviewInfo, + entry: ParentPrChecksCacheEntry +): boolean { + if (review.state === 'merged' && !mergedReviewMatchesHead(review, worktree)) { + return false + } + const linkedReviewNumber = getLinkedReviewNumberForProvider(worktree, review.provider) + if (hasLinkedReview(worktree)) { + return linkedReviewNumber === review.number + } + if ((entry.linkedReviewHintKey ?? '') !== '') { + return false + } + const display = getWorktreeCardPrDisplay( + review, + worktree.linkedPR, + worktree.linkedGitLabMR ?? null, + worktree.linkedBitbucketPR ?? null, + worktree.linkedAzureDevOpsPR ?? null, + worktree.linkedGiteaPR ?? null, + { reviewHintKey: entry.linkedReviewHintKey } + ) + return display?.provider === review.provider && display.number === review.number +} + +function mergedReviewMatchesHead(review: HostedReviewInfo, worktree: Worktree): boolean { + return isCachedMergedBranchPRCurrentForWorktree( + { + number: review.number, + title: review.title, + state: review.state, + url: review.url, + checksStatus: review.status, + updatedAt: review.updatedAt, + mergeable: review.mergeable, + ...(review.headSha ? { headSha: review.headSha } : {}), + ...(review.confirmedContainedHeadOid + ? { confirmedContainedHeadOid: review.confirmedContainedHeadOid } + : {}) + } satisfies PRInfo, + worktree + ) +} + +function getLinkedReviewNumberForProvider( + worktree: Worktree, + provider: HostedReviewInfo['provider'] +): number | null { + switch (provider) { + case 'github': + return worktree.linkedPR + case 'gitlab': + return worktree.linkedGitLabMR ?? null + case 'bitbucket': + return worktree.linkedBitbucketPR ?? null + case 'azure-devops': + return worktree.linkedAzureDevOpsPR ?? null + case 'gitea': + return worktree.linkedGiteaPR ?? null + case 'unsupported': + return null + } +} + +function hasLinkedReview(worktree: Worktree): boolean { + return ( + worktree.linkedPR != null || + worktree.linkedGitLabMR != null || + worktree.linkedBitbucketPR != null || + worktree.linkedAzureDevOpsPR != null || + worktree.linkedGiteaPR != null + ) +} diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts index 28730809afa..2b9e5dcbe33 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts @@ -147,6 +147,7 @@ describe('parent PR checks refresh', () => { linkedBitbucketPR: 10, linkedAzureDevOpsPR: 11, linkedGiteaPR: 12, + currentHeadOid: 'abc', staleWhileRevalidate: true } ]) diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts index e09caf6a448..545f2b6fec2 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts @@ -24,6 +24,7 @@ type FetchHostedReview = ( linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null + currentHeadOid?: string | null } ) => Promise @@ -134,6 +135,7 @@ async function refreshParentPrChecksCandidate( linkedBitbucketPR: candidate.worktree.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: candidate.worktree.linkedAzureDevOpsPR ?? null, linkedGiteaPR: candidate.worktree.linkedGiteaPR ?? null, + currentHeadOid: candidate.worktree.head ?? null, staleWhileRevalidate: true }) if (!review) { diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts index 8aa9e721502..9e6f3c2f784 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-row-types.ts @@ -7,6 +7,7 @@ export type ParentPrChecksCacheEntry = { data: T | null fetchedAt: number headSha?: string + linkedReviewHintKey?: string } export type ParentPrChecksRefreshOutcome = @@ -52,9 +53,11 @@ export type ParentPrChecksRow = { group: ParentPrChecksGroupKey checkTone: CheckStatus title: string + reviewNumber: number | null reviewLabel: string | null reviewUrl: string | null reviewState: HostedReviewInfo['state'] | null + reviewStatus: HostedReviewInfo['status'] | null provider: HostedReviewInfo['provider'] | null summary: string detailNames: string[] diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index 5471b89a593..d261df5851e 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import type { PRCheckDetail, PRInfo, Repo, Worktree } from '../../../../shared/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' -import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { + getGitHubPRCacheKey, + getGitHubRepoCacheKey, + getLegacyGitHubPRCacheKey +} from '@/store/slices/github-cache-key' import { prChecksCacheSuffix } from '@/store/slices/github' import { buildParentPrChecksProjection, @@ -62,6 +66,19 @@ function makeReview(overrides: Partial = {}): HostedReviewInfo } } +function makePRInfo(overrides: Partial = {}): PRInfo { + return { + number: 42, + title: 'Branch PR', + state: 'open', + url: 'https://example.test/pull/42', + checksStatus: 'success', + updatedAt: '2026-01-01T00:00:00.000Z', + mergeable: 'MERGEABLE', + ...overrides + } +} + function makeProjection({ worktree = makeWorktree({ id: 'repo-1::/feature' }), repo = makeRepo(), @@ -72,7 +89,10 @@ function makeProjection({ }: { worktree?: Worktree repo?: Repo - hostedReviewCache?: Record + hostedReviewCache?: Record< + string, + { data: HostedReviewInfo | null; fetchedAt: number; linkedReviewHintKey?: string } + > prCache?: Record checksCache?: Record< string, @@ -102,7 +122,11 @@ describe('buildParentPrChecksProjection', () => { worktree, repo, hostedReviewCache: { - [cacheKey]: { data: makeReview({ status: 'failure' }), fetchedAt: 1 } + [cacheKey]: { + data: makeReview({ status: 'failure' }), + fetchedAt: 1, + linkedReviewHintKey: '' + } } }).rows[0] ).toMatchObject({ @@ -116,7 +140,11 @@ describe('buildParentPrChecksProjection', () => { worktree, repo, hostedReviewCache: { - [cacheKey]: { data: makeReview({ status: 'pending' }), fetchedAt: 1 } + [cacheKey]: { + data: makeReview({ status: 'pending' }), + fetchedAt: 1, + linkedReviewHintKey: '' + } } }).rows[0] ).toMatchObject({ status: 'pending', group: 'pending' }) @@ -126,7 +154,11 @@ describe('buildParentPrChecksProjection', () => { worktree, repo, hostedReviewCache: { - [cacheKey]: { data: makeReview({ state: 'merged' }), fetchedAt: 1 } + [cacheKey]: { + data: makeReview({ state: 'merged', headSha: 'abc' }), + fetchedAt: 1, + linkedReviewHintKey: '' + } } }).rows[0] ).toMatchObject({ status: 'merged', group: 'merged' }) @@ -138,7 +170,8 @@ describe('buildParentPrChecksProjection', () => { hostedReviewCache: { [cacheKey]: { data: makeReview({ mergeable: 'CONFLICTING', status: 'success' }), - fetchedAt: 1 + fetchedAt: 1, + linkedReviewHintKey: '' } } }).rows[0] @@ -168,6 +201,325 @@ describe('buildParentPrChecksProjection', () => { expect(provenNoReview.summary.noPr).toBe(1) }) + it('uses branch-discovered PR cache for unlinked worktrees', () => { + const repo = makeRepo() + const worktree = makeWorktree({ id: 'repo-1::/feature', linkedPR: null }) + const cacheKey = getGitHubPRCacheKey(repo.path, repo.id, 'feature', settings) + + expect( + makeProjection({ + worktree, + repo, + prCache: { + [cacheKey]: { data: makePRInfo({ number: 99 }), fetchedAt: 2 } + } + }).rows[0] + ).toMatchObject({ + status: 'success', + group: 'passing', + reviewNumber: 99, + reviewLabel: '#99' + }) + }) + + it('uses legacy path-scoped PR cache for local persisted entries', () => { + const repo = makeRepo() + const worktree = makeWorktree({ id: 'repo-1::/feature', linkedPR: 99 }) + const cacheKey = getLegacyGitHubPRCacheKey(repo.path, undefined, 'feature') + + expect( + makeProjection({ + worktree, + repo, + prCache: { + [cacheKey]: { data: makePRInfo({ number: 99, checksStatus: 'success' }), fetchedAt: 2 } + } + }).rows[0] + ).toMatchObject({ + status: 'success', + reviewNumber: 99, + reviewLabel: '#99' + }) + }) + + it('uses legacy path-scoped PR cache for explicit local repos', () => { + const repo = makeRepo({ executionHostId: 'local' }) + const worktree = makeWorktree({ id: 'repo-1::/feature', linkedPR: 99 }) + const cacheKey = getLegacyGitHubPRCacheKey(repo.path, undefined, 'feature') + + expect( + makeProjection({ + worktree, + repo, + prCache: { + [cacheKey]: { data: makePRInfo({ number: 99, checksStatus: 'success' }), fetchedAt: 2 } + } + }).rows[0] + ).toMatchObject({ + status: 'success', + reviewNumber: 99, + reviewLabel: '#99' + }) + }) + + it('does not use stale merged branch PR cache after the worktree advances', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: null, + head: 'new-head' + }) + const cacheKey = getGitHubPRCacheKey(repo.path, repo.id, 'feature', settings) + + expect( + makeProjection({ + worktree, + repo, + prCache: { + [cacheKey]: { + data: makePRInfo({ + number: 99, + state: 'merged', + headSha: 'merged-head' + }), + fetchedAt: 2 + } + } + }).rows[0] + ).toMatchObject({ + status: 'notFetched', + reviewLabel: null + }) + }) + + it('does not use stale merged linked PR cache after the worktree advances', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: 99, + head: 'new-head' + }) + const cacheKey = getGitHubPRCacheKey(repo.path, repo.id, 'feature', settings) + + expect( + makeProjection({ + worktree, + repo, + prCache: { + [cacheKey]: { + data: makePRInfo({ + number: 99, + title: 'Stale merged linked PR', + state: 'merged', + headSha: 'merged-head' + }), + fetchedAt: 2 + } + } + }).rows[0] + ).toMatchObject({ + status: 'linkedDetailsUnavailable', + reviewNumber: 99, + reviewLabel: '#99', + title: 'Loading PR...' + }) + }) + + it('does not let older linked PR cache override a newer hosted-review miss', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: 99 + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + const prKey = getGitHubPRCacheKey(repo.path, repo.id, 'feature', settings) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { data: null, fetchedAt: 3 } + }, + prCache: { + [prKey]: { + data: makePRInfo({ + number: 99, + title: 'Older linked PR cache' + }), + fetchedAt: 2 + } + } + }).rows[0] + ).toMatchObject({ + status: 'linkedDetailsUnavailable', + reviewNumber: 99, + reviewLabel: '#99', + title: 'PR details unavailable' + }) + }) + + it('does not let mismatched hosted-review cache override linked PR metadata', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: 99 + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { + data: makeReview({ + number: 12, + title: 'Stale branch review', + status: 'failure' + }), + fetchedAt: 2, + linkedReviewHintKey: 'github:12' + } + } + }).rows[0] + ).toMatchObject({ + status: 'linkedDetailsUnavailable', + reviewNumber: 99, + reviewLabel: '#99', + title: 'Loading PR...' + }) + }) + + it('does not use stale merged hosted-review cache after the worktree advances', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: null, + head: 'new-head' + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { + data: makeReview({ + number: 99, + title: 'Stale merged hosted review', + state: 'merged', + headSha: 'merged-head' + }), + fetchedAt: 2, + linkedReviewHintKey: '' + } + } + }).rows[0] + ).toMatchObject({ + status: 'notFetched', + reviewLabel: null + }) + }) + + it('does not use stale merged non-GitHub hosted-review cache after the worktree advances', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedBitbucketPR: null, + head: 'new-head' + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { + data: makeReview({ + provider: 'bitbucket', + number: 77, + title: 'Stale merged Bitbucket PR', + state: 'merged', + headSha: 'merged-head' + }), + fetchedAt: 2, + linkedReviewHintKey: '' + } + } + }).rows[0] + ).toMatchObject({ + status: 'notFetched', + reviewLabel: null + }) + }) + + it('does not let same-number hosted-review cache from another provider override linked PR metadata', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedPR: 99 + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { + data: makeReview({ + provider: 'gitlab', + number: 99, + title: 'Wrong provider same number', + status: 'failure' + }), + fetchedAt: 2, + linkedReviewHintKey: '' + } + } + }).rows[0] + ).toMatchObject({ + status: 'linkedDetailsUnavailable', + provider: 'github', + reviewNumber: 99, + reviewLabel: '#99', + title: 'Loading PR...' + }) + }) + + it('does not use linked-hint hosted-review cache after a non-GitHub link is removed', () => { + const repo = makeRepo() + const worktree = makeWorktree({ + id: 'repo-1::/feature', + linkedBitbucketPR: null + }) + const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id) + + expect( + makeProjection({ + worktree, + repo, + hostedReviewCache: { + [hostedKey]: { + data: makeReview({ + provider: 'bitbucket', + number: 77, + title: 'Removed linked Bitbucket PR' + }), + fetchedAt: 2, + linkedReviewHintKey: 'bitbucket:77' + } + } + }).rows[0] + ).toMatchObject({ + status: 'notFetched', + reviewLabel: null + }) + }) + it('classifies completed unavailable refreshes as unavailable instead of not fetched', () => { const repo = makeRepo() const worktree = makeWorktree({ id: 'repo-1::/feature' }) @@ -218,7 +570,11 @@ describe('buildParentPrChecksProjection', () => { worktree, repo, hostedReviewCache: { - [cacheKey]: { data: makeReview({ status: 'success' }), fetchedAt: 1 } + [cacheKey]: { + data: makeReview({ status: 'success' }), + fetchedAt: 1, + linkedReviewHintKey: '' + } }, refreshOutcomes: new Map([[identity, { kind: 'error' }]]) }) @@ -253,7 +609,7 @@ describe('buildParentPrChecksProjection', () => { const projection = makeProjection({ worktree, repo, - hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: 1 } }, + hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: 1, linkedReviewHintKey: '' } }, checksCache: { [checksKey]: { data: [ @@ -289,7 +645,7 @@ describe('buildParentPrChecksProjection', () => { const projection = makeProjection({ worktree, repo, - hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: 1 } }, + hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: 1, linkedReviewHintKey: '' } }, checksCache: { [checksKey]: { data: [ diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index be8f3bdb2c1..6330c3bbee9 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -4,7 +4,7 @@ import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-revi import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr-display' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' import { prChecksCacheSuffix } from '@/store/slices/github' import { @@ -23,6 +23,13 @@ import { getRowSummary, groupForRowStatus } from './parent-pr-checks-row-status' +import { + canUseParentPrChecksGitHubPRCacheEntry, + getParentPrChecksGitHubPRCacheEntry +} from './parent-pr-checks-github-pr-cache' +import { canUseParentPrChecksHostedReviewCacheEntry } from './parent-pr-checks-hosted-review-cache' + +type ParentPrChecksRowSourceArgs = Omit export type { ParentPrChecksGroupKey, @@ -37,13 +44,10 @@ export function buildParentPrChecksProjection( args: BuildParentPrChecksRowsArgs ): ParentPrChecksProjection { const repoById = new Map(args.repos.map((repo) => [repo.id, repo])) - const rows = args.worktrees.map((worktree) => - buildParentPrChecksRow({ - ...args, - worktree, - repo: repoById.get(worktree.repoId) ?? null - }) - ) + const rows = buildParentPrChecksRows({ + ...args, + repoById + }) const groups = PARENT_PR_CHECKS_GROUP_ORDER.map((key) => ({ key, label: PARENT_PR_CHECKS_GROUP_LABELS[key], @@ -52,6 +56,18 @@ export function buildParentPrChecksProjection( return { rows, groups, summary: summarizeParentPrChecksRows(rows) } } +export function buildParentPrChecksRows( + args: ParentPrChecksRowSourceArgs & { repoById: ReadonlyMap } +): ParentPrChecksRow[] { + return args.worktrees.map((worktree) => + buildParentPrChecksRow({ + ...args, + worktree, + repo: args.repoById.get(worktree.repoId) ?? null + }) + ) +} + export function summarizeParentPrChecksRows( rows: readonly ParentPrChecksRow[] ): ParentPrChecksSummary { @@ -90,7 +106,7 @@ export function getParentPrChecksRefreshIdentity( } function buildParentPrChecksRow( - args: BuildParentPrChecksRowsArgs & { worktree: Worktree; repo: Repo | null } + args: ParentPrChecksRowSourceArgs & { worktree: Worktree; repo: Repo | null } ): ParentPrChecksRow { const branch = getBranchName(args.worktree) const refreshIdentity = getParentPrChecksRefreshIdentity(args.worktree, args.repo, branch) @@ -125,9 +141,11 @@ function buildParentPrChecksRow( group: groupForRowStatus(status), checkTone: getRowCheckTone(status, review), title: getRowTitle(args.worktree, branch, review, fallbackDisplay?.title), + reviewNumber: review?.number ?? fallbackDisplay?.number ?? null, reviewLabel: getReviewLabel(review, fallbackDisplay), reviewUrl: review?.url ?? fallbackDisplay?.url ?? null, reviewState: review?.state ?? fallbackDisplay?.state ?? null, + reviewStatus: review?.status ?? fallbackDisplay?.status ?? null, provider: review?.provider ?? fallbackDisplay?.provider ?? null, summary: getRowSummary(status, review, detailNames), detailNames, @@ -138,7 +156,7 @@ function buildParentPrChecksRow( } function getReviewSnapshot( - args: BuildParentPrChecksRowsArgs & { worktree: Worktree; repo: Repo | null }, + args: ParentPrChecksRowSourceArgs & { worktree: Worktree; repo: Repo | null }, branch: string | null, outcome: ParentPrChecksRefreshOutcome | undefined ): { review: HostedReviewInfo | null | undefined; hasCacheEntry: boolean } { @@ -150,18 +168,30 @@ function getReviewSnapshot( } const scopedArgs = { ...args, repo: args.repo } const hostedReviewEntry = args.hostedReviewCache[getHostedReviewKey(scopedArgs, branch)] - if (hostedReviewEntry?.data) { + if ( + hostedReviewEntry?.data && + canUseParentPrChecksHostedReviewCacheEntry( + args.worktree, + hostedReviewEntry.data, + hostedReviewEntry + ) + ) { return { review: hostedReviewEntry.data, hasCacheEntry: true } } - const prEntry = args.prCache[getPRKey(scopedArgs, branch)] - if (prEntry?.data) { + const prEntry = getParentPrChecksGitHubPRCacheEntry({ + prCache: args.prCache, + repo: args.repo, + branch, + settings: args.settings + }) + if (canUseParentPrChecksGitHubPRCacheEntry(args.worktree, prEntry, hostedReviewEntry)) { return { review: hostedReviewInfoFromGitHubPRInfo(prEntry.data), hasCacheEntry: true } } return { - review: hostedReviewEntry?.data, + review: hostedReviewEntry?.data === null ? null : undefined, hasCacheEntry: hostedReviewEntry !== undefined } } @@ -188,7 +218,7 @@ function getReviewLabel( } function getCheckDetails( - args: BuildParentPrChecksRowsArgs & { repo: Repo | null }, + args: ParentPrChecksRowSourceArgs & { repo: Repo | null }, review: HostedReviewInfo | null | undefined, branch: string | null ): PRCheckDetail[] { @@ -218,7 +248,7 @@ function getCheckDetailNames(checks: readonly PRCheckDetail[]): string[] { } function getGitHubChecksEntry( - args: BuildParentPrChecksRowsArgs & { repo: Repo }, + args: ParentPrChecksRowSourceArgs & { repo: Repo }, review: HostedReviewInfo ): ParentPrChecksCacheEntry | undefined { const prRepo = null @@ -244,7 +274,7 @@ function getGitHubChecksEntry( } function getHostedReviewKey( - args: BuildParentPrChecksRowsArgs & { repo: Repo }, + args: ParentPrChecksRowSourceArgs & { repo: Repo }, branch: string ): string { return getHostedReviewCacheKey( @@ -258,18 +288,6 @@ function getHostedReviewKey( ) } -function getPRKey(args: BuildParentPrChecksRowsArgs & { repo: Repo }, branch: string): string { - return getGitHubPRCacheKey( - args.repo.path, - args.repo.id, - branch, - args.settings, - args.repo.connectionId, - args.repo.executionHostId, - true - ) -} - function getBranchName(worktree: Worktree): string | null { const identity = getWorktreeGitIdentityDisplay(worktree) return identity?.kind === 'branch' ? identity.branchName : null diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index dbf77ecf0f9..adcb4bd43fa 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -675,8 +675,8 @@ type VirtualizedWorktreeViewportProps = { allRepoIds: string[] onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void onHostDragActiveChange: (active: boolean) => void - prCache: Record | null - hostedReviewCache: Record | null + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts index 6ca326b3c61..0b823f62c8c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { CheckStatus, + PRInfo, Repo, Worktree, WorktreeLineage, @@ -17,6 +18,12 @@ const repo: Repo = { addedAt: 1 } +class LookupOnlyRepoMap extends Map { + override values(): MapIterator { + throw new Error('Folder card PR display should use repo lookup instead of repo enumeration') + } +} + function makeWorktree(overrides: Partial & { id: string }): Worktree { const { id, ...rest } = overrides return { @@ -68,6 +75,24 @@ function makeWorktreeLineage(child: Worktree, parent: Worktree): WorktreeLineage } describe('getFolderWorkspaceCardPrDisplay', () => { + it('uses the existing repo lookup without enumerating all repos', () => { + const worktree = makeWorktree({ id: 'lookup-only', linkedPR: 8 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [worktree.id]: makeWorkspaceLineage(worktree) }, + worktreeLineageById: {}, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new LookupOnlyRepoMap([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::lookup-only': makePrEntry(8, 'success') + } + }) + + expect(display).toMatchObject({ number: 8, status: 'success' }) + }) + it('uses failing attached PR status ahead of pending and passing PRs', () => { const passing = makeWorktree({ id: 'passing', linkedPR: 1 }) const pending = makeWorktree({ id: 'pending', linkedPR: 2 }) @@ -89,9 +114,9 @@ describe('getFolderWorkspaceCardPrDisplay', () => { repoMap: new Map([[repo.id, repo]]), hostedReviewCache: null, prCache: { - 'repo-1::passing': { data: makePr(1, 'success') }, - 'repo-1::pending': { data: makePr(2, 'pending') }, - 'repo-1::failing': { data: makePr(3, 'failure') } + 'repo-1::passing': makePrEntry(1, 'success'), + 'repo-1::pending': makePrEntry(2, 'pending'), + 'repo-1::failing': makePrEntry(3, 'failure') } }) @@ -116,8 +141,8 @@ describe('getFolderWorkspaceCardPrDisplay', () => { repoMap: new Map([[repo.id, repo]]), hostedReviewCache: null, prCache: { - 'repo-1::passing': { data: makePr(1, 'success') }, - 'repo-1::pending': { data: makePr(2, 'pending') } + 'repo-1::passing': makePrEntry(1, 'success'), + 'repo-1::pending': makePrEntry(2, 'pending') } }) @@ -139,15 +164,75 @@ describe('getFolderWorkspaceCardPrDisplay', () => { repoMap: new Map([[repo.id, repo]]), hostedReviewCache: null, prCache: { - 'repo-1::nested': { data: makePr(4, 'success') } + 'repo-1::nested': makePrEntry(4, 'success') } }) expect(display).toMatchObject({ number: 4, status: 'success' }) }) + + it('uses branch-discovered PR cache for unlinked attached worktrees', () => { + const worktree = makeWorktree({ id: 'branch-discovered', linkedPR: null }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [worktree.id]: makeWorkspaceLineage(worktree) }, + worktreeLineageById: {}, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::branch-discovered': { data: makePr(9, 'success'), fetchedAt: 2 } + } + }) + + expect(display).toMatchObject({ number: 9, status: 'success' }) + }) + + it('uses the right-sidebar classified status for conflicting PRs', () => { + const worktree = makeWorktree({ id: 'conflicting-pr', linkedPR: null }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [worktree.id]: makeWorkspaceLineage(worktree) }, + worktreeLineageById: {}, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::conflicting-pr': { + data: { ...makePr(11, 'success'), mergeable: 'CONFLICTING' }, + fetchedAt: 2 + } + } + }) + + expect(display).toMatchObject({ number: 11, status: 'failure' }) + }) + + it('does not use stale merged branch PR cache after the worktree advances', () => { + const worktree = makeWorktree({ id: 'advanced-after-merge', linkedPR: null, head: 'new-head' }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [worktree.id]: makeWorkspaceLineage(worktree) }, + worktreeLineageById: {}, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::advanced-after-merge': { + data: { ...makePr(10, 'success'), state: 'merged', headSha: 'merged-head' }, + fetchedAt: 2 + } + } + }) + + expect(display).toBeNull() + }) }) -function makePr(number: number, checksStatus: CheckStatus) { +function makePr(number: number, checksStatus: CheckStatus): PRInfo { return { number, title: `PR ${number}`, @@ -158,3 +243,10 @@ function makePr(number: number, checksStatus: CheckStatus) { mergeable: 'UNKNOWN' } } + +function makePrEntry( + number: number, + checksStatus: CheckStatus +): { data: PRInfo; fetchedAt: number } { + return { data: makePr(number, checksStatus), fetchedAt: 2 } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index fb43b506dcd..bbd67117685 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -1,26 +1,11 @@ import type { AppState } from '@/store/types' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { branchName } from '@/lib/git-utils' -import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { - PRInfo, - Repo, - Worktree, - WorktreeLineage, - WorkspaceLineage -} from '../../../../shared/types' +import type { Repo, Worktree, WorktreeLineage, WorkspaceLineage } from '../../../../shared/types' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { getWorktreeCardPrDisplay, type WorktreeCardPrDisplay } from './worktree-card-pr-display' - -type HostedReviewCacheEntry = { - data?: HostedReviewInfo | null - linkedReviewHintKey?: string -} - -type PrCacheEntry = { - data?: PRInfo | null -} +import { + buildParentPrChecksRows, + type ParentPrChecksRow +} from '@/components/right-sidebar/parent-pr-checks-rows' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' type FolderWorkspaceCardPrDisplayArgs = { folderWorkspaceId: string @@ -28,8 +13,8 @@ type FolderWorkspaceCardPrDisplayArgs = { worktreeLineageById: Record | null | undefined worktreeMap: ReadonlyMap repoMap: ReadonlyMap - hostedReviewCache: Record | null - prCache: Record | null + hostedReviewCache: AppState['hostedReviewCache'] | null + prCache: AppState['prCache'] | null settings?: AppState['settings'] } @@ -50,21 +35,23 @@ export function getFolderWorkspaceCardPrDisplay({ prCache, settings }: FolderWorkspaceCardPrDisplayArgs): WorktreeCardPrDisplay | null { - const reviews = getAttachedWorktreesForFolderWorkspaceCard({ + const attachedWorktrees = getAttachedWorktreesForFolderWorkspaceCard({ folderWorkspaceId, workspaceLineageByChildKey, worktreeLineageById, worktreeMap }) - .map((worktree) => - getAttachedWorktreePrDisplay({ - worktree, - repo: repoMap.get(worktree.repoId), - hostedReviewCache, - prCache, - settings - }) - ) + + const reviews = buildParentPrChecksRows({ + worktrees: attachedWorktrees, + repoById: repoMap, + settings: settings ?? null, + hostedReviewCache: hostedReviewCache ?? {}, + prCache: prCache ?? {}, + // Folder cards only need the compact status icon; avoid check-detail cache fanout. + checksCache: {} + }) + .map(parentPrChecksRowToCardDisplay) .filter((review): review is WorktreeCardPrDisplay => review !== null) if (reviews.length === 0) { @@ -111,6 +98,20 @@ function getAttachedWorktreesForFolderWorkspaceCard({ return [...included.values()] } +function parentPrChecksRowToCardDisplay(row: ParentPrChecksRow): WorktreeCardPrDisplay | null { + if (!row.provider || row.provider === 'unsupported' || row.reviewNumber === null) { + return null + } + return { + provider: row.provider, + number: row.reviewNumber, + title: row.title, + ...(row.reviewState ? { state: row.reviewState } : {}), + ...(row.reviewUrl ? { url: row.reviewUrl } : {}), + status: row.checkTone + } +} + function getWorkspaceLineageChild( lineage: WorkspaceLineage, worktreeMap: ReadonlyMap @@ -144,126 +145,6 @@ function isCurrentLineagePair( ) } -function getAttachedWorktreePrDisplay({ - worktree, - repo, - hostedReviewCache, - prCache, - settings -}: { - worktree: Worktree - repo: Repo | undefined - hostedReviewCache: Record | null - prCache: Record | null - settings?: AppState['settings'] -}): WorktreeCardPrDisplay | null { - if (!repo) { - return null - } - - const branch = branchName(worktree.branch).trim() - if (!branch) { - return getLinkedReviewDisplay(worktree) - } - - const hostedReviewCacheKey = getHostedReviewCacheKey( - repo.path, - branch, - settings, - repo.id, - repo.connectionId, - repo.executionHostId, - true - ) - const hostedReviewEntry = hostedReviewCache?.[hostedReviewCacheKey] as - | HostedReviewCacheEntry - | undefined - const hostedReviewDisplay = hostedReviewEntry - ? getWorktreeCardPrDisplay( - hostedReviewEntry.data, - worktree.linkedPR, - worktree.linkedGitLabMR ?? null, - worktree.linkedBitbucketPR ?? null, - worktree.linkedAzureDevOpsPR ?? null, - worktree.linkedGiteaPR ?? null, - { reviewHintKey: hostedReviewEntry.linkedReviewHintKey } - ) - : null - if (hostedReviewDisplay) { - return hostedReviewDisplay - } - - const cachedGitHubPr = getCachedGitHubPr({ worktree, repo, branch, prCache, settings }) - if (cachedGitHubPr) { - return { - provider: 'github', - number: cachedGitHubPr.number, - title: cachedGitHubPr.title, - state: cachedGitHubPr.state, - url: cachedGitHubPr.url, - status: cachedGitHubPr.checksStatus - } - } - - return getLinkedReviewDisplay(worktree) -} - -function getLinkedReviewDisplay(worktree: Worktree): WorktreeCardPrDisplay | null { - return getWorktreeCardPrDisplay( - undefined, - worktree.linkedPR, - worktree.linkedGitLabMR ?? null, - worktree.linkedBitbucketPR ?? null, - worktree.linkedAzureDevOpsPR ?? null, - worktree.linkedGiteaPR ?? null - ) -} - -function getCachedGitHubPr({ - worktree, - repo, - branch, - prCache, - settings -}: { - worktree: Worktree - repo: Repo - branch: string - prCache: Record | null - settings?: AppState['settings'] -}): PRInfo | null { - if (!prCache || worktree.linkedPR === null) { - return null - } - - const cacheKey = getGitHubPRCacheKey( - repo.path, - repo.id, - branch, - settings, - repo.connectionId, - repo.executionHostId, - true - ) - const canUseLegacyPRCache = !repo.connectionId && !repo.executionHostId - const legacyRepoScopedCacheKey = canUseLegacyPRCache - ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) - : '' - const legacyPathScopedCacheKey = canUseLegacyPRCache - ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) - : '' - const entry = - (prCache[cacheKey] as PrCacheEntry | undefined) ?? - (legacyRepoScopedCacheKey - ? (prCache[legacyRepoScopedCacheKey] as PrCacheEntry | undefined) - : undefined) ?? - (legacyPathScopedCacheKey - ? (prCache[legacyPathScopedCacheKey] as PrCacheEntry | undefined) - : undefined) - const pr = entry?.data ?? null - return pr?.number === worktree.linkedPR ? pr : null -} - function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number { return getReviewDisplayPriority(left) - getReviewDisplayPriority(right) } From 7953240135955d3e8113bc6c0db85e2ba25d93ab Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:30:24 -0700 Subject: [PATCH 133/141] Render launch prompts in native chat and refine initial view (#7444) * Render launch prompts in native chat and refine initial view mode - Seed and render the agent launch prompt as a synthetic, pending user message in the native chat view until the transcript catches up. - Refine initial view mode logic so native chat does not auto-open for draft prompt delivery, unsupported agents, or when disabled. - Add delivery failure tracking for the launch prompt, rendering an error status in the message list if pasting into the terminal fails. - Implement corresponding store actions, selectors, cleanup routines, and extensive test coverage. * Support native-prefill prompt delivery and fix chat message sorting * Treat native draft pre-fills as successful deliveries instead of marking the seeded launch prompts as failed. * Group native chat messages into sorting tiers (real content, streaming preview, optimistic echoes) so optimistic bubbles don't sort past the streaming preview due to finite timestamps. * Avoid auto-opening native chat for draft prompt followups Followup paths paste the prompt as an unsubmitted draft. When prompt delivery is configured as 'auto-submit', this previously opened the native chat view with no actual submitted turn to render. By gating the initial view mode using 'draft' delivery on followup paths, we ensure the tab starts in terminal mode instead. --- .../native-chat/NativeChatMessageList.tsx | 17 +- .../components/native-chat/NativeChatView.tsx | 40 ++++- .../native-chat/native-chat-availability.ts | 21 +-- .../native-chat-message-grouping.test.ts | 10 ++ .../native-chat/native-chat-pending.test.ts | 90 +++++++++++ .../native-chat/native-chat-pending.ts | 38 +++++ .../native-chat-session-assembler.ts | 25 ++- src/renderer/src/hooks/useIpcEvents.ts | 8 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../lib/agent-launch-prompt-delivery.test.ts | 150 ++++++++++++++++++ .../src/lib/agent-launch-prompt-delivery.ts | 48 ++++++ .../src/lib/agent-native-draft-prefill.ts | 17 ++ src/renderer/src/lib/agent-paste-draft.ts | 5 +- .../src/lib/launch-agent-in-new-tab.test.ts | 74 ++++++++- .../src/lib/launch-agent-in-new-tab.ts | 15 +- .../src/lib/launch-work-item-direct-agent.ts | 4 +- .../src/lib/launch-work-item-direct.test.ts | 26 ++- .../lib/native-chat-initial-view-mode.test.ts | 75 +++++++-- .../src/lib/native-chat-initial-view-mode.ts | 54 +++++-- .../src/lib/native-chat-launch-prompt.ts | 9 ++ .../src/lib/native-chat-supported-agent.ts | 15 ++ .../src/lib/worktree-activation.test.ts | 81 ++++++++++ src/renderer/src/lib/worktree-activation.ts | 16 +- .../store/slices/terminal-orphan-helpers.ts | 6 + src/renderer/src/store/slices/terminals.ts | 45 ++++++ .../slices/worktree-removal-maps-leak.test.ts | 67 ++++++++ src/renderer/src/store/slices/worktrees.ts | 4 + 31 files changed, 897 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/lib/agent-launch-prompt-delivery.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-delivery.ts create mode 100644 src/renderer/src/lib/agent-native-draft-prefill.ts create mode 100644 src/renderer/src/lib/native-chat-launch-prompt.ts create mode 100644 src/renderer/src/lib/native-chat-supported-agent.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b447a4287c2..313845c2030 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -127,7 +127,8 @@ function MessageRow({ expandSignal, onScrollMessageToTop, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + deliveryFailed = false }: { message: NativeChatMessage expandSignal: boolean @@ -135,6 +136,7 @@ function MessageRow({ onScrollMessageToTop: (el: HTMLElement) => void onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + deliveryFailed?: boolean }): React.JSX.Element | null { const rowRef = useRef(null) const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks]) @@ -181,6 +183,14 @@ function MessageRow({ )} + {deliveryFailed ? ( +
+ {translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + )} +
+ ) : null} ) } @@ -227,7 +237,8 @@ export function NativeChatMessageList({ expandSignal, fontScale, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + failedDeliveryMessageIds }: { session: NativeChatLiveSession isWorking: boolean @@ -237,6 +248,7 @@ export function NativeChatMessageList({ fontScale: number onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) @@ -370,6 +382,7 @@ export function NativeChatMessageList({ onScrollMessageToTop={scrollMessageToTop} onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} + deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true} /> ))} {showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index fa190c37879..498697a7baa 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -24,10 +24,12 @@ import { appendPendingSendCache, commandMarkersAsMessages, appendCommandMarkerCache, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatCommandMarker, type NativeChatPendingSend @@ -156,6 +158,9 @@ function NativeChatResolvedView({ contextMenuActions?: Omit }): React.JSX.Element { const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath }) + const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) + const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) + const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null // Live hook state for this pane, selected directly so the working indicator // flips the instant the agent reports 'working' — even when switching to chat // mid-turn before the transcript merge has caught up. @@ -270,6 +275,12 @@ function NativeChatResolvedView({ writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) ) }, [session.messages, pendingScope]) + useEffect(() => { + if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { + return + } + clearNativeChatLaunchPrompt(terminalTabId) + }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) @@ -291,10 +302,32 @@ function NativeChatResolvedView({ [commandMarkerScope] ) + const launchPromptMessage = useMemo( + () => launchPromptAsMessage(paneLaunchPrompt, session.messages), + [paneLaunchPrompt, session.messages] + ) + const sessionWithLaunchPrompt = useMemo(() => { + if (!launchPromptMessage) { + return session + } + return { ...session, messages: [...session.messages, launchPromptMessage] } + }, [launchPromptMessage, session]) + const sessionAfterCommandBoundaries = useMemo(() => { - const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers) - return messages === session.messages ? session : { ...session, messages } - }, [session, commandMarkers]) + const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) + return messages === sessionWithLaunchPrompt.messages + ? sessionWithLaunchPrompt + : { ...sessionWithLaunchPrompt, messages } + }, [sessionWithLaunchPrompt, commandMarkers]) + const launchPromptVisible = + launchPromptMessage !== null && + sessionAfterCommandBoundaries.messages.some((message) => message.id === launchPromptMessage.id) + const failedLaunchPromptMessageIds = useMemo(() => { + if (!paneLaunchPrompt?.failed || !launchPromptVisible || !launchPromptMessage) { + return undefined + } + return new Set([launchPromptMessage.id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage, launchPromptVisible]) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -422,6 +455,7 @@ function NativeChatResolvedView({ fontScale={fontScale.scale} onLinkClick={nativeChatFileLinkClick} allowFileUriLinks={fileLinkContext !== null} + failedDeliveryMessageIds={failedLaunchPromptMessageIds} /> )} diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 6c5413eb4fc..4e520af1b9a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,25 +1,8 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' -/** Agents whose transcripts the native chat view can actually parse and render. - * Native chat depends on provider-specific transcript/streaming parsing, so the - * toggle must stay limited to the providers we support — currently Claude - * (including the OpenClaude variant) and Codex. Other agents (Grok, Gemini, …) - * run fine in the terminal but have no native-chat rendering, so they must not - * show the toggle. */ -const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex' -]) - -/** Whether the given agent identity (from any signal: launch hint, live - * detection, or title resolution) is one native chat can render. */ -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { isNativeChatSupportedAgent } /** Inputs that decide whether a tab may toggle into the native chat view. * Kept as a plain shape (not the live store) so the decision stays pure and diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 0b510514135..7df292d5ed9 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' function msg( overrides: Partial & Pick @@ -31,6 +32,15 @@ describe('orderNativeChatMessages', () => { ]) expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) + + it('sorts the streaming preview after real content but before optimistic echoes', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'pending:abc', role: 'user', timestamp: 20, source: 'scrape' }), + msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }), + msg({ id: 'real-user', role: 'user', timestamp: 10 }) + ]) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) + }) }) describe('buildNativeChatRenderItems', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index c981fe806c7..5083ab0c518 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -8,11 +8,14 @@ import { clearPendingSendCacheForTests, commandMarkersAsMessages, isCommandMarkerId, + isLaunchPromptMessageId, isPendingMessageId, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' @@ -135,6 +138,86 @@ describe('pendingSendsAsMessages', () => { }) }) +describe('launchPromptAsMessage', () => { + it('maps a launch prompt to a tab-keyed scrape-source user message', () => { + expect( + launchPromptAsMessage({ + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }) + ).toEqual({ + id: 'launch-pending:tab-1', + role: 'user', + blocks: [{ type: 'text', text: 'Fix failing checks' }], + timestamp: 42, + source: 'scrape' + }) + }) + + it('hides the launch prompt while its transcript user turn is visible', () => { + expect( + launchPromptAsMessage( + { + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }, + [userMessage('u1', 'Fix failing checks')] + ) + ).toBeNull() + }) + + it('uses pending-send normalization for large multiline generated prompts', () => { + const prompt = [ + '[Image #1] Resolve the failing checks:', + '', + 'Resolve the failing checks:', + '', + '- lint failed', + ' fix spacing' + ].join('\n') + const transcript = [ + userMessage( + 'u1', + 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' + ), + assistantMessage('a1', 'I will fix it') + ] + + expect( + shouldPruneLaunchPrompt( + { + tabId: 'tab-1', + agent: 'codex', + text: prompt, + createdAt: 42 + }, + transcript + ) + ).toBe(true) + }) + + it('keeps the launch prompt until the transcript advances past the user turn', () => { + const prompt = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'Fix failing checks', + createdAt: 42 + } + + expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false) + expect( + shouldPruneLaunchPrompt(prompt, [ + userMessage('u1', 'Fix failing checks'), + assistantMessage('a1', 'working') + ]) + ).toBe(true) + }) +}) + describe('pending send cache', () => { it('persists optimistic sends for the same pane and agent', () => { clearPendingSendCacheForTests() @@ -165,6 +248,13 @@ describe('isPendingMessageId', () => { }) }) +describe('isLaunchPromptMessageId', () => { + it('recognizes the launch prompt id prefix', () => { + expect(isLaunchPromptMessageId('launch-pending:tab-1')).toBe(true) + expect(isLaunchPromptMessageId('pending:p1')).toBe(false) + }) +}) + describe('commandMarkersAsMessages', () => { it('renders a slash command as a system "Ran " message', () => { expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 67bfb290aef..3021dc7bba4 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -152,6 +153,43 @@ export function isPendingMessageId(id: string): boolean { return id.startsWith('pending:') } +// Why: the seeded prompt has a synthetic id that never matches the real turn's, +// so dedup/prune match on normalized user-message text instead — this hides the +// optimistic bubble once the transcript's own copy of the turn catches up. +export function launchPromptAsMessage( + entry: NativeChatLaunchPrompt | null, + existingMessages: NativeChatMessage[] = [] +): NativeChatMessage | null { + if (!entry) { + return null + } + const represented = matchingUserMessageTexts(existingMessages) + if (represented.has(normalize(entry.text))) { + return null + } + return { + id: `launch-pending:${entry.tabId}`, + role: 'user' as const, + blocks: entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [], + timestamp: entry.createdAt, + source: 'scrape' as const + } +} + +// Why: prune only once an assistant turn has landed after the matching user +// text — keeping the optimistic bubble through the user-only phase avoids a +// first-turn flash before the transcript's own copy of the turn catches up. +export function shouldPruneLaunchPrompt( + entry: NativeChatLaunchPrompt, + messages: NativeChatMessage[] +): boolean { + return advancedPastUserMessageTexts(messages).has(normalize(entry.text)) +} + +export function isLaunchPromptMessageId(id: string): boolean { + return id.startsWith('launch-pending:') +} + /** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to * the agent's TUI and are not chat turns, so we surface a small system line as * feedback that the command ran rather than echoing a user bubble. */ diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index ddb4ac7fd40..53ebbd0084a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -6,7 +6,9 @@ import { type NativeChatSession, type NativeChatSessionStatus } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers' +import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -84,10 +86,29 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } +// Why: the tail bubbles form fixed tiers that timestamps alone can't express. +// The streaming preview (null timestamp) must follow real content but sit ahead +// of the optimistic composer echoes, which carry finite `sentAt` timestamps that +// would otherwise sort past it. Rank first, then timestamp within a tier. +function messageSortRank(message: NativeChatMessage): number { + if (message.id === NATIVE_CHAT_STREAMING_ID) { + return 1 + } + if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + return 2 + } + return 0 +} + // Why: null timestamps (sources that can't supply one, e.g. scrape segments) -// sort before any real timestamp so they don't jump to the end. Ties break on -// id for a stable, deterministic order. +// sort before any real timestamp within their tier so they don't jump to the +// end. Ties break on id for a stable, deterministic order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { + const ar = messageSortRank(a) + const br = messageSortRank(b) + if (ar !== br) { + return ar - br + } const at = a.timestamp ?? Number.NEGATIVE_INFINITY const bt = b.timestamp ?? Number.NEGATIVE_INFINITY if (at !== bt) { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ae1df25c90d..8ae66a5fcda 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1424,7 +1424,9 @@ export function useIpcEvents(): void { ...(launchAgent ? { launchAgent, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings, { + agent: launchAgent + }) } : {}), ...(cwd ? { startupCwd: cwd } : {}), @@ -1612,7 +1614,9 @@ export function useIpcEvents(): void { ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings), + ...initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent + }), ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f00e6bd8e92..927d992cfc7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12448,7 +12448,8 @@ "title": "Allow {{value0}}?", "allow": "Allow", "deny": "Deny" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b2c5b024265..f2a749b8020 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12448,7 +12448,8 @@ "title": "¿Permitir {{value0}}?", "allow": "Permitir", "deny": "Denegar" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d5d6043054b..eb9a5b68647 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12448,7 +12448,8 @@ "title": "{{value0}} を許可しますか?", "allow": "許可する", "deny": "拒否" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 419d2229dcc..18c7669642f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12448,7 +12448,8 @@ "title": "{{value0}}을(를) 허용하시겠습니까?", "allow": "허용하다", "deny": "부인하다" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 56c381482f7..23ed90c8f24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12448,7 +12448,8 @@ "title": "允许 {{value0}}?", "allow": "允许", "deny": "拒绝" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts new file mode 100644 index 00000000000..577db1e534e --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + pasteDraftWhenAgentReady: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mocks.pasteDraftWhenAgentReady +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed + }) + } +})) + +import { deliverLaunchPromptToAgentTab } from './agent-launch-prompt-delivery' + +describe('deliverLaunchPromptToAgentTab', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.pasteDraftWhenAgentReady.mockResolvedValue(true) + }) + + it('seeds a native-chat launch prompt for supported submitted content', async () => { + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: expect.any(Number) + }) + expect(mocks.pasteDraftWhenAgentReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true, + timeoutMs: undefined, + onTimeout: undefined + }) + }) + + it('does not seed for drafts, unsupported agents, or empty content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'unsupported-tab', + agent: 'grok', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'empty-tab', + agent: 'claude', + content: ' ', + submit: true, + forcePaste: true + }) + + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('marks a seeded launch prompt failed when paste delivery returns false', async () => { + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'claude', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + ).resolves.toBe(false) + + expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + }) + + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { + // claude delivers via `--prefill` at launch, so paste no-ops (returns false) + // when forcePaste is false — that is a native delivery, not a failure. + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'claude', + content: 'Large generated prompt', + submit: true, + forcePaste: false + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalled() + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + + it('does not mark unseeded launches failed', async () => { + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'grok', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + + it('passes timeout options through to the paste transport', async () => { + const onTimeout = vi.fn() + + await deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true, + timeoutMs: 123, + onTimeout + }) + + expect(mocks.pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 123, onTimeout }) + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts new file mode 100644 index 00000000000..1569c5f1a92 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -0,0 +1,48 @@ +import { agentDeliversDraftViaNativePrefill } from '@/lib/agent-native-draft-prefill' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { useAppStore } from '@/store' +import type { TuiAgent } from '../../../shared/types' + +export function deliverLaunchPromptToAgentTab(args: { + tabId: string + agent: TuiAgent + content: string + submit: boolean + forcePaste: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout } = args + const shouldSeed = + submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) + + if (shouldSeed) { + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId, + agent, + text: content, + createdAt: Date.now() + }) + } + + // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, + // so pasteDraftWhenAgentReady returns false without pasting. That is a successful + // native delivery, not a failure — don't flag the seeded bubble in that case. + const deliversViaNativePrefill = agentDeliversDraftViaNativePrefill(agent, forcePaste) + + return pasteDraftWhenAgentReady({ + tabId, + content, + agent, + submit, + forcePaste, + timeoutMs, + onTimeout + }).then((delivered) => { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + return delivered || deliversViaNativePrefill + }) +} diff --git a/src/renderer/src/lib/agent-native-draft-prefill.ts b/src/renderer/src/lib/agent-native-draft-prefill.ts new file mode 100644 index 00000000000..797b25e7827 --- /dev/null +++ b/src/renderer/src/lib/agent-native-draft-prefill.ts @@ -0,0 +1,17 @@ +import type { TuiAgent } from '../../../shared/types' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +// Why: agents with a native draft-prefill flag/env launch with the prompt +// already in their input box, so the paste helpers intentionally no-op (return +// false) unless `forcePaste` overrides. Callers use this to tell "delivered +// natively" apart from a real paste failure. +export function agentDeliversDraftViaNativePrefill( + agent: TuiAgent | undefined, + forcePaste: boolean | undefined +): boolean { + if (forcePaste) { + return false + } + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + return Boolean(agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index c840596de2c..e9246310881 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -14,6 +14,7 @@ import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' +import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { @@ -91,7 +92,7 @@ export async function pasteDraftWhenAgentReady(args: { // duplicate it. Callers should not invoke this helper for those agents; // the early return guards against accidental double-injection if a stale // call slips through. - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } @@ -140,7 +141,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: { const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 4b3c18d968c..73e72df1fab 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -6,6 +6,8 @@ const mockSetActiveTabType = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() +const mockSeedNativeChatLaunchPrompt = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -18,6 +20,13 @@ const store = { agentDefaultArgs: {} as Record, agentDefaultEnv: {} as Record>, activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean }, projects: [ { @@ -56,7 +65,9 @@ const store = { queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, - setAgentStatus: mockSetAgentStatus + setAgentStatus: mockSetAgentStatus, + seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed } vi.mock('@/store', () => ({ @@ -152,6 +163,66 @@ describe('launchAgentInNewTab', () => { }) }) + it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.not.stringContaining('large generated prompt') + }) + ) + expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + text: 'large generated prompt', + createdAt: expect.any(Number) + }) + }) + + it('keeps unsupported submit-after-ready launches in terminal mode and does not seed chat', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'grok', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'grok' + }) + expect(mockSeedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + it('passes quick command labels only to locally-created agent tabs', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -414,6 +485,7 @@ describe('launchAgentInNewTab', () => { }) store.terminalLayoutsByTabId = { 'tab-1': { activeLeafId: LEAF_ID } } await Promise.resolve() + await Promise.resolve() expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 41ded040d48..42d577b4d14 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -9,7 +9,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -268,10 +268,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // lands after mount the agent binary never starts; the user sees a bare shell. // Since both calls happen synchronously in the same React batch, the queue // is in place by the time the pane commits. + // Why: the followup path pastes the prompt as an unsubmitted draft (submit + // stays false), so gate the initial chat view like a `draft` launch — + // otherwise a default `auto-submit` followup would open native chat with no + // submitted turn to render. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings, { + agent, + promptDelivery: viewModePromptDelivery + }) }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, @@ -302,7 +311,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // don't fire for user-initiated cancellation (mirrors the 5s launch // watchdog in QuickLaunchButton). const tabId = tab.id - void pasteDraftWhenAgentReady({ + void deliverLaunchPromptToAgentTab({ tabId, content: pasteDraftAfterLaunch, agent, diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index c4f681fd89d..6e259f9a2e1 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { buildAgentDraftLaunchPlan, @@ -144,7 +144,7 @@ export async function pasteDirectWorkItemDraftWhenAgentReady(args: { forcePaste?: boolean }): Promise { const { primaryTabId, startupPlan, content, submit = false, forcePaste = false } = args - await pasteDraftWhenAgentReady({ + await deliverLaunchPromptToAgentTab({ tabId: primaryTabId, content, agent: startupPlan.agent, diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 33ae88fde57..77d8b44e062 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ ensureRemoteDetectedAgents: vi.fn(), updateWorktreeMeta: vi.fn(), setSidebarOpen: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn(), activateAndRevealWorktree: vi.fn(), pasteDraftWhenAgentReady: vi.fn(), openModalFallback: vi.fn(), @@ -21,6 +23,8 @@ const mocks = vi.hoisted(() => ({ createWorktree: ReturnType updateWorktreeMeta: ReturnType setSidebarOpen: ReturnType + seedNativeChatLaunchPrompt: ReturnType + markNativeChatLaunchPromptFailed: ReturnType } })) @@ -185,7 +189,9 @@ describe('launchWorkItemDirect', () => { ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents, createWorktree: mocks.createWorktree, updateWorktreeMeta: mocks.updateWorktreeMeta, - setSidebarOpen: mocks.setSidebarOpen + setSidebarOpen: mocks.setSidebarOpen, + seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed } as typeof mocks.store // @ts-expect-error -- test shim globalThis.window = { api: mockApi } @@ -449,13 +455,21 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(true) expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled() - expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'tab-1', + content: 'Use this explicit user prompt.', + agent: 'claude', + submit: true, + forcePaste: true, + onTimeout: expect.any(Function) + }) + ) + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', - content: 'Use this explicit user prompt.', agent: 'claude', - submit: true, - forcePaste: true, - onTimeout: expect.any(Function) + text: 'Use this explicit user prompt.', + createdAt: expect.any(Number) }) }) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 496ff80b713..bc8d1553f2b 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -6,33 +6,84 @@ import { describe('decideInitialAgentTabViewMode', () => { it("returns 'chat' when native chat and the opt-in default setting are on", () => { - expect(decideInitialAgentTabViewMode(true, true)).toBe('chat') + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'codex' + }) + ).toBe('chat') }) it('returns undefined when native chat is disabled', () => { - expect(decideInitialAgentTabViewMode(false, true)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: false, + openAgentTabsInChatByDefault: true, + agent: 'codex' + }) + ).toBeUndefined() }) it('returns undefined when the default-chat setting is off', () => { - expect(decideInitialAgentTabViewMode(true, false)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false, + agent: 'codex' + }) + ).toBeUndefined() }) it('returns undefined when the setting is missing (legacy settings)', () => { - expect(decideInitialAgentTabViewMode(true, undefined)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: undefined, + agent: 'codex' + }) + ).toBeUndefined() + }) + + it('returns undefined for unsupported agents', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok' + }) + ).toBeUndefined() + }) + + it('returns undefined for draft delivery', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'claude', + promptDelivery: 'draft' + }) + ).toBeUndefined() }) it('returns tab creation props only when chat should be the initial mode', () => { expect( - initialAgentTabViewModeProps({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true - }) + initialAgentTabViewModeProps( + { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + { agent: 'claude' } + ) ).toEqual({ viewMode: 'chat' }) expect( - initialAgentTabViewModeProps({ - experimentalNativeChat: false, - openAgentTabsInChatByDefault: true - }) + initialAgentTabViewModeProps( + { + experimentalNativeChat: false, + openAgentTabsInChatByDefault: true + }, + { agent: 'claude' } + ) ).toEqual({}) }) }) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 8f83467a808..a79b60962e9 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -1,29 +1,49 @@ -import type { GlobalSettings, Tab } from '../../../shared/types' +import type { GlobalSettings, Tab, TuiAgent } from '../../../shared/types' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' + +export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' /** * Decide the initial `viewMode` for a newly launched agent tab from the * opt-in `openAgentTabsInChatByDefault` setting. * - * Returns `'chat'` only when the setting is explicitly on; otherwise returns - * `undefined` so the tab keeps the implicit default (`'terminal'`) and stays - * backward-compatible with tabs persisted before the setting existed. A pure - * function so the decision can be unit-tested without the store or launch path. + * Returns `'chat'` only when the setting is explicitly on and the launched + * agent has a native-chat renderer. Draft launches stay in the terminal because + * their prompt exists only in the TUI input buffer. */ -export function decideInitialAgentTabViewMode( - experimentalNativeChat: boolean | undefined, - openAgentTabsInChatByDefault: boolean | undefined -): Tab['viewMode'] { - return experimentalNativeChat === true && openAgentTabsInChatByDefault === true - ? 'chat' - : undefined +export function decideInitialAgentTabViewMode(args: { + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery +}): Tab['viewMode'] { + if (args.experimentalNativeChat !== true || args.openAgentTabsInChatByDefault !== true) { + return undefined + } + if (!isNativeChatSupportedAgent(args.agent)) { + return undefined + } + if (args.promptDelivery === 'draft') { + return undefined + } + return 'chat' } export function initialAgentTabViewModeProps( - settings: Pick | null + settings: + | Pick + | null + | undefined, + options: { + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery + } = {} ): { viewMode?: Tab['viewMode'] } { - const viewMode = decideInitialAgentTabViewMode( - settings?.experimentalNativeChat, - settings?.openAgentTabsInChatByDefault - ) + const viewMode = decideInitialAgentTabViewMode({ + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + agent: options.agent, + promptDelivery: options.promptDelivery + }) return viewMode ? { viewMode } : {} } diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts new file mode 100644 index 00000000000..270a27f7a5b --- /dev/null +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -0,0 +1,9 @@ +import type { TuiAgent } from '../../../shared/types' + +export type NativeChatLaunchPrompt = { + tabId: string + agent: TuiAgent + text: string + createdAt: number + failed?: boolean +} diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts new file mode 100644 index 00000000000..ec24cd3968d --- /dev/null +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -0,0 +1,15 @@ +import type { AgentType } from '../../../shared/agent-status-types' +import type { TuiAgent } from '../../../shared/types' + +/** Agents whose transcripts the native chat view can parse and render. */ +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ + 'claude', + 'openclaude', + 'codex' +]) + +export function isNativeChatSupportedAgent( + agent: TuiAgent | AgentType | null | undefined +): boolean { + return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) +} diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index fb7e201123d..53e6a5fe8ed 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -469,6 +469,87 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('keeps draft startup payloads in terminal mode even when native chat is configured', () => { + const store = createMockStore({ + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { + command: 'claude', + launchAgent: 'claude', + draftPrompt: 'Review before sending' + }, + undefined, + undefined + ) + + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true, + launchAgent: 'claude' + }) + }) + + it('opens the startup default tab in native chat when configured', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + createTab, + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude', launchAgent: 'claude' }, + undefined, + undefined, + { runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] } + ) + + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false, + launchAgent: 'claude', + viewMode: 'chat' + }) + }) + + it('keeps a draft startup default tab in terminal mode even when native chat is configured', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + createTab, + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude', launchAgent: 'claude', draftPrompt: 'Review before sending' }, + undefined, + undefined, + { runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] } + ) + + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false, + launchAgent: 'claude' + }) + }) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { setSetupScriptLaunchMode('new-tab') let createdIndex = 0 diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 894e9d38ad9..1d94786a345 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -578,7 +578,13 @@ export function ensureWorktreeHasInitialTerminal( const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, ...(launchAgent - ? { launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null) } + ? { + launchAgent, + ...initialAgentTabViewModeProps(store.settings ?? null, { + agent: launchAgent, + promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined + }) + } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) @@ -637,7 +643,13 @@ function applyDefaultTerminalTabs( pendingActivationSpawn: true, recordInteraction: false, ...(launchAgent - ? { launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null) } + ? { + launchAgent, + ...initialAgentTabViewModeProps(store.settings ?? null, { + agent: launchAgent, + promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined + }) + } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index b6ffe73f902..cc5fb47be28 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -18,6 +18,7 @@ type OrphanTerminalCleanupState = Pick< | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' + | 'nativeChatLaunchPromptByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -65,6 +66,7 @@ export function buildOrphanTerminalCleanupPatch( | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' + | 'nativeChatLaunchPromptByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -83,6 +85,7 @@ export function buildOrphanTerminalCleanupPatch( pendingSetupSplitByTabId: state.pendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId, tabBarOrderByWorktree: state.tabBarOrderByWorktree, cacheTimerByKey: state.cacheTimerByKey, activeTabIdByWorktree: state.activeTabIdByWorktree, @@ -105,6 +108,7 @@ export function buildOrphanTerminalCleanupPatch( const nextAutomaticAgentResumeClaimsByTabId = { ...state.automaticAgentResumeClaimsByTabId } + const nextNativeChatLaunchPromptByTabId = { ...state.nativeChatLaunchPromptByTabId } const nextTabBarOrderByWorktree = { ...state.tabBarOrderByWorktree, [worktreeId]: (state.tabBarOrderByWorktree[worktreeId] ?? []).filter( @@ -128,6 +132,7 @@ export function buildOrphanTerminalCleanupPatch( delete nextPendingSetupSplitByTabId[orphanTabId] delete nextPendingIssueCommandSplitByTabId[orphanTabId] delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId] + delete nextNativeChatLaunchPromptByTabId[orphanTabId] for (const key of Object.keys(nextCacheTimerByKey)) { if (key.startsWith(`${orphanTabId}:`)) { delete nextCacheTimerByKey[key] @@ -157,6 +162,7 @@ export function buildOrphanTerminalCleanupPatch( pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, tabBarOrderByWorktree: nextTabBarOrderByWorktree, cacheTimerByKey: nextCacheTimerByKey, activeTabIdByWorktree: nextActiveTabIdByWorktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index f946f5626ea..3b99e38374a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -64,6 +64,7 @@ import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sani import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -370,6 +371,11 @@ export type TerminalSlice = { * bridges the gap after startup payload consumption and before hooks go live. */ automaticAgentResumeClaimsByTabId: Record claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void + /** Launch-time native-chat prompt echo, keyed by terminal tab. In-memory only. */ + nativeChatLaunchPromptByTabId: Record + seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void + markNativeChatLaunchPromptFailed: (tabId: string) => void + clearNativeChatLaunchPrompt: (tabId: string) => void pendingStartupByTabId: Record< string, { @@ -636,6 +642,7 @@ export const createTerminalSlice: StateCreator pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, automaticAgentResumeClaimsByTabId: {}, + nativeChatLaunchPromptByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, defaultTerminalTabsAppliedByWorktreeId: {}, @@ -685,6 +692,41 @@ export const createTerminalSlice: StateCreator })) }, + seedNativeChatLaunchPrompt: (prompt) => { + set((s) => ({ + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [prompt.tabId]: prompt + } + })) + }, + + markNativeChatLaunchPromptFailed: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchPromptByTabId[tabId] + if (!current || current.failed) { + return {} + } + return { + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [tabId]: { ...current, failed: true } + } + } + }) + }, + + clearNativeChatLaunchPrompt: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchPromptByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchPromptByTabId } + delete next[tabId] + return { nativeChatLaunchPromptByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { if (!paneKey || !Number.isFinite(timestamp)) { return @@ -1091,6 +1133,8 @@ export const createTerminalSlice: StateCreator delete nextPendingStartupByTabId[tabId] const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } delete nextAutomaticAgentResumeClaimsByTabId[tabId] + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + delete nextNativeChatLaunchPromptByTabId[tabId] const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } delete nextPendingInitialCwdByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } @@ -1168,6 +1212,7 @@ export const createTerminalSlice: StateCreator terminalLayoutsByTabId: nextLayouts, pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index e603ae94740..fc00b3082ac 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -188,6 +188,73 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously expect(getAgentHibernationPaneOutputEpoch(survivingPaneKey)).toBe(1) }) + it('bulk purgeWorktreeTerminalState drops native-chat launch prompts for removed tabs only', () => { + const store = createTestStore() + const TAB1 = 'tab-wt1' + const TAB2 = 'tab-wt2' + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchPromptByTabId: { + [TAB1]: { tabId: TAB1, agent: 'codex', text: 'fix wt1', createdAt: 1 }, + [TAB2]: { tabId: TAB2, agent: 'codex', text: 'fix wt2', createdAt: 2 } + } + }) + + store.getState().purgeWorktreeTerminalState([WT1]) + + const s = store.getState() + expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({ + tabId: TAB2, + agent: 'codex', + text: 'fix wt2', + createdAt: 2 + }) + }) + + it('single removeWorktree drops native-chat launch prompts for removed tabs only', async () => { + const store = createTestStore() + const TAB1 = 'tab-wt1' + const TAB2 = 'tab-wt2' + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchPromptByTabId: { + [TAB1]: { tabId: TAB1, agent: 'claude', text: 'fix wt1', createdAt: 1 }, + [TAB2]: { tabId: TAB2, agent: 'claude', text: 'fix wt2', createdAt: 2 } + } + }) + + const result = await store.getState().removeWorktree(WT1) + + expect(result).toEqual({ ok: true }) + const s = store.getState() + expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({ + tabId: TAB2, + agent: 'claude', + text: 'fix wt2', + createdAt: 2 + }) + }) + it('bulk purgeWorktreeTerminalState drops page/workspace-keyed browser maps for the removed worktree only', () => { const store = createTestStore() const WS1 = 'ws-1' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 6d5de1c57bb..5966530cc2d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1991,6 +1991,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } for (const tabId of tabIds) { delete nextLayouts[tabId] delete nextPtyIdsByTabId[tabId] delete nextRuntimePaneTitlesByTabId[tabId] delete nextAutomaticAgentResumeClaimsByTabId[tabId] + delete nextNativeChatLaunchPromptByTabId[tabId] } const nextDeleteState = { ...s.deleteStateByWorktreeId } delete nextDeleteState[worktreeId] @@ -3246,6 +3249,7 @@ export const createWorktreeSlice: StateCreator ptyIdsByTabId: nextPtyIdsByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, terminalLayoutsByTabId: nextLayouts, deleteStateByWorktreeId: nextDeleteState, baseStatusByWorktreeId: (() => { From 3bcfae176764c84b66dea157f30ca6fe430447b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:33:38 -0700 Subject: [PATCH 134/141] Reduce Linux file-watcher load on large codebases by excluding nested build/dependency dirs (#7453) Co-authored-by: Orca --- src/main/ipc/filesystem-watcher-ignore.test.ts | 16 +++++++++++++--- src/main/ipc/filesystem-watcher-ignore.ts | 7 ++++++- src/relay/fs-handler.ts | 8 +++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 6784143e013..6a3ab329fef 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -32,8 +32,18 @@ describe('buildParcelWatcherIgnoreOption', () => { } }) - it('passes the plain list through on other platforms', () => { - setPlatform('linux') - expect(buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)).toEqual(WATCHER_IGNORE_DIRS) + it('expands to nested globs on Linux/Windows so nested node_modules/.git are excluded', () => { + for (const platform of ['linux', 'win32'] as const) { + setPlatform(platform) + const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) + // @parcel/watcher resolves plain names to top-level-only absolute paths on + // these platforms, so nested dirs must be matched via depth-agnostic globs. + for (const dir of WATCHER_IGNORE_DIRS) { + expect(option).toContain(`**/${dir}`) + expect(option).toContain(`**/${dir}/**`) + } + // No plain (glob-free) entries — those would only exclude the top level. + expect(option.every((entry) => entry.includes('*'))).toBe(true) + } }) }) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 61339afb3e4..9828f4a5911 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -25,7 +25,12 @@ export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] { if (process.platform !== 'darwin') { - return [...ignoreDirs] + // Linux/Windows: @parcel/watcher resolves plain names to top-level-only + // absolute paths, so nested node_modules/.git/build/etc. (monorepos) get + // fully watched — on Linux that means one inotify watch per nested dir, + // exhausting fs.inotify.max_user_watches. Nested globs match at any depth + // (and **/dir still matches the top-level dir), pruning those subtrees. + return ignoreDirs.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) } return [ ...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT), diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index a1b4ee08660..0c4a263f13e 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -29,6 +29,10 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOption +} from '../main/ipc/filesystem-watcher-ignore' type WatchState = { rootPath: string @@ -408,7 +412,9 @@ export class FsHandler { })) this.dispatcher.notify('fs.changed', { events: mapped }) }, - { ignore: ['.git', 'node_modules', 'dist', 'build', '.next', '.cache', '__pycache__'] } + // Why: align remote-Linux watchers with the shared nested-glob exclusion + // so nested node_modules/.git don't exhaust inotify on large codebases. + { ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) } ) watchState.unwatchFn = () => { void subscription.unsubscribe() From eee2d9807dc8784964edd3c7092a178404e491d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:39:26 +0000 Subject: [PATCH 135/141] release: v1.4.124-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb76d17279f..8744db177eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.1", + "version": "1.4.124-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f3f67a51f5816fa63ddbf79dff9f296453c3fe1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:34:53 +0000 Subject: [PATCH 136/141] release: v1.4.124-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8744db177eb..ccce6236780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.2", + "version": "1.4.124-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 3cd23a13a1404878b0c8f87e7e13a51623e65365 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:21:42 -0700 Subject: [PATCH 137/141] fix(pty): keep runtime dirs a surviving daemon still uses (#7463) The node-pty native runtime is relocated to a per-app-version dir under userData so live terminals survive NSIS updates. On every launch, removeStaleRuntimeVersions() deleted every *other* version's dir, gated on a "renameSync succeeds => unused => safe to delete" heuristic. That heuristic is false on Windows: running .exe images and mapped .dll files are opened with FILE_SHARE_DELETE, so both the directory rename and the recursive delete succeed while a daemon adopted across the update is still using them. Existing PTYs keep streaming via deferred-delete handles, but the next spawn re-loads conpty.dll from the now-unlinked version dir and fails with ERROR_PATH_NOT_FOUND (code 3) -- the "Cannot find conpty.dll ... error code: 3" new-tab failure. Gate cleanup on real daemon liveness instead: collectInUseRuntimeVersions() reads the daemon-v.pid files under userData/daemon and, for each live daemon, protects the appVersion (runtime dir) it recorded. Deletion now skips the current version and any in-use version, dropping the rename dance. Every failure mode leaks a dir rather than deleting one in use. Rewrites the misleading cleanup test (which modeled no live daemon) and adds coverage for surviving/dead daemons, appVersion:null pin files, multiple protocol versions, and malformed/non-pid files. Co-authored-by: Neil --- .../pty/node-pty-runtime-relocation.test.ts | 174 +++++++++++++++++- src/main/pty/node-pty-runtime-relocation.ts | 91 +++++++-- 2 files changed, 241 insertions(+), 24 deletions(-) diff --git a/src/main/pty/node-pty-runtime-relocation.test.ts b/src/main/pty/node-pty-runtime-relocation.test.ts index 1dfbe033885..5b4ce4886e9 100644 --- a/src/main/pty/node-pty-runtime-relocation.test.ts +++ b/src/main/pty/node-pty-runtime-relocation.test.ts @@ -12,7 +12,10 @@ vi.mock('electron', () => ({ } })) +import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner' +import type { DaemonPidFile } from '../daemon/daemon-spawner' import { + collectInUseRuntimeVersions, ensureRelocatedNodePtyNativeRuntime, resolveNodePtyNativeSourceDir } from './node-pty-runtime-relocation' @@ -42,6 +45,23 @@ function seedSourceDir(dir: string): void { writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host') } +// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v.pid). +function writeDaemonPidFile( + daemonRuntimeDir: string, + protocolVersion: number, + pidFile: DaemonPidFile +): void { + mkdirSync(daemonRuntimeDir, { recursive: true }) + writeFileSync(getDaemonPidPath(daemonRuntimeDir, protocolVersion), serializeDaemonPidFile(pidFile)) +} + +// Deterministic liveness that treats the given pids as running, so tests never +// depend on the host's real process table. +function aliveFor(...alivePids: number[]): (pid: number) => boolean { + const set = new Set(alivePids) + return (pid) => set.has(pid) +} + describe('resolveNodePtyNativeSourceDir', () => { it('prefers the rebuilt build/Release binding over prebuilds', () => { const pkg = join(tempDir, 'node-pty') @@ -66,13 +86,98 @@ describe('resolveNodePtyNativeSourceDir', () => { }) }) +describe('collectInUseRuntimeVersions', () => { + it('returns an empty set when the daemon dir does not exist', () => { + expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0) + }) + + it('collects the app version a live daemon pins', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321)) + + expect([...inUse]).toEqual(['1.4.124-rc.1']) + }) + + it('omits the version of a daemon whose pid is dead', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */)) + + expect(inUse.size).toBe(0) + }) + + it('collects one version per live daemon across protocol versions', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200)) + + expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2']) + }) + + it('ignores a daemon whose pid file records no app version', () => { + const daemonDir = join(tempDir, 'daemon') + // Pre-relocation daemon: JSON pid file without appVersion. + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null }) + // Legacy daemon: bare-integer pid file (appVersion parses to null). + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(getDaemonPidPath(daemonDir, 17), '9999') + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999)) + + expect(inUse.size).toBe(0) + }) + + it('ignores non-pid files in the daemon dir', () => { + const daemonDir = join(tempDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret') + writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x') + writeFileSync(join(daemonDir, 'notes.txt'), 'x') + + expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0) + }) + + it('skips a malformed pid file without throwing', () => { + const daemonDir = join(tempDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all') + writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200)) + + expect([...inUse]).toEqual(['2.0.0']) + }) + + it('treats the current process as alive under the default liveness probe', () => { + const daemonDir = join(tempDir, 'daemon') + // startedAtMs null so startTimeMatches short-circuits true on every platform. + writeDaemonPidFile(daemonDir, 18, { + pid: process.pid, + startedAtMs: null, + appVersion: '3.0.0' + }) + + expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0']) + }) +}) + describe('ensureRelocatedNodePtyNativeRuntime', () => { it('copies the runtime tree (without symbols) and returns the version dir', () => { const sourceDir = join(tempDir, 'source') seedSourceDir(sourceDir) const destRoot = join(tempDir, 'dest') - const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) expect(destDir).toBe(join(destRoot, '1.2.3')) expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') @@ -85,10 +190,20 @@ describe('ensureRelocatedNodePtyNativeRuntime', () => { const sourceDir = join(tempDir, 'source') seedSourceDir(sourceDir) const destRoot = join(tempDir, 'dest') - ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy') - const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') }) @@ -100,30 +215,70 @@ describe('ensureRelocatedNodePtyNativeRuntime', () => { mkdirSync(join(destRoot, '1.2.3'), { recursive: true }) writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy') - const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' }) + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') }) - it('removes stale version dirs but keeps the current one', () => { + it('reclaims a stale version dir once no live daemon pins it', () => { const sourceDir = join(tempDir, 'source') seedSourceDir(sourceDir) const destRoot = join(tempDir, 'dest') - ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' }) + const daemonRuntimeDir = join(tempDir, 'daemon') + ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) - const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' }) + // The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0. + writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) + + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '2.0.0', + daemonRuntimeDir, + isDaemonPidAlive: aliveFor(/* 4321 is dead */) + }) expect(destDir).toBe(join(destRoot, '2.0.0')) expect(existsSync(join(destRoot, '1.0.0'))).toBe(false) expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true) }) + it('preserves a stale version dir a surviving daemon still pins', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + const daemonRuntimeDir = join(tempDir, 'daemon') + ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) + + // The 1.0.0 daemon survived the update and still loads conpty.dll from its + // 1.0.0 runtime dir on every new spawn — deleting it would strand it. + writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) + + const destDir = ensureRelocatedNodePtyNativeRuntime({ + sourceDir, + destRoot, + version: '2.0.0', + daemonRuntimeDir, + isDaemonPidAlive: aliveFor(4321) + }) + + expect(destDir).toBe(join(destRoot, '2.0.0')) + expect(existsSync(join(destRoot, '1.0.0', 'conpty.node'))).toBe(true) + expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true) + }) + it('fails open when the source dir is missing', () => { expect( ensureRelocatedNodePtyNativeRuntime({ sourceDir: join(tempDir, 'does-not-exist'), destRoot: join(tempDir, 'dest'), - version: '1.2.3' + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') }) ).toBeNull() }) @@ -140,7 +295,8 @@ describe.runIf(process.platform === 'win32')('patched node-pty loader override', const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir: sourceDir!, destRoot: join(tempDir, 'relocated-runtime'), - version: 'loader-test' + version: 'loader-test', + daemonRuntimeDir: join(tempDir, 'daemon') }) expect(destDir).not.toBeNull() diff --git a/src/main/pty/node-pty-runtime-relocation.ts b/src/main/pty/node-pty-runtime-relocation.ts index a3821dde5d4..0e8600662eb 100644 --- a/src/main/pty/node-pty-runtime-relocation.ts +++ b/src/main/pty/node-pty-runtime-relocation.ts @@ -3,13 +3,14 @@ import { existsSync, mkdirSync, readdirSync, - renameSync, + readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { app } from 'electron' +import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health' /** * Relocates node-pty's Windows native runtime (conpty.node, conpty.dll, @@ -55,7 +56,61 @@ function copyRuntimeTree(sourceDir: string, destDir: string): void { } } -function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void { +type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean + +function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean { + try { + process.kill(pid, 0) + } catch { + return false + } + return startTimeMatches(pid, startedAtMs) +} + +/** + * Runtime version dirs still claimed by a live daemon, read from the daemon + * pid files under `daemonRuntimeDir` (`daemon-v.pid`). + * + * Why: a daemon deliberately survives app updates, and it was forked with + * ORCA_NODE_PTY_NATIVE_DIR pinned to its own app-version runtime dir — which + * it reloads conpty.dll from on every new spawn. Each pid file records that + * `appVersion`, so a live daemon's version dir must never be reclaimed. + */ +export function collectInUseRuntimeVersions( + daemonRuntimeDir: string, + isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault +): Set { + const inUse = new Set() + let entries + try { + entries = readdirSync(daemonRuntimeDir, { withFileTypes: true }) + } catch { + return inUse + } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8')) + } catch { + continue + } + // appVersion null => a pre-relocation daemon that loads from the install + // dir and thus pins no version dir here. + if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) { + inUse.add(parsed.appVersion) + } + } + return inUse +} + +function removeStaleRuntimeVersions( + destRoot: string, + keepVersion: string, + inUseVersions: ReadonlySet +): void { let entries try { entries = readdirSync(destRoot, { withFileTypes: true }) @@ -63,19 +118,18 @@ function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void return } for (const entry of entries) { - if (!entry.isDirectory() || entry.name === keepVersion) { + if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) { continue } - // Why: an adopted daemon from a previous version may still run (or later - // respawn) binaries out of its own version dir. Renaming a directory - // fails on Windows while anything inside is open, so a successful rename - // proves the dir is unused and safe to delete. - const doomedPath = join(destRoot, `${entry.name}.stale`) + // Why: Windows maps the daemon's conpty.dll/OpenConsole.exe with + // FILE_SHARE_DELETE, so a rename/delete succeeds even while a surviving + // daemon still needs them — the old rename heuristic deleted in-use dirs + // and stranded the daemon (new spawn -> conpty.dll ERROR_PATH_NOT_FOUND). + // Only dirs no live daemon claims via its pid file are safe to remove. try { - renameSync(join(destRoot, entry.name), doomedPath) - rmSync(doomedPath, { recursive: true, force: true }) + rmSync(join(destRoot, entry.name), { recursive: true, force: true }) } catch { - // Still in use (or already being cleaned) — retry on a future launch. + // Still locked or already gone — retry on a future launch. } } } @@ -84,8 +138,10 @@ export function ensureRelocatedNodePtyNativeRuntime(options: { sourceDir: string destRoot: string version: string + daemonRuntimeDir: string + isDaemonPidAlive?: IsDaemonPidAlive }): string | null { - const { sourceDir, destRoot, version } = options + const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options const destDir = join(destRoot, version) try { // Why: the marker is written only after a full copy, so a crash mid-copy @@ -95,7 +151,8 @@ export function ensureRelocatedNodePtyNativeRuntime(options: { copyRuntimeTree(sourceDir, destDir) writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '') } - removeStaleRuntimeVersions(destRoot, version) + const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive) + removeStaleRuntimeVersions(destRoot, version, inUseVersions) return destDir } catch { // Fail open: node-pty keeps loading from the install dir, which is the @@ -126,10 +183,14 @@ export function installRelocatedNodePtyNativeRuntime(): void { if (!sourceDir) { return } + const userData = app.getPath('userData') const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, - destRoot: join(app.getPath('userData'), 'node-pty-runtime'), - version: app.getVersion() + destRoot: join(userData, 'node-pty-runtime'), + version: app.getVersion(), + // Keyed to daemon-init's runtimeDir (userData/daemon), where surviving + // daemons write daemon-v.pid recording the runtime version they pin. + daemonRuntimeDir: join(userData, 'daemon') }) if (destDir) { process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir From 0fa8589abf503b5de25661a3998b3e767bde9e3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:25:02 +0000 Subject: [PATCH 138/141] release: v1.4.124-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ccce6236780..2054920622b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.3", + "version": "1.4.124-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b58c0a42da9da82576b534fc3c8bd6f85b6f41d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:46:49 -0700 Subject: [PATCH 139/141] Fix stale and race-prone E2E test failures (#7468) Co-authored-by: Orca --- tests/e2e/github-cli-stall-repro.spec.ts | 28 ++-- tests/e2e/helpers/worktree-registration.ts | 151 ++++++++++++++++++ tests/e2e/onboarding.spec.ts | 67 +++++++- tests/e2e/pr-comments-sidebar-cards.spec.ts | 1 - tests/e2e/settings-display-name-ime.spec.ts | 7 + ...e-control-commit-draft-persistence.spec.ts | 62 +------ .../source-control-commit-message-ai.spec.ts | 72 ++------- tests/e2e/terminal-shortcuts.spec.ts | 62 +++---- tests/e2e/workspace-space-git-status.spec.ts | 25 ++- tests/e2e/worktree.spec.ts | 18 ++- 10 files changed, 315 insertions(+), 178 deletions(-) create mode 100644 tests/e2e/helpers/worktree-registration.ts diff --git a/tests/e2e/github-cli-stall-repro.spec.ts b/tests/e2e/github-cli-stall-repro.spec.ts index 18e13778e3d..31a58f63b71 100644 --- a/tests/e2e/github-cli-stall-repro.spec.ts +++ b/tests/e2e/github-cli-stall-repro.spec.ts @@ -21,6 +21,10 @@ if (args[0] === 'api' && args.includes('rate_limit')) { console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } })) process.exit(0) } +if (args[0] === 'api' && joined.includes('search/issues')) { + console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] })) + process.exit(0) +} if (args[0] === 'issue' && args[1] === 'list') { console.log('[]') process.exit(0) @@ -113,20 +117,18 @@ test('GitHub Tasks drawer recovers when gh stalls on issue details', async ({ return { repoId: repo.id } }, testRepoPath) - const drawer = orcaPage - .getByRole('dialog') - .filter({ hasText: 'Issue detail fetch that hangs in gh' }) - .last() - await expect(drawer).toBeVisible() + // The item detail opens as an inline task-detail page (no longer a modal + // dialog): the item title heading proves it mounted. + const detailHeading = orcaPage.getByRole('heading', { + name: /Issue detail fetch that hangs in gh/ + }) + await expect(detailHeading).toBeVisible({ timeout: 10_000 }) - // Why: this is the user-visible regression signal. Before ghExecFileAsync had - // a default timeout, the drawer's pending details promise never settled and - // the conversation pane stayed stuck in its loading shell. The main GitHub - // details service degrades failed detail fetches to an empty shell, so the - // stable visible proof is that the drawer becomes usable and stops spinning. - await expect(drawer.getByText('No description provided.')).toBeVisible({ timeout: 5_000 }) - await expect(drawer.getByText('No comments yet.')).toBeVisible() - await expect(drawer.locator('.animate-spin')).toHaveCount(0) + // Why: the bounded gh timeout rejects instead of hanging, so this terminal + // error text (replacing the body, not a spinner) proves the stall recovered. + await expect(orcaPage.getByText('Unable to load details for this GitHub item.')).toBeVisible({ + timeout: 5_000 + }) expect(repoId).toBeTruthy() }) diff --git a/tests/e2e/helpers/worktree-registration.ts b/tests/e2e/helpers/worktree-registration.ts new file mode 100644 index 00000000000..96a4bce5e2f --- /dev/null +++ b/tests/e2e/helpers/worktree-registration.ts @@ -0,0 +1,151 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import type { CommitMessageAiSettings } from '../../../src/shared/types' + +// Why: these specs create worktrees via raw `git worktree add`, which bypasses +// Orca's own add/remove path — the one thing that invalidates the main-process +// worktrees.list scan cache (DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000). So a +// read inside the TTL window can serve a stale miss. No renderer-reachable +// cache-invalidation seam exists without adding product surface, so poll past +// the deterministic 5s boundary. Budget 10s (not 6s, which sits dangerously +// close to a 5s TTL if one scan is slow); the informative message keeps a +// genuinely never-loading worktree failing loudly. +const WORKTREE_CACHE_TTL_POLL_MS = 10_000 + +/** + * Loads the repo's worktrees into the renderer store and resolves the id of the + * worktree at `targetWorktreePath`, polling past the 5s scan-cache TTL so a + * raw `git worktree add` that Orca never observed still becomes visible. + */ +async function resolveE2eWorktreeId( + page: Page, + repoPath: string, + targetWorktreePath: string +): Promise { + let worktreeId: string | null = null + await expect + .poll( + async () => { + worktreeId = await page.evaluate( + async ({ repoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchRepos() + const repo = store.getState().repos.find((entry) => entry.path === repoPath) + if (!repo) { + throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) + } + // Why: use the store's own fetch (like loadWorktreesUntilPathsPresent) + // so both TTL workarounds stay behaviorally identical. + await store.getState().fetchWorktrees(repo.id) + const listedWorktrees = store.getState().worktreesByRepo[repo.id] ?? [] + const normalize = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + const worktree = listedWorktrees.find( + (entry) => normalize(entry.path) === normalize(targetWorktreePath) + ) + return worktree?.id ?? null + }, + { repoPath, targetWorktreePath } + ) + return worktreeId + }, + { + timeout: WORKTREE_CACHE_TTL_POLL_MS, + message: `E2E worktree was not loaded within the worktree-cache TTL window: ${targetWorktreePath}` + } + ) + .not.toBeNull() + + if (!worktreeId) { + throw new Error(`E2E worktree was not loaded: ${targetWorktreePath}`) + } + return worktreeId +} + +/** + * Shared setup for the Source Control specs: resolves the target worktree + * (surviving the scan-cache TTL race), activates it, optionally applies AI + * commit-message settings, loads its git status, and opens the Source Control + * sidebar tab. + */ +export async function openSourceControlForWorktree( + page: Page, + repoPath: string, + targetWorktreePath: string, + options: { commitMessageAi?: CommitMessageAiSettings } = {} +): Promise { + const worktreeId = await resolveE2eWorktreeId(page, repoPath, targetWorktreePath) + + await page.evaluate( + async ({ worktreeId, commitMessageAi }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + if (!worktree) { + throw new Error(`E2E worktree disappeared from the store: ${worktreeId}`) + } + store.getState().setActiveWorktree(worktree.id) + if (commitMessageAi) { + await store.getState().updateSettings({ commitMessageAi }) + } + const status = await window.api.git.status({ worktreePath: worktree.path }) + store.getState().setGitStatus(worktree.id, status) + store.getState().setRightSidebarTab('source-control') + store.getState().setRightSidebarOpen(true) + }, + { worktreeId, commitMessageAi: options.commitMessageAi ?? null } + ) + + await expect + .poll( + async () => + page.evaluate(() => { + const state = window.__store?.getState() + return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') + }), + { timeout: 5_000 } + ) + .toBe(true) +} + +/** + * Polls `fetchWorktrees` past the scan-cache TTL until every expected path is + * registered in the store. Used by the Workspace Space git-status spec, which + * adds 60 worktrees via raw git and would otherwise read a stale partial scan. + */ +export async function loadWorktreesUntilPathsPresent( + page: Page, + repoId: string, + expectedPaths: string[] +): Promise { + await expect + .poll( + async () => + page.evaluate( + async ({ repoId, expectedPaths }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchWorktrees(repoId) + const registered = new Set( + (store.getState().worktreesByRepo[repoId] ?? []).map((entry) => entry.path) + ) + return expectedPaths.every((entry) => registered.has(entry)) + }, + { repoId, expectedPaths } + ), + { + timeout: WORKTREE_CACHE_TTL_POLL_MS, + message: `Not all worktrees registered within the worktree-cache TTL window (${expectedPaths.length} expected)` + } + ) + .toBe(true) +} diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index cdfd3b452c9..1fd2e1cc846 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -241,12 +241,17 @@ test.describe('Onboarding flow', () => { .toBe(oppositeTheme) await continueOnboarding(orcaPage) + // Why: the theme Continue persists step 2, then persists *through* any + // skipped optional steps (integrations is skipped when gh is installed, + // windows_terminal off macOS), so lastCompletedStep can land at 2, 3, or 4. + // Key off the settled "theme step committed" lower bound rather than a fixed + // window that assumed integrations always renders. await expect - .poll(async () => [2, 3].includes((await getOnboardingState(orcaPage)).lastCompletedStep), { + .poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, { timeout: 5_000, - message: 'lastCompletedStep did not advance after second Continue' + message: 'lastCompletedStep did not advance past the theme step after second Continue' }) - .toBe(true) + .toBeGreaterThanOrEqual(2) await expect .poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }) .toBe(oppositeTheme) @@ -414,7 +419,53 @@ test.describe('Onboarding flow', () => { timeout: 15_000 }) await orcaPage.evaluate(async () => { - await window.__store?.getState().updateSettings({ activeRuntimeEnvironmentId: 'env-e2e' }) + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + // Why: after #5071 the server-path add step gates on the registered + // runtime-environment list (store.runtimeEnvironments), not just the + // activeRuntimeEnvironmentId setting. Seed a redacted environment so the + // host option exists and the "on host" add UI renders. + const now = Date.now() + store.getState().setRuntimeEnvironments([ + { + id: 'env-e2e', + name: 'E2E Server', + createdAt: now, + updatedAt: now, + lastUsedAt: null, + runtimeId: null, + source: 'manual', + endpoints: [ + { + id: 'ws-env-e2e', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'wss://e2e.invalid/ws' + } + ], + preferredEndpointId: 'ws-env-e2e' + } + ]) + // Why: a runtime host is only auto-selectable (health 'available') when it + // has a live, protocol-compatible status; without one it reads + // 'disconnected' and the Add Project dialog falls back to Local Mac. + // runtimeProtocolVersion 3 clears MIN_COMPATIBLE_RUNTIME_SERVER_VERSION. + store.getState().setRuntimeEnvironmentStatus('env-e2e', { + status: { + runtimeId: 'env-e2e-runtime', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 1 + }, + checkedAt: now + }) + await store.getState().updateSettings({ activeRuntimeEnvironmentId: 'env-e2e' }) }) await expect .poll(async () => (await getSettings(orcaPage)).activeRuntimeEnvironmentId, { @@ -425,10 +476,12 @@ test.describe('Onboarding flow', () => { await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click() await expectAddProjectDialog(orcaPage) - await expect(orcaPage.getByRole('button', { name: /Browse server/i })).toBeVisible() + // The runtime env is selected as the Add Project host and the browse action + // is host-scoped, proving the server project-setup UI is preserved on skip. + await expect(orcaPage.getByText('Existing Git repository or folder on this host')).toBeVisible() + await expect(orcaPage.getByRole('button', { name: /Browse folder/i })).toBeVisible() await expect(orcaPage.getByRole('button', { name: /Clone from URL/i })).toBeVisible() - await expect(orcaPage.getByRole('button', { name: /Create on server/i })).toBeVisible() - await expect(orcaPage.getByText(/Or enter a server path manually/i)).toBeVisible() + await expect(orcaPage.getByRole('button', { name: /Create new project/i })).toBeVisible() await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0) expect((await getOnboardingState(orcaPage)).closedAt).not.toBeNull() }) diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index a045302269a..9cccce81bfb 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -58,7 +58,6 @@ test.describe('PR comments sidebar cards view', () => { await expect(orcaPage.getByText('Needs review · 1')).toBeVisible() await expect(orcaPage.getByText('Please update this handler before merge.')).toBeVisible() await expect(orcaPage.getByText('alice')).toBeVisible() - await expect(orcaPage.getByText('Open', { exact: true })).toBeVisible() await expect(orcaPage.getByText('LGTM on the overall approach.')).toBeVisible() const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ diff --git a/tests/e2e/settings-display-name-ime.spec.ts b/tests/e2e/settings-display-name-ime.spec.ts index 5de78264c1d..8001c6d3338 100644 --- a/tests/e2e/settings-display-name-ime.spec.ts +++ b/tests/e2e/settings-display-name-ime.spec.ts @@ -112,6 +112,13 @@ async function typeHangulGanadaSlowly( // Slow typing: let the async store echo land before the next key. await page.waitForTimeout(200) } + + // Why: a real IME commits the pending syllable (space/enter) at the end of a + // word, firing compositionend. Without this final commit the controlled + // input stays in composing state, so the component's defer-until-compositionend + // persist never runs. insertText replaces the composing region in place, so it + // finalizes to the same text rather than double-committing. + await session.send('Input.insertText', { text: committed + pending }) } test.describe('Repository Display Name IME composition', () => { diff --git a/tests/e2e/source-control-commit-draft-persistence.spec.ts b/tests/e2e/source-control-commit-draft-persistence.spec.ts index e4efd1fdafd..4607e027baf 100644 --- a/tests/e2e/source-control-commit-draft-persistence.spec.ts +++ b/tests/e2e/source-control-commit-draft-persistence.spec.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { openSourceControlForWorktree } from './helpers/worktree-registration' type E2eWorktree = { branchName: string @@ -46,67 +47,6 @@ function cleanupWorktree(repoPath: string, worktreePath: string, branchName: str } } -async function openSourceControlForWorktree( - page: Parameters[0], - repoPath: string, - targetWorktreePath: string -): Promise { - await page.evaluate( - async ({ repoPath, targetWorktreePath }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - await state.fetchRepos() - const repo = store.getState().repos.find((entry) => entry.path === repoPath) - if (!repo) { - throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) - } - - const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: listedWorktrees - } - })) - - const normalizeMacTmpPath = (value: string): string => - value.startsWith('/private/var/') ? value.slice('/private'.length) : value - const worktree = listedWorktrees.find( - (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) - ) - if (!worktree) { - throw new Error( - `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees - .map((entry) => entry.path) - .join(', ')}` - ) - } - - store.getState().setActiveWorktree(worktree.id) - const status = await window.api.git.status({ worktreePath: worktree.path }) - store.getState().setGitStatus(worktree.id, status) - store.getState().setRightSidebarOpen(true) - store.getState().setRightSidebarTab('source-control') - }, - { repoPath, targetWorktreePath } - ) - - await expect - .poll( - async () => - page.evaluate(() => { - const state = window.__store?.getState() - return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') - }), - { timeout: 5_000 } - ) - .toBe(true) -} - test.describe('Source Control commit draft persistence', () => { test('preserves a typed draft when the sidebar tab remounts', async ({ orcaPage, diff --git a/tests/e2e/source-control-commit-message-ai.spec.ts b/tests/e2e/source-control-commit-message-ai.spec.ts index 89c11f5f2a6..60fd8c49dc5 100644 --- a/tests/e2e/source-control-commit-message-ai.spec.ts +++ b/tests/e2e/source-control-commit-message-ai.spec.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { openSourceControlForWorktree } from './helpers/worktree-registration' function createWorktreeWithStagedChange(repoPath: string): { branchName: string @@ -50,67 +51,16 @@ test.describe('Source Control AI commit messages', () => { try { await waitForSessionReady(orcaPage) - await orcaPage.evaluate( - async ({ repoPath, worktreePath: targetWorktreePath, agentCommand: command }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - await state.fetchRepos() - const repo = store.getState().repos.find((entry) => entry.path === repoPath) - if (!repo) { - throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) - } - const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: listedWorktrees - } - })) - const normalizeMacTmpPath = (value: string): string => - value.startsWith('/private/var/') ? value.slice('/private'.length) : value - const worktree = listedWorktrees.find( - (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) - ) - if (!worktree) { - throw new Error( - `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees - .map((entry) => entry.path) - .join(', ')}` - ) - } - - store.getState().setActiveWorktree(worktree.id) - await store.getState().updateSettings({ - commitMessageAi: { - enabled: true, - agentId: 'custom', - selectedModelByAgent: {}, - selectedThinkingByModel: {}, - customPrompt: '', - customAgentCommand: command - } - }) - const status = await window.api.git.status({ worktreePath: worktree.path }) - store.getState().setGitStatus(worktree.id, status) - store.getState().setRightSidebarTab('source-control') - store.getState().setRightSidebarOpen(true) - }, - { repoPath: testRepoPath, worktreePath, agentCommand } - ) - - await expect - .poll( - async () => - orcaPage.evaluate(() => { - const state = window.__store?.getState() - return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') - }), - { timeout: 5_000 } - ) - .toBe(true) + await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath, { + commitMessageAi: { + enabled: true, + agentId: 'custom', + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: agentCommand + } + }) const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) await expect(textarea).toBeVisible({ timeout: 10_000 }) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 9d13159f0f9..fbea6ebf1fb 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -413,21 +413,6 @@ async function pressShiftedRussianLayoutKey(page: Page): Promise<{ }) } -// Why: handleRequestClosePane pops a "Close Terminal?" dialog when the pane -// reports a running child process. Under E2E, a freshly split pane's -// proc.process is briefly unset so the check returns true spuriously. Click -// Close when the dialog appears so the test's chord-routing assertion stays -// deterministic; no-op when it doesn't. -async function confirmCloseDialogIfShown(page: Page): Promise { - const confirmButton = page.getByRole('button', { name: 'Close', exact: true }) - try { - await confirmButton.waitFor({ state: 'visible', timeout: 500 }) - await confirmButton.click() - } catch { - // Dialog did not appear — pane closed directly. - } -} - async function pressAndExpectWrite( page: Page, app: ElectronApplication, @@ -460,6 +445,34 @@ const mod = isMac ? 'Meta' : 'Control' const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d` const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d' +// Why: a freshly split pane can transiently still report a running child, so +// poll for the confirm dialog and pane-count settling instead of a fixed wait. +async function closeActivePaneAndSettle(page: Page, expectedCount: number): Promise { + await focusActiveTerminal(page) + await page.keyboard.press(`${mod}+w`) + // The "Stop running command?" confirm surfaces a "Stop and Close" action when + // the pane still reports a running child. + const confirmButton = page.getByRole('button', { name: /Stop and Close/i }) + await expect + .poll( + async () => { + if (await confirmButton.isVisible().catch(() => false)) { + // Why: surface a click failure so a real actionability/strict-mode + // error isn't hidden behind the generic pane-count timeout. + await confirmButton.click().catch((err) => { + console.warn('closeActivePaneAndSettle: confirm click failed', err) + }) + } + return countVisibleTerminalPanes(page) + }, + { + timeout: 10_000, + message: `Expected ${expectedCount} visible terminal panes after close` + } + ) + .toBe(expectedCount) +} + // Why: serial mode is load-bearing. Tests mutate shared Electron app state // (pane layout, terminal buffer, expand toggle) and the pty:write spy log is // a single main-process singleton. Parallel execution would interleave chord @@ -749,6 +762,9 @@ test.describe('Terminal Shortcuts', () => { await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitVerticalChord) await waitForPaneCount(orcaPage, panesBeforeSplit + 1) + // Why: ensure the new split pane's PTY is actually bound before we later + // close it, so the close cycle can't race an in-progress split. + await waitForActivePanePtyId(orcaPage) // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). await focusActiveTerminal(orcaPage) @@ -781,25 +797,15 @@ test.describe('Terminal Shortcuts', () => { .toBe(false) // Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane). - // Why: the close handler checks hasChildProcesses async; a freshly - // spawned pane can transiently report a running child (node-pty's - // proc.process lags the spawn), which surfaces a confirmation dialog - // instead of closing immediately. Confirm it if it appears — the test - // only needs to prove the chord routed to the close handler. - await focusActiveTerminal(orcaPage) - await orcaPage.keyboard.press(`${mod}+w`) - await confirmCloseDialogIfShown(orcaPage) - await waitForPaneCount(orcaPage, panesBeforeSplit) + await closeActivePaneAndSettle(orcaPage, panesBeforeSplit) // Split horizontally (chord varies by platform — see splitHorizontalChord). const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitHorizontalChord) await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) - await focusActiveTerminal(orcaPage) - await orcaPage.keyboard.press(`${mod}+w`) - await confirmCloseDialogIfShown(orcaPage) - await waitForPaneCount(orcaPage, panesBeforeHSplit) + await waitForActivePanePtyId(orcaPage) + await closeActivePaneAndSettle(orcaPage, panesBeforeHSplit) // Cmd/Ctrl+F toggles the search overlay. await focusActiveTerminal(orcaPage) diff --git a/tests/e2e/workspace-space-git-status.spec.ts b/tests/e2e/workspace-space-git-status.spec.ts index 44102bd806c..45b756a662f 100644 --- a/tests/e2e/workspace-space-git-status.spec.ts +++ b/tests/e2e/workspace-space-git-status.spec.ts @@ -3,13 +3,19 @@ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' test.describe('Workspace Space git status checks', () => { test('checks every scanned deletable row, including rows after the first 50', async ({ orcaPage, testRepoPath }) => { - const worktreeParent = mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-')) + // Why: on symlinked tmpdirs (/var→/private/var on macOS, /tmp→… on CI) Orca + // registers worktrees under their realpath, so the parent must be canonical + // before `git worktree add` or the recorded paths won't match and rows drop. + const worktreeParent = realpathSync( + mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-')) + ) const worktreePaths = Array.from({ length: 60 }, (_, index) => path.join(worktreeParent, `worktree-${index}`) ) @@ -25,6 +31,22 @@ test.describe('Workspace Space git status checks', () => { realpathSync(worktreePath) ) + const repoId = await orcaPage.evaluate((testRepoPath) => { + const store = window.__store + if (!store) { + throw new Error('Expected e2e store to be exposed') + } + const repo = store.getState().repos.find((item) => item.path === testRepoPath) + if (!repo) { + throw new Error('Expected test repo to be loaded') + } + return repo.id + }, testRepoPath) + + // Why: the 60 worktrees were added via raw git, so poll past the 5s scan + // cache TTL until every path registers before deriving the space rows. + await loadWorktreesUntilPathsPresent(orcaPage, repoId, registeredWorktreePaths) + await orcaPage.evaluate( async ({ testRepoPath, worktreePaths }) => { const store = window.__store @@ -37,7 +59,6 @@ test.describe('Workspace Space git status checks', () => { if (!repo) { throw new Error('Expected test repo to be loaded') } - await initialState.fetchWorktrees(repo.id) await window.api.git.status({ worktreePath: worktreePaths[0] }) const state = store.getState() diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 352f57ab597..3c980756e8e 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -263,9 +263,12 @@ test.describe('Create Workspace', () => { } ) ipcMain.removeHandler('worktrees:resolvePrBase') + // Why: the fixture repo has no remote and its default branch name + // depends on the host's git init.defaultBranch (main vs master), so + // resolve the PR base to HEAD, which always exists regardless. ipcMain.handle('worktrees:resolvePrBase', () => { counters.__smartResolvePrBaseCount += 1 - return { baseBranch: 'origin/main' } + return { baseBranch: 'HEAD' } }) }, { title, url } @@ -299,6 +302,10 @@ test.describe('Create Workspace', () => { }) await expect(orcaPage.getByRole('option', { name: url })).toHaveCount(0) await expect(orcaPage.getByText('Linked PR #2049')).toBeVisible() + // Why: quick create reuses the single GitHub lookup from typing (no + // redundant re-fetch), and since #5733 ("Create PR worktrees from the PR + // head") it resolves the PR start point exactly once at submit time — so + // the base resolves once here rather than being skipped. await expect .poll(() => electronApp.evaluate(() => { @@ -312,7 +319,7 @@ test.describe('Create Workspace', () => { } }) ) - .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 }) + .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 1 }) } finally { await orcaPage .evaluate(() => { @@ -358,9 +365,10 @@ test.describe('Create Workspace', () => { }) ) ipcMain.removeHandler('worktrees:resolvePrBase') - // Why: the fixture repo's default branch is master and has no - // remote; resolve the PR base to a ref that actually exists. - ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'master' })) + // Why: the fixture repo has no remote and its default branch name + // depends on the host's git init.defaultBranch (main vs master), so + // resolve the PR base to HEAD, which always exists regardless. + ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'HEAD' })) }, { title, url } ) From f4faafa9874c188be461d3138c9040b6332b44c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:52:06 -0700 Subject: [PATCH 140/141] fix(daemon): relocate daemon host image out of the install-dir kill zone (#7473) Co-authored-by: Neil Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- .github/workflows/release-cut.yml | 8 + config/daemon-host-node-runtime.cjs | 41 +++ config/electron-builder.config.cjs | 5 + .../scripts/electron-builder-config.test.mjs | 62 +++++ ...package-electron-runtime-contract.test.mjs | 3 + .../daemon/daemon-host-relocation.test.ts | 100 +++++++ src/main/daemon/daemon-host-relocation.ts | 83 ++++++ src/main/daemon/daemon-init.ts | 53 +++- src/main/index.ts | 3 + .../install-dir-runtime-relocation.test.ts | 251 ++++++++++++++++++ .../pty/install-dir-runtime-relocation.ts | 145 ++++++++++ .../pty/node-pty-runtime-relocation.test.ts | 239 +---------------- src/main/pty/node-pty-runtime-relocation.ts | 134 +--------- 13 files changed, 756 insertions(+), 371 deletions(-) create mode 100644 config/daemon-host-node-runtime.cjs create mode 100644 src/main/daemon/daemon-host-relocation.test.ts create mode 100644 src/main/daemon/daemon-host-relocation.ts create mode 100644 src/main/pty/install-dir-runtime-relocation.test.ts create mode 100644 src/main/pty/install-dir-runtime-relocation.ts diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 96af9bd0cd1..a107536f302 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -878,6 +878,14 @@ jobs: } Get-Item -LiteralPath $file } + # The standalone node.exe that hosts the terminal daemon outside the + # install-dir kill zone the NSIS updater sweeps. See + # src/main/daemon/daemon-host-relocation.ts. + $daemonHostNode = 'dist/win-unpacked/resources/daemon-host/node.exe' + if (-not (Test-Path -LiteralPath $daemonHostNode -PathType Leaf)) { + throw "Missing Windows daemon-host runtime file: $daemonHostNode" + } + Get-Item -LiteralPath $daemonHostNode - name: Install SignPath PowerShell module if: matrix.platform == 'win' diff --git a/config/daemon-host-node-runtime.cjs b/config/daemon-host-node-runtime.cjs new file mode 100644 index 00000000000..944d9a94d5b --- /dev/null +++ b/config/daemon-host-node-runtime.cjs @@ -0,0 +1,41 @@ +const { copyFileSync, existsSync, mkdirSync } = require('node:fs') +const { join } = require('node:path') + +// Where the standalone daemon-host node.exe is staged under packaged resources. +// Mirrored at runtime by src/main/daemon/daemon-host-relocation.ts, which copies +// it into userData/daemon-host// and forks the terminal daemon from it. +const DAEMON_HOST_DIR = 'daemon-host' +const DAEMON_HOST_NODE_EXE = 'node.exe' + +/** + * Stages a standalone Windows node.exe into resources/daemon-host so the + * detached terminal daemon can be forked from a userData copy that lives + * outside the install directory the NSIS updater force-closes mid-update. + * + * Source is the build host's own node.exe (process.execPath). It is version- + * correct by construction: engines.node pins the build to Node 24.x, whose NAPI + * 10 runtime loads node-pty's Electron-42-built conpty.node. Being an official + * Node binary, it also carries the OpenJS Foundation Authenticode signature, so + * it ships validly signed regardless of whether SignPath re-signs nested PEs. + * + * Windows x64 only — the sole Windows build target. Other targets ship no + * node.exe and the runtime falls open to forking the install-dir Electron host. + */ +function ensurePackagedDaemonHostNode(resourcesDir, electronPlatformName) { + if (electronPlatformName !== 'win32') { + return + } + const nodeExePath = process.execPath + // The build must run under standalone Node (not Electron) so execPath is a + // real node.exe with a NAPI runtime; anything else would strand the daemon. + if (!/node\.exe$/i.test(nodeExePath) || !existsSync(nodeExePath)) { + throw new Error( + `[daemon-host] cannot stage node.exe: build host execPath is not a node.exe (${nodeExePath})` + ) + } + const destDir = join(resourcesDir, DAEMON_HOST_DIR) + mkdirSync(destDir, { recursive: true }) + copyFileSync(nodeExePath, join(destDir, DAEMON_HOST_NODE_EXE)) +} + +module.exports = { ensurePackagedDaemonHostNode, DAEMON_HOST_DIR, DAEMON_HOST_NODE_EXE } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a73..9880f0d9de4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,6 +7,7 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { ensurePackagedDaemonHostNode } = require('./daemon-host-node-runtime.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -128,6 +129,10 @@ module.exports = { return } prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) + // Why: stage the standalone node.exe that hosts the terminal daemon outside + // the install-dir kill zone the NSIS updater sweeps (win32 only; no-ops + // elsewhere). See src/main/daemon/daemon-host-relocation.ts. + ensurePackagedDaemonHostNode(resourcesDir, context.electronPlatformName) verifyPackagedMainRuntimeDeps(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3f318219eeb..ce03bd6b49a 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -17,6 +17,22 @@ const { prunePackagedZodSources, verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') +const { ensurePackagedDaemonHostNode } = require('../daemon-host-node-runtime.cjs') + +// process.execPath is read-only in the types; point it at a fixture node.exe. +function withStubbedExecPath(value, fn) { + const original = process.execPath + Object.defineProperty(process, 'execPath', { value, configurable: true, writable: true }) + try { + return fn() + } finally { + Object.defineProperty(process, 'execPath', { + value: original, + configurable: true, + writable: true + }) + } +} describe('electron-builder config', () => { it('excludes repo-only source trees from app.asar', () => { @@ -207,6 +223,52 @@ describe('electron-builder config', () => { } }) + it('stages the build host node.exe into resources/daemon-host for Windows', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-')) + try { + const fakeHostDir = join(root, 'host') + await mkdir(fakeHostDir, { recursive: true }) + const fakeNodeExe = join(fakeHostDir, 'node.exe') + await writeFile(fakeNodeExe, 'fake-node-binary', 'utf8') + const resourcesDir = join(root, 'resources') + await mkdir(resourcesDir, { recursive: true }) + + withStubbedExecPath(fakeNodeExe, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32')) + + await expect(readFile(join(resourcesDir, 'daemon-host', 'node.exe'), 'utf8')).resolves.toBe( + 'fake-node-binary' + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not stage a daemon-host node.exe on non-Windows platforms', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-skip-')) + try { + ensurePackagedDaemonHostNode(resourcesDir, 'linux') + await expect(readdir(resourcesDir)).resolves.toEqual([]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('fails the Windows build when the host binary is not a node.exe', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-bad-')) + try { + const notNode = join(root, 'electron.exe') + await writeFile(notNode, 'not-node', 'utf8') + const resourcesDir = join(root, 'resources') + await mkdir(resourcesDir, { recursive: true }) + + expect(() => + withStubbedExecPath(notNode, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32')) + ).toThrow(/not a node\.exe/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('includes @parcel/watcher in the packaged runtime closure', () => { // Why: the main process imports '@parcel/watcher' for filesystem change // events; if it is absent from the packaged closure the serve host silently diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c106b166feb..43a898dbca2 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -178,6 +178,9 @@ describe('Electron runtime package contract', () => { 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' ) expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') + // The daemon-host node.exe must be staged alongside the node-pty runtime so + // the terminal daemon can be forked from outside the install-dir kill zone. + expect(steps[verifyNodePtyIndex].run).toContain('resources/daemon-host/node.exe') const uploadThroughDownloadScript = steps .slice(uploadIndex, downloadIndex + 1) diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts new file mode 100644 index 00000000000..9656164091f --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -0,0 +1,100 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mutable Electron app stub, hoisted so the vi.mock factory can close over it. +const { electronApp } = vi.hoisted(() => ({ + electronApp: { + isPackaged: false, + userDataPath: '', + version: '0.0.0-test', + getPath: (): string => electronApp.userDataPath, + getVersion: (): string => electronApp.version + } +})) + +vi.mock('electron', () => ({ app: electronApp })) + +import { resolveDaemonHostSourceDir } from './daemon-host-relocation' + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-')) +}) + +afterEach(() => { + vi.unstubAllEnvs() + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} +}) + +describe('resolveDaemonHostSourceDir', () => { + it('returns the daemon-host dir when it holds a node.exe', () => { + const resources = join(tempDir, 'resources') + mkdirSync(join(resources, 'daemon-host'), { recursive: true }) + writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'host') + expect(resolveDaemonHostSourceDir(resources)).toBe(join(resources, 'daemon-host')) + }) + + it('returns null when the daemon-host dir has no node.exe', () => { + const resources = join(tempDir, 'resources') + mkdirSync(join(resources, 'daemon-host'), { recursive: true }) + expect(resolveDaemonHostSourceDir(resources)).toBeNull() + }) + + it('returns null when the daemon-host dir is absent', () => { + expect(resolveDaemonHostSourceDir(join(tempDir, 'resources'))).toBeNull() + }) +}) + +// process.resourcesPath is typed read-only; point it at a fixture for the test. +function stubResourcesPath(value: string | undefined): void { + Object.defineProperty(process, 'resourcesPath', { value, configurable: true, writable: true }) +} + +// Copies a real node.exe path shape; the win32 guard in the module means this +// only exercises the relocation path on Windows. +describe.runIf(process.platform === 'win32')('installRelocatedDaemonHost', () => { + const originalResourcesPath = process.resourcesPath + + afterEach(() => { + stubResourcesPath(originalResourcesPath) + vi.resetModules() + }) + + it('relocates a bundled node.exe and exposes its userData path', async () => { + const resources = join(tempDir, 'resources') + mkdirSync(join(resources, 'daemon-host'), { recursive: true }) + writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'fake-node-host') + stubResourcesPath(resources) + electronApp.userDataPath = join(tempDir, 'userData') + electronApp.version = '9.9.9' + vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1') + + // Fresh module so the one-shot install/singleton state is not carried over. + vi.resetModules() + const mod = await import('./daemon-host-relocation') + mod.installRelocatedDaemonHost() + + const execPath = mod.getRelocatedDaemonHostExecPath() + expect(execPath).toBe(join(electronApp.userDataPath, 'daemon-host', '9.9.9', 'node.exe')) + expect(readFileSync(execPath!, 'utf8')).toBe('fake-node-host') + }) + + it('fails open to null when no bundled node.exe is present', async () => { + const resources = join(tempDir, 'resources') + mkdirSync(resources, { recursive: true }) + stubResourcesPath(resources) + electronApp.userDataPath = join(tempDir, 'userData') + vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1') + + vi.resetModules() + const mod = await import('./daemon-host-relocation') + mod.installRelocatedDaemonHost() + + expect(mod.getRelocatedDaemonHostExecPath()).toBeNull() + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts new file mode 100644 index 00000000000..21cdcffe3c7 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.ts @@ -0,0 +1,83 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' +import { ensureRelocatedRuntime } from '../pty/install-dir-runtime-relocation' + +/** + * Relocates the Node host that runs the detached terminal daemon out of the + * app install directory into userData. + * + * Why: the daemon is `fork()`ed as plain Node via ELECTRON_RUN_AS_NODE, so its + * process image is the install-dir Orca.exe. The NSIS update installer + * force-closes every process whose image path is under the install directory + * before replacing files, which kills the daemon — and every live terminal it + * owns — mid-update. Running the daemon from a version-keyed node.exe staged in + * userData takes its image out of the installer's kill zone so it survives. + * + * Fail-open: if no bundled node.exe is present (dev, or a build that predates + * shipping it), relocation no-ops and the caller keeps forking the install-dir + * Orca.exe host — the pre-relocation behavior, with zero regression. + */ +const RELOCATED_DAEMON_HOST_EXE = 'node.exe' + +let relocatedDaemonHostExecPath: string | null = null +let installed = false + +/** + * The bundled daemon-host dir under app resources, or null when no node.exe is + * shipped there. Kept separate from install so the resources path is testable. + */ +export function resolveDaemonHostSourceDir(resourcesPath: string): string | null { + const dir = join(resourcesPath, 'daemon-host') + return existsSync(join(dir, RELOCATED_DAEMON_HOST_EXE)) ? dir : null +} + +/** + * Copies the bundled daemon-host node.exe into a version-keyed userData dir + * (once) and records its path for the daemon fork. Safe to call more than once; + * only the first call does work. + */ +export function installRelocatedDaemonHost(): void { + if (installed) { + return + } + installed = true + if (process.platform !== 'win32') { + return + } + // The install-dir kill zone only exists for packaged installs; a dev override + // lets the relocation path be exercised without a full NSIS build. + if (!app.isPackaged && process.env.ORCA_FORCE_DAEMON_HOST_RELOCATION !== '1') { + return + } + // Why: fail-open contract — this must never throw. resourcesPath is unset + // outside a real Electron process (tests, node-hosted tooling). + if (typeof process.resourcesPath !== 'string') { + return + } + const sourceDir = resolveDaemonHostSourceDir(process.resourcesPath) + if (!sourceDir) { + return + } + const userData = app.getPath('userData') + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot: join(userData, 'daemon-host'), + version: app.getVersion(), + // Same runtimeDir daemon-init writes daemon-v.pid into, so a surviving + // daemon's host dir is pinned by its recorded appVersion and never reclaimed + // while it runs. + daemonRuntimeDir: join(userData, 'daemon') + }) + if (destDir) { + relocatedDaemonHostExecPath = join(destDir, RELOCATED_DAEMON_HOST_EXE) + } +} + +/** + * The relocated node.exe to fork the daemon from, or null to fall back to the + * install-dir Electron host (ELECTRON_RUN_AS_NODE). + */ +export function getRelocatedDaemonHostExecPath(): string | null { + return relocatedDaemonHostExecPath +} diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index ff18ecc3397..68c492a249e 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -36,6 +36,11 @@ import { isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' +import { + getRelocatedDaemonHostExecPath, + installRelocatedDaemonHost +} from './daemon-host-relocation' +import { startSpan } from '../observability/tracer' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getLocalPtyProvider, @@ -153,6 +158,16 @@ function createPreservedDaemonHandle( protocolVersion = PROTOCOL_VERSION, mode?: 'degraded-new-pty-fallback' ): DaemonProcessHandle { + // Why: the trace file is the only artifact available in field + // investigations of update-survival incidents; adopt-vs-fresh-fork is the + // first question every one of them asks. + const adoptSpan = startSpan('daemon.adopt', { + attributes: { + 'daemon.protocol_version': protocolVersion, + ...(mode ? { 'daemon.mode': mode } : {}) + } + }) + adoptSpan.end() const handle: DaemonProcessHandle = { shutdown: async () => { await cleanupDaemonForProtocol(runtimeDir, protocolVersion) @@ -260,6 +275,25 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { await killStaleDaemon(runtimeDir, socketPath, tokenPath) const userDataPath = app.getPath('userData') + // Why: staged here instead of app startup so the ~70MB node.exe copy stays + // off the first-paint path, and is skipped entirely on launches that adopt + // a live daemon (no fork). Latched — repeat calls are free. + installRelocatedDaemonHost() + // Why: on Windows a relocated node.exe in userData hosts the daemon so its + // process image escapes the install-dir the NSIS updater force-closes; when + // absent (dev, non-win32, pre-relocation builds) fall back to the install-dir + // Electron host run as plain Node via ELECTRON_RUN_AS_NODE. + const relocatedHostExecPath = getRelocatedDaemonHostExecPath() + // Why: without this span a silent fallback to the install-dir host (back + // inside the updater kill zone) is indistinguishable from the fixed path + // in field trace files. + const forkSpan = startSpan('daemon.fork', { + attributes: { + 'daemon.host': relocatedHostExecPath ? 'relocated-node' : 'install-dir-electron', + ...(relocatedHostExecPath ? { 'daemon.host_exec_path': relocatedHostExecPath } : {}), + 'app.version': app.getVersion() + } + }) const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { // Why: detached daemons can outlive dev worktrees. Starting from // userData keeps process.cwd() valid after a repo/worktree is deleted. @@ -269,13 +303,17 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // open, which would prevent Electron from exiting cleanly. detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. + // Why: a standalone node.exe is already plain Node; reset execArgv so the + // Electron main process's node flags don't leak into it. + ...(relocatedHostExecPath ? { execPath: relocatedHostExecPath, execArgv: [] } : {}), env: { ...process.env, - ELECTRON_RUN_AS_NODE: '1', + // Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary run as a + // plain Node.js process instead of an Electron main process. Without it, + // Electron's GPU/display initialization can interfere with native module + // operations like node-pty's posix_spawn of the spawn-helper. A relocated + // node.exe is already plain Node, so the var is omitted there. + ...(relocatedHostExecPath ? {} : { ELECTRON_RUN_AS_NODE: '1' }), // Why: the detached daemon is plain Node and cannot call Electron's // app.getPath(), but shell-ready rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath @@ -307,6 +345,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Already dead } } + forkSpan.fail(error) reject(error) } function onReadyMessage(msg: unknown): void { @@ -318,6 +357,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Why: the daemon process is detached after readiness; leaving // startup listeners attached retains this launch promise closure. cleanupStartupListeners() + if (child.pid !== undefined) { + forkSpan.setAttribute('daemon.pid', child.pid) + } + forkSpan.end() if (child.pid) { // Why: JSON pid file carries pid + process start time so later // killStaleDaemon() can verify the pid still belongs to the daemon diff --git a/src/main/index.ts b/src/main/index.ts index a361df3e3ac..7eccdffc7a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -578,6 +578,9 @@ if (hasSingleInstanceLock) { initOpenCodeUsagePath() // Why: must run before anything loads node-pty (first PTY spawn) so main // and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR. + // (The daemon-host node.exe relocation deliberately does NOT run here: its + // ~70MB copy would sit on the first-paint path. daemon-init stages it at + // fork time instead.) installRelocatedNodePtyNativeRuntime() crashReports = CrashReportStore.fromUserData() recordCrashBreadcrumb('app_started', { diff --git a/src/main/pty/install-dir-runtime-relocation.test.ts b/src/main/pty/install-dir-runtime-relocation.test.ts new file mode 100644 index 00000000000..0844b5a8322 --- /dev/null +++ b/src/main/pty/install-dir-runtime-relocation.test.ts @@ -0,0 +1,251 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner' +import type { DaemonPidFile } from '../daemon/daemon-spawner' +import { + collectInUseRuntimeVersions, + ensureRelocatedRuntime +} from './install-dir-runtime-relocation' + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'install-dir-relocation-')) +}) + +afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} +}) + +// A generic runtime tree with a nested dir and a .pdb symbol file, to exercise +// recursive copy and the symbol-exclusion rule independent of any one runtime. +function seedSourceDir(dir: string): void { + mkdirSync(join(dir, 'nested'), { recursive: true }) + writeFileSync(join(dir, 'runtime.exe'), 'host') + writeFileSync(join(dir, 'runtime.dll'), 'lib') + writeFileSync(join(dir, 'runtime.pdb'), 'symbols') + writeFileSync(join(dir, 'nested', 'inner.bin'), 'inner') +} + +// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v.pid). +function writeDaemonPidFile( + daemonRuntimeDir: string, + protocolVersion: number, + pidFile: DaemonPidFile +): void { + mkdirSync(daemonRuntimeDir, { recursive: true }) + writeFileSync( + getDaemonPidPath(daemonRuntimeDir, protocolVersion), + serializeDaemonPidFile(pidFile) + ) +} + +// Deterministic liveness that treats the given pids as running, so tests never +// depend on the host's real process table. +function aliveFor(...alivePids: number[]): (pid: number) => boolean { + const set = new Set(alivePids) + return (pid) => set.has(pid) +} + +describe('collectInUseRuntimeVersions', () => { + it('returns an empty set when the daemon dir does not exist', () => { + expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0) + }) + + it('collects the app version a live daemon pins', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321)) + + expect([...inUse]).toEqual(['1.4.124-rc.1']) + }) + + it('omits the version of a daemon whose pid is dead', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */)) + + expect(inUse.size).toBe(0) + }) + + it('collects one version per live daemon across protocol versions', () => { + const daemonDir = join(tempDir, 'daemon') + writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' }) + writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200)) + + expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2']) + }) + + it('ignores a daemon whose pid file records no app version', () => { + const daemonDir = join(tempDir, 'daemon') + // Pre-relocation daemon: JSON pid file without appVersion. + writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null }) + // Legacy daemon: bare-integer pid file (appVersion parses to null). + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(getDaemonPidPath(daemonDir, 17), '9999') + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999)) + + expect(inUse.size).toBe(0) + }) + + it('ignores non-pid files in the daemon dir', () => { + const daemonDir = join(tempDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret') + writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x') + writeFileSync(join(daemonDir, 'notes.txt'), 'x') + + expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0) + }) + + it('skips a malformed pid file without throwing', () => { + const daemonDir = join(tempDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all') + writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' }) + + const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200)) + + expect([...inUse]).toEqual(['2.0.0']) + }) + + it('treats the current process as alive under the default liveness probe', () => { + const daemonDir = join(tempDir, 'daemon') + // startedAtMs null so startTimeMatches short-circuits true on every platform. + writeDaemonPidFile(daemonDir, 18, { + pid: process.pid, + startedAtMs: null, + appVersion: '3.0.0' + }) + + expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0']) + }) +}) + +describe('ensureRelocatedRuntime', () => { + it('copies the runtime tree (without symbols) and returns the version dir', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) + + expect(destDir).toBe(join(destRoot, '1.2.3')) + expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host') + expect(readFileSync(join(destDir!, 'runtime.dll'), 'utf8')).toBe('lib') + expect(readFileSync(join(destDir!, 'nested', 'inner.bin'), 'utf8')).toBe('inner') + expect(existsSync(join(destDir!, 'runtime.pdb'))).toBe(false) + }) + + it('skips recopying once the completion marker exists', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) + + writeFileSync(join(sourceDir, 'runtime.exe'), 'changed-after-first-copy') + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) + + expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host') + }) + + it('redoes an interrupted copy that has no completion marker', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + mkdirSync(join(destRoot, '1.2.3'), { recursive: true }) + writeFileSync(join(destRoot, '1.2.3', 'runtime.exe'), 'torn partial copy') + + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) + + expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host') + }) + + it('reclaims a stale version dir once no live daemon pins it', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + const daemonRuntimeDir = join(tempDir, 'daemon') + ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) + + // The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0. + writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) + + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '2.0.0', + daemonRuntimeDir, + isDaemonPidAlive: aliveFor(/* 4321 is dead */) + }) + + expect(destDir).toBe(join(destRoot, '2.0.0')) + expect(existsSync(join(destRoot, '1.0.0'))).toBe(false) + expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true) + }) + + it('preserves a stale version dir a surviving daemon still pins', () => { + const sourceDir = join(tempDir, 'source') + seedSourceDir(sourceDir) + const destRoot = join(tempDir, 'dest') + const daemonRuntimeDir = join(tempDir, 'daemon') + ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) + + // The 1.0.0 daemon survived the update and still loads its 1.0.0 runtime dir + // on demand — deleting it would strand the running daemon. + writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) + + const destDir = ensureRelocatedRuntime({ + sourceDir, + destRoot, + version: '2.0.0', + daemonRuntimeDir, + isDaemonPidAlive: aliveFor(4321) + }) + + expect(destDir).toBe(join(destRoot, '2.0.0')) + expect(existsSync(join(destRoot, '1.0.0', 'runtime.exe'))).toBe(true) + expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true) + }) + + it('fails open when the source dir is missing', () => { + expect( + ensureRelocatedRuntime({ + sourceDir: join(tempDir, 'does-not-exist'), + destRoot: join(tempDir, 'dest'), + version: '1.2.3', + daemonRuntimeDir: join(tempDir, 'daemon') + }) + ).toBeNull() + }) +}) diff --git a/src/main/pty/install-dir-runtime-relocation.ts b/src/main/pty/install-dir-runtime-relocation.ts new file mode 100644 index 00000000000..21ce82fd03e --- /dev/null +++ b/src/main/pty/install-dir-runtime-relocation.ts @@ -0,0 +1,145 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health' + +/** + * Relocates a per-version runtime directory out of the app install directory + * into userData, and reclaims old version dirs no live daemon still pins. + * + * Why: the Windows NSIS update installer force-closes every process (and, for + * the terminal runtime, every native binary loaded from a process) whose image + * lives under the install directory before replacing files. Copying the runtime + * to a version-keyed userData dir takes it out of that kill zone so the detached + * daemon and its PTYs survive updates. Both node-pty's native runtime and the + * daemon's own Node host share this machinery. + */ +export const RELOCATION_COMPLETE_MARKER = '.relocation-complete' + +function copyRuntimeTree(sourceDir: string, destDir: string): void { + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + const destPath = join(destDir, entry.name) + if (entry.isDirectory()) { + copyRuntimeTree(sourcePath, destPath) + } else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) { + copyFileSync(sourcePath, destPath) + } + } +} + +export type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean + +function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean { + try { + process.kill(pid, 0) + } catch { + return false + } + return startTimeMatches(pid, startedAtMs) +} + +/** + * Runtime version dirs still claimed by a live daemon, read from the daemon + * pid files under `daemonRuntimeDir` (`daemon-v.pid`). + * + * Why: a daemon deliberately survives app updates, and it loads its version's + * relocated runtime (node-pty binaries, and — once relocated — its own Node + * host) on demand. Each pid file records that `appVersion`, so a live daemon's + * version dir must never be reclaimed while the process is still running. + */ +export function collectInUseRuntimeVersions( + daemonRuntimeDir: string, + isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault +): Set { + const inUse = new Set() + let entries + try { + entries = readdirSync(daemonRuntimeDir, { withFileTypes: true }) + } catch { + return inUse + } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8')) + } catch { + continue + } + // appVersion null => a pre-relocation daemon that loads from the install + // dir and thus pins no version dir here. + if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) { + inUse.add(parsed.appVersion) + } + } + return inUse +} + +function removeStaleRuntimeVersions( + destRoot: string, + keepVersion: string, + inUseVersions: ReadonlySet +): void { + let entries + try { + entries = readdirSync(destRoot, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) { + continue + } + // Why: Windows maps the daemon's binaries with FILE_SHARE_DELETE, so a + // rename/delete succeeds even while a surviving daemon still needs them — + // a rename heuristic would delete in-use dirs and strand the daemon. Only + // dirs no live daemon claims via its pid file are safe to remove. + try { + rmSync(join(destRoot, entry.name), { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } + } +} + +/** + * Ensures `sourceDir` is copied to `destRoot/version` (once, guarded by a + * completion marker) and reclaims sibling version dirs no live daemon pins. + * Returns the version dir, or null on any failure (callers fail open to + * loading from the install dir — the pre-relocation behavior). + */ +export function ensureRelocatedRuntime(options: { + sourceDir: string + destRoot: string + version: string + daemonRuntimeDir: string + isDaemonPidAlive?: IsDaemonPidAlive +}): string | null { + const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options + const destDir = join(destRoot, version) + try { + // Why: the marker is written only after a full copy, so a crash mid-copy + // leaves no marker and the next launch redoes the copy from scratch. + if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) { + rmSync(destDir, { recursive: true, force: true }) + copyRuntimeTree(sourceDir, destDir) + writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '') + } + const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive) + removeStaleRuntimeVersions(destRoot, version, inUseVersions) + return destDir + } catch { + return null + } +} diff --git a/src/main/pty/node-pty-runtime-relocation.test.ts b/src/main/pty/node-pty-runtime-relocation.test.ts index 5b4ce4886e9..e5f9fbd61ac 100644 --- a/src/main/pty/node-pty-runtime-relocation.test.ts +++ b/src/main/pty/node-pty-runtime-relocation.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import os from 'node:os' import { dirname, join } from 'node:path' @@ -12,13 +12,8 @@ vi.mock('electron', () => ({ } })) -import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner' -import type { DaemonPidFile } from '../daemon/daemon-spawner' -import { - collectInUseRuntimeVersions, - ensureRelocatedNodePtyNativeRuntime, - resolveNodePtyNativeSourceDir -} from './node-pty-runtime-relocation' +import { ensureRelocatedRuntime } from './install-dir-runtime-relocation' +import { resolveNodePtyNativeSourceDir } from './node-pty-runtime-relocation' let tempDir: string @@ -34,34 +29,6 @@ afterEach(() => { } catch {} }) -function seedSourceDir(dir: string): void { - mkdirSync(join(dir, 'conpty'), { recursive: true }) - writeFileSync(join(dir, 'conpty.node'), 'binding') - writeFileSync(join(dir, 'conpty_console_list.node'), 'listing') - writeFileSync(join(dir, 'pty.node'), 'winpty-binding') - writeFileSync(join(dir, 'winpty-agent.exe'), 'agent') - writeFileSync(join(dir, 'conpty.pdb'), 'symbols') - writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll') - writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host') -} - -// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v.pid). -function writeDaemonPidFile( - daemonRuntimeDir: string, - protocolVersion: number, - pidFile: DaemonPidFile -): void { - mkdirSync(daemonRuntimeDir, { recursive: true }) - writeFileSync(getDaemonPidPath(daemonRuntimeDir, protocolVersion), serializeDaemonPidFile(pidFile)) -} - -// Deterministic liveness that treats the given pids as running, so tests never -// depend on the host's real process table. -function aliveFor(...alivePids: number[]): (pid: number) => boolean { - const set = new Set(alivePids) - return (pid) => set.has(pid) -} - describe('resolveNodePtyNativeSourceDir', () => { it('prefers the rebuilt build/Release binding over prebuilds', () => { const pkg = join(tempDir, 'node-pty') @@ -86,204 +53,6 @@ describe('resolveNodePtyNativeSourceDir', () => { }) }) -describe('collectInUseRuntimeVersions', () => { - it('returns an empty set when the daemon dir does not exist', () => { - expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0) - }) - - it('collects the app version a live daemon pins', () => { - const daemonDir = join(tempDir, 'daemon') - writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) - - const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321)) - - expect([...inUse]).toEqual(['1.4.124-rc.1']) - }) - - it('omits the version of a daemon whose pid is dead', () => { - const daemonDir = join(tempDir, 'daemon') - writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' }) - - const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */)) - - expect(inUse.size).toBe(0) - }) - - it('collects one version per live daemon across protocol versions', () => { - const daemonDir = join(tempDir, 'daemon') - writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' }) - writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' }) - - const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200)) - - expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2']) - }) - - it('ignores a daemon whose pid file records no app version', () => { - const daemonDir = join(tempDir, 'daemon') - // Pre-relocation daemon: JSON pid file without appVersion. - writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null }) - // Legacy daemon: bare-integer pid file (appVersion parses to null). - mkdirSync(daemonDir, { recursive: true }) - writeFileSync(getDaemonPidPath(daemonDir, 17), '9999') - - const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999)) - - expect(inUse.size).toBe(0) - }) - - it('ignores non-pid files in the daemon dir', () => { - const daemonDir = join(tempDir, 'daemon') - mkdirSync(daemonDir, { recursive: true }) - writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret') - writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x') - writeFileSync(join(daemonDir, 'notes.txt'), 'x') - - expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0) - }) - - it('skips a malformed pid file without throwing', () => { - const daemonDir = join(tempDir, 'daemon') - mkdirSync(daemonDir, { recursive: true }) - writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all') - writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' }) - - const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200)) - - expect([...inUse]).toEqual(['2.0.0']) - }) - - it('treats the current process as alive under the default liveness probe', () => { - const daemonDir = join(tempDir, 'daemon') - // startedAtMs null so startTimeMatches short-circuits true on every platform. - writeDaemonPidFile(daemonDir, 18, { - pid: process.pid, - startedAtMs: null, - appVersion: '3.0.0' - }) - - expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0']) - }) -}) - -describe('ensureRelocatedNodePtyNativeRuntime', () => { - it('copies the runtime tree (without symbols) and returns the version dir', () => { - const sourceDir = join(tempDir, 'source') - seedSourceDir(sourceDir) - const destRoot = join(tempDir, 'dest') - - const destDir = ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '1.2.3', - daemonRuntimeDir: join(tempDir, 'daemon') - }) - - expect(destDir).toBe(join(destRoot, '1.2.3')) - expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') - expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host') - expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll') - expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false) - }) - - it('skips recopying once the completion marker exists', () => { - const sourceDir = join(tempDir, 'source') - seedSourceDir(sourceDir) - const destRoot = join(tempDir, 'dest') - ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '1.2.3', - daemonRuntimeDir: join(tempDir, 'daemon') - }) - - writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy') - const destDir = ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '1.2.3', - daemonRuntimeDir: join(tempDir, 'daemon') - }) - - expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') - }) - - it('redoes an interrupted copy that has no completion marker', () => { - const sourceDir = join(tempDir, 'source') - seedSourceDir(sourceDir) - const destRoot = join(tempDir, 'dest') - mkdirSync(join(destRoot, '1.2.3'), { recursive: true }) - writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy') - - const destDir = ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '1.2.3', - daemonRuntimeDir: join(tempDir, 'daemon') - }) - - expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding') - }) - - it('reclaims a stale version dir once no live daemon pins it', () => { - const sourceDir = join(tempDir, 'source') - seedSourceDir(sourceDir) - const destRoot = join(tempDir, 'dest') - const daemonRuntimeDir = join(tempDir, 'daemon') - ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) - - // The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0. - writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) - - const destDir = ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '2.0.0', - daemonRuntimeDir, - isDaemonPidAlive: aliveFor(/* 4321 is dead */) - }) - - expect(destDir).toBe(join(destRoot, '2.0.0')) - expect(existsSync(join(destRoot, '1.0.0'))).toBe(false) - expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true) - }) - - it('preserves a stale version dir a surviving daemon still pins', () => { - const sourceDir = join(tempDir, 'source') - seedSourceDir(sourceDir) - const destRoot = join(tempDir, 'dest') - const daemonRuntimeDir = join(tempDir, 'daemon') - ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir }) - - // The 1.0.0 daemon survived the update and still loads conpty.dll from its - // 1.0.0 runtime dir on every new spawn — deleting it would strand it. - writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' }) - - const destDir = ensureRelocatedNodePtyNativeRuntime({ - sourceDir, - destRoot, - version: '2.0.0', - daemonRuntimeDir, - isDaemonPidAlive: aliveFor(4321) - }) - - expect(destDir).toBe(join(destRoot, '2.0.0')) - expect(existsSync(join(destRoot, '1.0.0', 'conpty.node'))).toBe(true) - expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true) - }) - - it('fails open when the source dir is missing', () => { - expect( - ensureRelocatedNodePtyNativeRuntime({ - sourceDir: join(tempDir, 'does-not-exist'), - destRoot: join(tempDir, 'dest'), - version: '1.2.3', - daemonRuntimeDir: join(tempDir, 'daemon') - }) - ).toBeNull() - }) -}) - // Loads the real win32 conpty binding, so it cannot run on other platforms. describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => { it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => { @@ -292,7 +61,7 @@ describe.runIf(process.platform === 'win32')('patched node-pty loader override', const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir) expect(sourceDir).not.toBeNull() - const destDir = ensureRelocatedNodePtyNativeRuntime({ + const destDir = ensureRelocatedRuntime({ sourceDir: sourceDir!, destRoot: join(tempDir, 'relocated-runtime'), version: 'loader-test', diff --git a/src/main/pty/node-pty-runtime-relocation.ts b/src/main/pty/node-pty-runtime-relocation.ts index 0e8600662eb..7c1631d7816 100644 --- a/src/main/pty/node-pty-runtime-relocation.ts +++ b/src/main/pty/node-pty-runtime-relocation.ts @@ -1,16 +1,8 @@ -import { - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { app } from 'electron' -import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health' +import { ensureRelocatedRuntime } from './install-dir-runtime-relocation' /** * Relocates node-pty's Windows native runtime (conpty.node, conpty.dll, @@ -26,8 +18,6 @@ import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health' */ export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR' -const RELOCATION_COMPLETE_MARKER = '.relocation-complete' - export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null { // Packaged builds carry the rebuilt binding in build/Release; dev installs // (and the forced-relocation test path) load from prebuilds. @@ -43,124 +33,6 @@ export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string return null } -function copyRuntimeTree(sourceDir: string, destDir: string): void { - mkdirSync(destDir, { recursive: true }) - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - const destPath = join(destDir, entry.name) - if (entry.isDirectory()) { - copyRuntimeTree(sourcePath, destPath) - } else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) { - copyFileSync(sourcePath, destPath) - } - } -} - -type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean - -function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean { - try { - process.kill(pid, 0) - } catch { - return false - } - return startTimeMatches(pid, startedAtMs) -} - -/** - * Runtime version dirs still claimed by a live daemon, read from the daemon - * pid files under `daemonRuntimeDir` (`daemon-v.pid`). - * - * Why: a daemon deliberately survives app updates, and it was forked with - * ORCA_NODE_PTY_NATIVE_DIR pinned to its own app-version runtime dir — which - * it reloads conpty.dll from on every new spawn. Each pid file records that - * `appVersion`, so a live daemon's version dir must never be reclaimed. - */ -export function collectInUseRuntimeVersions( - daemonRuntimeDir: string, - isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault -): Set { - const inUse = new Set() - let entries - try { - entries = readdirSync(daemonRuntimeDir, { withFileTypes: true }) - } catch { - return inUse - } - for (const entry of entries) { - if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { - continue - } - let parsed - try { - parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8')) - } catch { - continue - } - // appVersion null => a pre-relocation daemon that loads from the install - // dir and thus pins no version dir here. - if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) { - inUse.add(parsed.appVersion) - } - } - return inUse -} - -function removeStaleRuntimeVersions( - destRoot: string, - keepVersion: string, - inUseVersions: ReadonlySet -): void { - let entries - try { - entries = readdirSync(destRoot, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) { - continue - } - // Why: Windows maps the daemon's conpty.dll/OpenConsole.exe with - // FILE_SHARE_DELETE, so a rename/delete succeeds even while a surviving - // daemon still needs them — the old rename heuristic deleted in-use dirs - // and stranded the daemon (new spawn -> conpty.dll ERROR_PATH_NOT_FOUND). - // Only dirs no live daemon claims via its pid file are safe to remove. - try { - rmSync(join(destRoot, entry.name), { recursive: true, force: true }) - } catch { - // Still locked or already gone — retry on a future launch. - } - } -} - -export function ensureRelocatedNodePtyNativeRuntime(options: { - sourceDir: string - destRoot: string - version: string - daemonRuntimeDir: string - isDaemonPidAlive?: IsDaemonPidAlive -}): string | null { - const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options - const destDir = join(destRoot, version) - try { - // Why: the marker is written only after a full copy, so a crash mid-copy - // leaves no marker and the next launch redoes the copy from scratch. - if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) { - rmSync(destDir, { recursive: true, force: true }) - copyRuntimeTree(sourceDir, destDir) - writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '') - } - const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive) - removeStaleRuntimeVersions(destRoot, version, inUseVersions) - return destDir - } catch { - // Fail open: node-pty keeps loading from the install dir, which is the - // pre-relocation behavior (sessions then don't survive updates). - return null - } -} - export function installRelocatedNodePtyNativeRuntime(): void { if (process.platform !== 'win32') { return @@ -184,7 +56,7 @@ export function installRelocatedNodePtyNativeRuntime(): void { return } const userData = app.getPath('userData') - const destDir = ensureRelocatedNodePtyNativeRuntime({ + const destDir = ensureRelocatedRuntime({ sourceDir, destRoot: join(userData, 'node-pty-runtime'), version: app.getVersion(), From 61bd98db6faacb8baffa0de369b187c0e40d662a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:52:58 +0000 Subject: [PATCH 141/141] release: v1.4.124-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2054920622b..b6a585da685 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.4", + "version": "1.4.124-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai",