From 59a9b44e2fd0f939d0bae11e36d44b2f7005edd7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:24:11 -0700 Subject: [PATCH 01/57] Fix terminal font chooser filtering (#6121) --- ...ngsFormControls.font-autocomplete.test.tsx | 29 +++++++++++++++++++ .../settings/SettingsFormControls.tsx | 8 ++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index c2cab9dcee0..f7f43cff2ed 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -82,6 +82,35 @@ describe('FontAutocomplete', () => { ).toBe('JetBrains Mono') }) + it('shows the full list on focus when the committed font has multiple matching suggestions', async () => { + function Harness(): ReactNode { + const [value, setValue] = useState('Cascadia Mono') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toEqual([ + 'Arial', + 'Cascadia Code', + 'Cascadia Mono', + 'Cascadia Mono PL', + 'Consolas' + ]) + }) + it('keeps typed searches filtered even after the value updates', async () => { function Harness(): ReactNode { const [value, setValue] = useState('Geist') diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 6af9a0f9511..d60a72c6900 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -630,12 +630,10 @@ export function FontAutocomplete({ () => filterFontSuggestions(suggestions, query), [suggestions, query] ) - // Why: a committed font fills the input, but typed searches can also mirror - // into `value`; only expand exact matches outside an active search session. + // Why: the committed font fills the input, but opening the chooser should + // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = - !isFilteringQuery && normalizedQuery === normalizedValue && filteredSuggestions.length <= 1 - ? suggestions - : filteredSuggestions + !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter From ac3ab72b522a3e57414ab87debccd2680acdaa2b Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:27:38 -0700 Subject: [PATCH 02/57] fix(jira): surface issue-search failures instead of a misleading empty list (#5958) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/main/jira/issues.test.ts | 69 ++++++++++++++++- src/main/jira/issues.ts | 65 +++++++++++++--- src/renderer/src/components/TaskPage.tsx | 69 ++++++++++++++++- .../task-page-jira-load-state.test.ts | 66 ++++++++++++++++ .../components/task-page-jira-load-state.ts | 76 +++++++++++++++++++ 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 +- src/renderer/src/store/slices/jira.test.ts | 20 ++++- src/renderer/src/store/slices/jira.ts | 18 ++++- 12 files changed, 371 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/components/task-page-jira-load-state.test.ts create mode 100644 src/renderer/src/components/task-page-jira-load-state.ts diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index befbbeed95c..49a49a46076 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -18,10 +18,10 @@ vi.mock('./client', () => ({ jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) })) -function makeEntry(): JiraClientForSite { +function makeEntry(id = 'site-1'): JiraClientForSite { return { site: { - id: 'site-1', + id, siteUrl: 'https://example.atlassian.net', email: 'ada@example.com', displayName: 'Example Jira', @@ -60,6 +60,71 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('rejects single-site search failures so the UI can surface them', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden') + }) + + it('includes Jira status codes in surfaced single-site search failures', async () => { + const error = Object.assign(new Error('Forbidden'), { status: 403 }) + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(error) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow( + 'Error 403: Forbidden' + ) + }) + + it('keeps healthy sites when one site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('keeps healthy sites when the saved selection fans out without an explicit site', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('surfaces an error when every site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock + .mockRejectedValueOnce(new Error('Forbidden')) + .mockRejectedValueOnce(new Error('Service Unavailable')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden') + }) + + it('prefers operational failures when every "all" search site fails', async () => { + const authError = new Error('Unauthorized') + const operationalError = new Error('Service Unavailable') + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + isAuthErrorMock.mockImplementation((error) => error === authError) + jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable') + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + it('paginates Jira project search results before sorting them', async () => { jiraRequestMock .mockResolvedValueOnce({ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 60a7dc49f8e..3bf8237349c 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number { return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) } -function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean { - return selection !== 'all' +type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 } function asRecord(value: unknown): JiraRecord { @@ -345,26 +375,37 @@ export async function searchIssues( return [] } const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) const results = await Promise.all( - entries.map(async (entry) => { + entries.map(async (entry, index) => { await acquire() try { return await searchIssuesForClient(entry, jql.trim(), safeLimit) } catch (error) { - if (isAuthError(error)) { + const authFailure = isAuthError(error) + if (authFailure) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { - throw error - } - } else { - console.warn('[jira] searchIssues failed:', error) } - return [] + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] } finally { release() } }) ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } return entries.length === 1 ? results.flat().slice(0, safeLimit) : sortAndLimitIssues(results.flat(), safeLimit) @@ -388,7 +429,7 @@ export async function getIssue( } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { + if (shouldSurfaceSiteFailure(siteId, entries.length)) { throw error } } else { @@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise void +}): React.JSX.Element { + return ( + +
+ +
+
{error.title}
+ {error.details ? ( + <> + + + + +
+ {error.details} +
+
+ + ) : null} +
+
+
+ ) +} + function getLinearIssueGridTemplate(visibleProperties: ReadonlySet): string { const columns = ['96px', 'minmax(240px,1.55fr)'] if (visibleProperties.has('labels')) { @@ -4121,7 +4171,8 @@ export default function TaskPage(): React.JSX.Element { // Jira tab state const [jiraIssues, setJiraIssues] = useState([]) const [jiraLoading, setJiraLoading] = useState(false) - const [jiraError, setJiraError] = useState(null) + const [jiraError, setJiraError] = useState(null) + const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false) const [jiraSearchInput, setJiraSearchInput] = useState('') const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('assigned') @@ -7231,6 +7282,7 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setJiraLoading(true) setJiraError(null) + setJiraErrorDetailsOpen(false) const trimmed = appliedJiraSearch.trim() const request = @@ -7252,7 +7304,9 @@ export default function TaskPage(): React.JSX.Element { if (cancelled) { return } - setJiraError(err instanceof Error ? err.message : 'Failed to load Jira issues.') + const failureState = createTaskPageJiraLoadFailureState(err) + setJiraIssues(failureState.issues) + setJiraError(failureState.error) setJiraLoading(false) }) @@ -9387,11 +9441,18 @@ export default function TaskPage(): React.JSX.Element { className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek" style={{ scrollbarGutter: 'stable' }} > - {(jiraStatus.credentialError ?? jiraError) ? ( + {jiraStatus.credentialError ? (
- {jiraStatus.credentialError ?? jiraError} + {jiraStatus.credentialError}
) : null} + {!jiraStatus.credentialError && jiraError ? ( + + ) : null} {jiraLoading && jiraIssues.length === 0 ? (
diff --git a/src/renderer/src/components/task-page-jira-load-state.test.ts b/src/renderer/src/components/task-page-jira-load-state.test.ts new file mode 100644 index 00000000000..a5a151386c0 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state' + +describe('TaskPage Jira load state', () => { + it('explains Jira forbidden errors while clearing stale issues', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Forbidden'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'Forbidden' + } + }) + }) + + it('keeps raw provider detail separate from the Jira status summary', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Error 403: XSRF check failed'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'XSRF check failed' + } + }) + }) + + it('explains malformed JQL errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Malformed JQL'))).toEqual({ + issues: [], + error: { + title: "Jira couldn't run this JQL query. Check the syntax and try again.", + details: 'Malformed JQL' + } + }) + }) + + it('explains network errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Network request failed'))).toEqual({ + issues: [], + error: { + title: "Couldn't reach Jira. Check your connection and try again.", + details: 'Network request failed' + } + }) + }) + + it('explains Jira server errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Service Unavailable'))).toEqual({ + issues: [], + error: { + title: 'Error 503: Jira had a server error while loading issues. Try again in a moment.', + details: 'Service Unavailable' + } + }) + }) + + it('uses the generic load error for non-Error rejections', () => { + expect(createTaskPageJiraLoadFailureState('failed')).toEqual({ + issues: [], + error: { + title: "Couldn't load Jira issues. Try again in a moment.", + details: 'Failed to load Jira issues.' + } + }) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-load-state.ts b/src/renderer/src/components/task-page-jira-load-state.ts new file mode 100644 index 00000000000..77826b78126 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.ts @@ -0,0 +1,76 @@ +import type { JiraIssue } from '../../../shared/types' + +export type TaskPageJiraLoadError = { + title: string + details: string | null +} + +export type TaskPageJiraLoadFailureState = { + issues: JiraIssue[] + error: TaskPageJiraLoadError +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load Jira issues.' +} + +function getErrorCode(message: string): number | null { + const explicit = /^Error\s+(\d{3})\b/i.exec(message)?.[1] + if (explicit) { + return Number(explicit) + } + if (/\bforbidden\b/i.test(message)) { + return 403 + } + if (/\bunauthorized\b|\bunauthenticated\b/i.test(message)) { + return 401 + } + if (/\btoo many requests\b|\brate limit\b/i.test(message)) { + return 429 + } + if (/\bservice unavailable\b/i.test(message)) { + return 503 + } + return null +} + +function getErrorDetails(message: string, code: number | null): string | null { + const normalized = + code === null ? message : message.replace(new RegExp(`^Error\\s+${code}:\\s*`, 'i'), '') + return normalized.trim() || null +} + +function getIssueSearchErrorSummary(message: string, code: number | null): string { + if (code === 401) { + return 'Jira authentication failed. Reconnect Jira in Settings, then try again.' + } + if (code === 403) { + return 'Jira denied access to this issue search. Check project permissions or try a different JQL query.' + } + if (code === 429) { + return 'Jira rate-limited this issue search. Try again in a moment.' + } + if (code !== null && code >= 500) { + return 'Jira had a server error while loading issues. Try again in a moment.' + } + if (/\bjql\b|\bsyntax\b/i.test(message)) { + return "Jira couldn't run this JQL query. Check the syntax and try again." + } + if (/\bnetwork\b|\bfetch failed\b|\btimed? ?out\b|\beconn/i.test(message)) { + return "Couldn't reach Jira. Check your connection and try again." + } + return "Couldn't load Jira issues. Try again in a moment." +} + +export function createTaskPageJiraLoadFailureState(error: unknown): TaskPageJiraLoadFailureState { + const message = getErrorMessage(error) + const code = getErrorCode(message) + const summary = getIssueSearchErrorSummary(message, code) + return { + issues: [], + error: { + title: code === null ? summary : `Error ${code}: ${summary}`, + details: getErrorDetails(message, code) + } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 20488fa7ea9..4483a71a20c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1601,7 +1601,8 @@ "ff90d0abc7": "Start workspace from {{value0}}", "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", - "4ac8ff2275": "Open {{value0}} in Jira" + "4ac8ff2275": "Open {{value0}} in Jira", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 23d644fa99f..a60a6d2db2a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1601,7 +1601,8 @@ "ff90d0abc7": "Iniciar espacio de trabajo desde {{value0}}", "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", - "4ac8ff2275": "Abrir {{value0}} en Jira" + "4ac8ff2275": "Abrir {{value0}} en Jira", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "Cerca", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index dcce2ac18ed..b25277abe07 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1601,7 +1601,8 @@ "ff90d0abc7": "{{value0}} からワークスペースを開始", "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", - "4ac8ff2275": "{{value0}} を Jira で開く" + "4ac8ff2275": "{{value0}} を Jira で開く", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 31d7c09eb57..beda6a43a8e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1601,7 +1601,8 @@ "ff90d0abc7": "{{value0}}에서 워크스페이스 시작", "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", - "4ac8ff2275": "Jira에서 {{value0}} 열기" + "4ac8ff2275": "Jira에서 {{value0}} 열기", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a2a27399a9d..0723b0e1ef0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1601,7 +1601,8 @@ "ff90d0abc7": "从 {{value0}} 开始工作区", "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", - "4ac8ff2275": "在 Jira 中打开 {{value0}}" + "4ac8ff2275": "在 Jira 中打开 {{value0}}", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "关闭", diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index ebe427f443b..f1dabcb24ca 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -391,14 +391,30 @@ describe('createJiraSlice credential errors', () => { }) }) - it('keeps Jira connected when an issue read hits endpoint-level forbidden access', async () => { + it('surfaces endpoint-level forbidden errors without disconnecting Jira', async () => { const store = createTestStore() store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) jiraListIssues.mockRejectedValueOnce(new Error('Forbidden')) - await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toEqual([]) + // A non-auth failure must reject so the Tasks panel can show a real error + // instead of a misleading empty list, while keeping the session connected. + await expect(store.getState().listJiraIssues('assigned', 30)).rejects.toThrow('Forbidden') + + expect(store.getState().jiraStatus.connected).toBe(true) + }) + + it('surfaces endpoint-level search errors without disconnecting Jira', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + jiraSearchIssues.mockRejectedValueOnce(new Error('Malformed JQL')) + + await expect(store.getState().searchJiraIssues('project =', 30)).rejects.toThrow( + 'Malformed JQL' + ) expect(store.getState().jiraStatus.connected).toBe(true) }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 1498f312edd..fd084a3d985 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -496,7 +496,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightSearchRequests.get(cacheKey) === entry) { @@ -583,7 +590,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightListRequests.get(cacheKey) === entry) { From f5b50b6091825e04c1b0e05f789e2a22c2a3c309 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:43:57 -0700 Subject: [PATCH 03/57] Document Orca CLI card status (#6122) --- skills/orca-cli/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index b36b2fd2109..8370ab87ca4 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -4,7 +4,7 @@ description: >- Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree comments, and the browser embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli", - "Orca worktree", "child worktree", "spawn codex/claude in a worktree", + "Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree", "read/wait/send Orca terminal", "terminal send", "Orca browser", or "control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs, Playwright, or Computer Use when the task touches Orca-managed state. Use @@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json orca worktree create --name independent-task --no-parent --json orca worktree set --worktree id: --display-name "My Task" --json orca worktree set --worktree active --comment "reproduced bug; testing fix" --json +orca worktree set --worktree active --workspace-status in-review --json orca worktree rm --worktree id: --force --json ``` @@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested. +Card status uses `--workspace-status `; defaults are `todo`, `in-progress`, `in-review`, `completed`. + ## Terminals Common commands: From bf2a6c1040e8abc93fbedf5fab97fa6fd25302fd Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Mon, 22 Jun 2026 20:47:18 -0700 Subject: [PATCH 04/57] Don't enqueue local PR refresh for remote-host repos (fix renderer OOM crash) (#6094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't enqueue local PR refresh for remote-host repos (fix renderer OOM) The renderer enqueues GitHub PR refreshes for worktrees. Remote/SSH/runtime worktrees are meant to refresh through the runtime route (getRuntimeRepoTarget), but when that route is unavailable — host not the active environment, or disconnected — the call falls through to the local `gh:enqueuePRRefresh` IPC. The local handler only resolves repos registered in the local store, so it rejects every such call with "Access denied: unknown repository path". With a remote worktree active (e.g. a runtime "Project server" workspace), this fires on a loop (worktree activation + SWR polling), flooding the renderer with failed invokes and unhandled rejections. Observed hundreds of these per session; the renderer JS heap climbs to the V8 ceiling (~3.5GB) and V8 aborts the process (crash-reports.json: renderer crash, exitCode 5, usedHeapMB == heapLimitMB). Add isLocalHostPRRefreshCandidate and gate all four enqueue sites on it, so the local handler only ever receives local-host candidates. Remote candidates with no available runtime route are skipped (their PR status refreshes once the host is active/reconnected) instead of spamming a handler that can't serve them. Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover local executionHostId in PR-refresh host guard Address CodeRabbit nitpick on #6094: add a positive case where a local repo carries an explicit executionHostId === LOCAL_EXECUTION_HOST_ID, completing predicate coverage for isLocalHostPRRefreshCandidate. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: route PR refreshes by repo host Co-authored-by: Orca * fix: skip disconnected SSH PR refreshes Co-authored-by: Orca --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../github-pr-refresh-host-guard.test.ts | 123 ++++++++ .../github-pr-refresh-owner-routing.test.ts | 279 ++++++++++++++++++ src/renderer/src/store/slices/github.ts | 67 ++++- 3 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts create mode 100644 src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts new file mode 100644 index 00000000000..e2a0839f9ee --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' + +// Why: regression guard for the renderer OOM crash. Enqueuing the local +// `gh:enqueuePRRefresh` for a repo owned by a remote/SSH/runtime host rejects +// with "Access denied: unknown repository path"; a flood of those failures grew +// the renderer heap to the V8 ceiling and crashed it. enqueueGitHubPRRefresh +// must only hit the local handler for local-host repos. +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + enqueuePRRefresh, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: vi.fn() } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function seed(store: ReturnType, repo: Record) { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + repos: [repo], + worktreesByRepo: { + [repo.id as string]: [ + { + id: 'wt-1', + repoId: repo.id, + path: `${repo.path}/wt`, + branch: 'refs/heads/feature', + displayName: 'feature', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: null, + linkedIssue: null + } + ] + }, + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + sshConnectionStates: new Map() + } as unknown as Partial) +} + +describe('enqueueGitHubPRRefresh host guard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('enqueues the local handler for a local-host repo', () => { + const store = createTestStore() + seed(store, { id: 'local-1', path: '/Users/me/code/local-1', name: 'local-1', kind: 'git' }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('enqueues the local handler for a repo with an explicit local executionHostId', () => { + const store = createTestStore() + seed(store, { + id: 'local-2', + path: '/Users/me/code/local-2', + name: 'local-2', + kind: 'git', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('does NOT enqueue the local handler for a runtime-host repo (the OOM loop)', () => { + const store = createTestStore() + seed(store, { + id: 'rt-1', + path: '/Users/lobster/orca/workspaces/openclaw/imessage-performance', + name: 'imessage-performance', + kind: 'git', + executionHostId: 'runtime:env-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('does NOT enqueue the local handler for an SSH repo', () => { + const store = createTestStore() + seed(store, { + id: 'ssh-1', + path: '/home/me/code/ssh-1', + name: 'ssh-1', + kind: 'git', + connectionId: 'conn-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts new file mode 100644 index 00000000000..d53beb70488 --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) +const reportVisiblePRRefreshCandidates = vi.fn().mockResolvedValue(true) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + refreshPRNow: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 }), + enqueuePRRefresh, + reportVisiblePRRefreshCandidates, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + cache: { + getGitHub: vi.fn().mockResolvedValue(null), + setGitHub: vi.fn().mockResolvedValue(undefined) + } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function resetRuntimeMocks(): void { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function makePR(overrides: Partial = {}): PRInfo { + return { + number: 12, + title: 'Test PR', + state: 'open', + url: 'https://example.com/pr/12', + checksStatus: 'pending', + updatedAt: '2026-03-28T00:00:00Z', + mergeable: 'UNKNOWN', + headSha: 'head-oid', + ...overrides + } +} + +function makeRepo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeWorktree(repoId: string, branch: string, id = `${repoId}-wt`): Worktree { + return { + id, + repoId, + path: `/worktrees/${id}`, + head: 'head-oid', + branch, + displayName: branch, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + isMainWorktree: false, + isBare: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } +} + +function seed( + store: ReturnType, + state: Pick & Partial +): void { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + groupBy: 'pr-status', + worktreeCardProperties: ['status'], + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + commentsCache: {}, + sshConnectionStates: new Map(), + ...state + } as unknown as Partial) +} + +describe('GitHub PR refresh owner-host routing', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRuntimeMocks() + }) + + it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/runtime-owner' + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => { + const store = createTestStore() + const repoPath = '/ssh/repo' + const branch = 'feature/ssh' + seed(store, { + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-ssh', + path: repoPath, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + ], + sshConnectionStates: new Map([ + ['ssh-1', { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + worktreesByRepo: { + 'repo-ssh': [makeWorktree('repo-ssh', branch, 'wt-ssh')] + } + }) + + store.getState().refreshGitHubForWorktreeIfStale('wt-ssh') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(enqueuePRRefresh).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + repoId: 'repo-ssh', + repoPath, + branch, + connectionId: 'ssh-1', + connectionState: 'connected' + }), + reason: 'active', + priority: 80 + }) + }) + + it('routes post-push refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 24 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/post-push' + seed(store, { + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().refreshGitHubForWorktree('wt-runtime') + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('splits visible candidates between local coordinator and runtime owner', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 25 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + seed(store, { + repos: [ + makeRepo({ id: 'repo-local', path: '/local/repo' }), + makeRepo({ + id: 'repo-runtime', + path: '/runtime/repo', + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree('repo-local', 'feature/local', 'wt-local')], + 'repo-runtime': [makeWorktree('repo-runtime', 'feature/runtime', 'wt-runtime')] + } + }) + + store.getState().reportVisibleGitHubPRRefreshCandidates(['wt-local', 'wt-runtime'], 123) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(reportVisiblePRRefreshCandidates).toHaveBeenCalledWith({ + candidates: [ + expect.objectContaining({ + repoId: 'repo-local', + repoPath: '/local/repo', + branch: 'feature/local' + }) + ], + generation: 123 + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 039550b7299..cfd032f969d 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -130,6 +130,46 @@ function getRuntimeRepoTarget( return repo ? { target, repo } : null } +function getPRRefreshOwnerRuntimeEnvironmentId( + candidate: Pick +): string | null { + const parsed = parseExecutionHostId(candidate.executionHostId) + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + const cacheScope = candidate.cacheKey.split('::', 1)[0] + const cacheScopeHost = parseExecutionHostId(cacheScope) + return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null +} + +function getPRRefreshRuntimeRepoTarget( + state: AppState, + candidate: GitHubPRRefreshCandidate +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + if (!ownerRuntimeEnvironmentId) { + return null + } + // Why: PR refreshes must follow the repo owner host, not the Active Server + // dropdown. A runtime-owned worktree can be visible while Local desktop is focused. + return getRuntimeRepoTarget( + state, + candidate.repoPath, + state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + ) +} + +function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { + // Why: the local PR coordinator owns local git and SSH bridge refreshes, but + // runtime-owned repos and disconnected SSH repos must not hit the IPC crash path. + if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { + return false + } + return !candidate.connectionId || candidate.connectionState === 'connected' +} + type GitHubWorkItemRequestContext = { repoId: string repoPath: string @@ -3383,7 +3423,7 @@ export const createGitHubSlice: StateCreator = (s if (!candidate) { return } - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, @@ -3394,6 +3434,9 @@ export const createGitHubSlice: StateCreator = (s }) return } + if (!shouldEnqueueLocalPRRefresh(candidate)) { + return + } const enqueue = window.api.gh.enqueuePRRefresh if (enqueue) { void enqueue({ candidate, reason, priority }) @@ -3424,8 +3467,9 @@ export const createGitHubSlice: StateCreator = (s return worktree ? buildPRRefreshCandidate(state, worktree) : null }) .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) - if (getActiveRuntimeTarget(state.settings).kind === 'environment') { - for (const candidate of candidates) { + const localCandidates: GitHubPRRefreshCandidate[] = [] + for (const candidate of candidates) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, worktreeId: candidate.worktreeId, @@ -3433,12 +3477,15 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) + continue + } + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) } - return } const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates if (reportVisible) { - void reportVisible({ candidates, generation }).catch((err) => { + void reportVisible({ candidates: localCandidates, generation }).catch((err) => { console.warn('Failed to report visible PR refresh candidates:', err) }) } @@ -3733,7 +3780,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 }) } } @@ -3797,7 +3844,7 @@ export const createGitHubSlice: StateCreator = (s if (!worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(get(), worktree) if (candidate) { - if (getRuntimeRepoTarget(get(), candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -3806,7 +3853,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 }) } } @@ -3995,7 +4042,7 @@ export const createGitHubSlice: StateCreator = (s if (shouldRefreshPR && !worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(state, worktree) if (candidate) { - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -4004,7 +4051,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 }) } } From 092fd6cde3c5dcb70a13106ea399b330cbb3fd7c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:48:47 -0700 Subject: [PATCH 05/57] Prune stale worktree virtual row cache (#6117) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeList.tsx | 15 +++++- .../worktree-list-virtual-rows.test.ts | 47 +++++++++++++++++++ .../sidebar/worktree-list-virtual-rows.ts | 25 ++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3764ee8adff..a46ed4c2d47 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -87,6 +87,7 @@ import { getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, + pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, type RenderRow } from './worktree-list-virtual-rows' @@ -1941,6 +1942,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.map(getRenderRowKey).join('\n'), [renderRows] ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -1976,6 +1978,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) // Why: after delete/collapse, TanStack may briefly retain the removed row's // cached element. Measuring that disconnected node reports 0px and corrupts // the next row's slot, so measure only elements whose DOM key still matches @@ -1983,7 +1989,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp measureMountedRows() const frameId = window.requestAnimationFrame(measureMountedRows) return () => window.cancelAnimationFrame(frameId) - }, [prCacheLen, issueCacheLen, measureMountedRows, renderRowKeySignature]) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index e0449b6eeca..cb742a12649 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -5,6 +5,7 @@ import { extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, getStickyHeaderIndexes, + pruneStaleVirtualRowElementCache, type RenderRow } from './worktree-list-virtual-rows' @@ -144,3 +145,49 @@ describe('extractWorktreeVirtualRowIndexes', () => { expect(indexes).toContain(0) }) }) + +describe('pruneStaleVirtualRowElementCache', () => { + it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { + const activeElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:active' : null + } as Element + const staleElement = { + isConnected: false, + getAttribute: (name: string) => (name === 'data-worktree-virtual-row-key' ? 'wt:stale' : null) + } as Element + const connectedStaleElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:connected-stale' : null + } as Element + const retainedScope = { + defaultHostId: 'runtime:env-1', + handlerName: 'handleOpenReviewInOrca' + } + Object.assign(staleElement, { __retainedWorktreeCardScopeForTest: retainedScope }) + + const virtualizer = { + elementsCache: new Map([ + ['wt:active', activeElement], + ['wt:stale', staleElement], + ['wt:connected-stale', connectedStaleElement] + ]), + measureElement: (element: Element | null) => { + if (element) { + throw new Error('stale cache pruning should not remeasure rows') + } + } + } + + pruneStaleVirtualRowElementCache({ + activeRowKeys: new Set(['wt:active']), + virtualizer + }) + + expect(virtualizer.elementsCache.get('wt:active')).toBe(activeElement) + expect(virtualizer.elementsCache.has('wt:stale')).toBe(false) + expect(virtualizer.elementsCache.get('wt:connected-stale')).toBe(connectedStaleElement) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index e98b9c1ee09..93daaf4271c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -76,6 +76,31 @@ export function getVirtualRowTransform(start: number): string { return `translateY(${start}px)` } +type VirtualRowElementCache = { + elementsCache: Map + measureElement: (node: TElement | null) => void +} + +export function pruneStaleVirtualRowElementCache({ + activeRowKeys, + virtualizer +}: { + activeRowKeys: ReadonlySet + virtualizer: VirtualRowElementCache +}): void { + virtualizer.measureElement(null) + for (const [key, element] of virtualizer.elementsCache) { + const rowKey = String(key) + if (activeRowKeys.has(rowKey) || element.isConnected) { + continue + } + // Why: measured row nodes retain their React fiber tree. Once TanStack's + // public null-measure cleanup has run, drop any disconnected stale key left + // behind so old WorktreeCard scopes do not survive runtime-host row churn. + virtualizer.elementsCache.delete(key) + } +} + export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { const indexes: number[] = [] rows.forEach((row, index) => { From c36109ee8061229635ee10b532f7a35323f212b2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:54:41 -0700 Subject: [PATCH 06/57] fix: normalize MSYS cwd for Windows terminals (#6126) --- src/main/daemon/pty-subprocess.test.ts | 32 +++++++++++++++++ src/main/providers/windows-shell-args.test.ts | 25 +++++++++++++ src/main/providers/windows-shell-args.ts | 36 +++++++++++++------ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index e2b8c155caa..75afb290310 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => { expect(spawnMock).not.toHaveBeenCalled() }) + it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => { + const proc = mockPtyProcess() + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + spawnMock.mockImplementation((_shell, _args, options) => { + if (options.cwd === '/c/Users/alice/project') { + throw new Error('Cannot create process, error code: 267') + } + return proc + }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/c/Users/alice/project', + shellOverride: 'powershell.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + POWERSHELL_OSC133_COMMAND_ARGS, + expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' }) + ) + }) + it('validates the requested Windows cwd before launching WSL on Windows', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0801f002ed5..c9f6d1a470a 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('normalizes MSYS drive cwd before spawning native PowerShell', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice' + ) + + expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -189,6 +200,20 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + undefined, + 'codex' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected sh cmd must not break out of the surrounding single quotes diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 9944b127745..826b672d271 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } +function normalizeMsysDrivePath(cwd: string): string { + const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/) + if (!match) { + return cwd + } + + // Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`; + // ConPTY requires the equivalent native drive path as its cwd. + const driveLetter = match[1].toUpperCase() + const rest = match[2]?.replace(/\//g, '\\') ?? '' + return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\` +} + /** Build the argv + effective cwd for a Windows shell launch. * * - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly. @@ -122,6 +135,7 @@ export function resolveWindowsShellLaunchArgs( startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() + const nativeCwd = normalizeMsysDrivePath(cwd) if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -133,8 +147,8 @@ export function resolveWindowsShellLaunchArgs( : CMD_UTF8_SETUP_COMMAND ], ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -147,16 +161,16 @@ export function resolveWindowsShellLaunchArgs( ...(powerShellCommand.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } if (isWindowsGitBashShellPath(shellPath)) { return { shellArgs: ['--login', '-i'], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -176,18 +190,18 @@ export function resolveWindowsShellLaunchArgs( validationCwd: toWindowsWslPath(cwd, wslContext.distro) } } - const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) - const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' + const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) + const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c' return { shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, - validationCwd: cwd + validationCwd: nativeCwd } } return { shellArgs: [], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } From 5d7368dcd278432f04e1b842ba08e8c478659459 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:55:05 -0700 Subject: [PATCH 07/57] Fix Fira Code ligature repaint in terminals (#6123) * Fix terminal ligature repaint after addon attach Co-authored-by: Orca * Fix terminal preview test mock types Co-authored-by: Orca --------- Co-authored-by: Orca --- .../TerminalSettingsPreview.lifecycle.test.tsx | 7 +++++++ .../settings/TerminalSettingsPreview.tsx | 3 +++ .../src/lib/pane-manager/pane-lifecycle.test.ts | 14 +++++++++++++- .../src/lib/pane-manager/pane-lifecycle.ts | 3 +++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 3422b3fbeed..349bea46ea8 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -9,8 +9,10 @@ type MockTerminalInstance = { open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number } type MockLigaturesAddonInstance = { @@ -66,8 +68,10 @@ vi.mock('@xterm/xterm', () => ({ open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number constructor(options: Record) { this.options = { ...options } @@ -78,7 +82,9 @@ vi.mock('@xterm/xterm', () => ({ }) this.write = vi.fn() this.reset = vi.fn() + this.refresh = vi.fn() this.dispose = vi.fn() + this.rows = Number(options.rows) this.loadAddon = vi.fn(() => { if (mockXterm.nextLoadAddonError) { throw mockXterm.nextLoadAddonError @@ -228,6 +234,7 @@ describe('TerminalSettingsPreview terminal lifecycle', () => { const addon = mockLigaturesAddon.instances[0] expect(terminal.loadAddon).toHaveBeenCalledOnce() expect(terminal.loadAddon).toHaveBeenCalledWith(addon) + expect(terminal.refresh).toHaveBeenCalledWith(0, 14) runCleanups() expect(addon.dispose).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index 4294e497a93..8d2a74a51ca 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -260,6 +260,9 @@ export function TerminalSettingsPreview({ try { terminal.loadAddon(addon) ligaturesAddonRef.current = addon + // Why: the preview writes its sample before this effect runs; repaint + // so already-rendered operators switch to ligature glyphs immediately. + terminal.refresh(0, terminal.rows - 1) } catch (err) { addon.dispose() console.warn('[settings preview] ligatures addon failed to attach', err) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index bb24554e950..f6a2e367de0 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -6,7 +6,7 @@ import { markComplexScriptOutput, resetTerminalWebglSuggestion } from './pane-webgl-renderer' -import { openTerminal } from './pane-lifecycle' +import { attachLigatures, openTerminal } from './pane-lifecycle' import { buildDefaultTerminalOptions, DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -348,6 +348,18 @@ describe('attachWebgl', () => { }) }) +describe('attachLigatures', () => { + it('refreshes existing rows after loading the ligatures addon', () => { + const pane = createPane() + + attachLigatures(pane) + + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(pane.ligaturesAddon).not.toBeNull() + }) +}) + describe('openTerminal — Unicode 11 ordering', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', () => 1) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 66370b28546..f2fc5846add 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -286,6 +286,9 @@ export function attachLigatures(pane: ManagedPaneInternal): void { const ligaturesAddon = new LigaturesAddon() pane.terminal.loadAddon(ligaturesAddon) pane.ligaturesAddon = ligaturesAddon + // Why: ligatures can be enabled after rows already rendered, especially + // from Settings. Force existing glyph runs to be recomputed immediately. + pane.terminal.refresh(0, pane.terminal.rows - 1) // Why: the WebGL renderer builds its glyph texture atlas at activation // time, so `font-feature-settings` applied after WebGL loaded won't // reach the GPU-rendered cells until the atlas is rebuilt. The upstream From f3e379d6dc4d375e9094ace603465ae3dcc49a68 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:57:13 -0700 Subject: [PATCH 08/57] fix: cache macos computer-use snapshot ax reads (#6130) Co-authored-by: Orca --- .../Sources/OrcaComputerUseMacOS/main.swift | 510 +++++++++++------- .../SnapshotRendering.swift | 14 + .../SnapshotRenderingTests.swift | 18 + 3 files changed, 362 insertions(+), 180 deletions(-) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 86ea4dbc37e..da6c4d34cda 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1508,6 +1508,7 @@ private final class TreeRenderer { var focusedElementId: Int? var truncated = false var maxDepthReached = false + private let reader = AXSnapshotReader() private var nextIndex = 0 static let maxNodes = 1200 static let maxDepth = 64 @@ -1530,22 +1531,26 @@ private final class TreeRenderer { } guard !ancestors.contains(where: { CFEqual($0, element) }) else { return } - let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" - let children = primaryChildren(element, role: role, windowBounds: windowBounds) - let value = valueString(element) - let placeholder = placeholderString(element) - let rawActions = actions(element) - let rowSummary = rowTextSummary(element, role: role) - let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil + let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" + let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds) + let value = reader.valueString(element, role: role) + let placeholder = reader.placeholderString(element) + let rawActions = reader.actions(element) + let rowSummary = reader.rowTextSummary(element, role: role) + let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String) + let title = reader.stringAttribute(element, kAXTitleAttribute as String) + let label = reader.stringAttribute(element, kAXDescriptionAttribute as String) + let url = reader.stringAttribute(element, kAXURLAttribute as String) + let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil let baseNode = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: [], rawActions: rawActions, childCount: children.count, @@ -1553,19 +1558,19 @@ private final class TreeRenderer { ) let name = SnapshotRenderHeuristics.displayName(baseNode) let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role) - let localFrame = frame(element, windowBounds: windowBounds) - let traits = traitsFor(element, role: role) - let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors) - let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) + let localFrame = reader.frame(element, windowBounds: windowBounds) + let traits = reader.traitsFor(element, role: role) + let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors) + let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) let node = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: traits, rawActions: rawActions, childCount: children.count, @@ -1617,6 +1622,23 @@ private final class TreeRenderer { } } + private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { + let childNodes = children.map { child in + let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" + return SnapshotRenderNode( + role: role, + roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String), + title: reader.stringAttribute(child, kAXTitleAttribute as String), + label: reader.stringAttribute(child, kAXDescriptionAttribute as String), + value: reader.valueString(child, role: role), + traits: reader.traitsFor(child, role: role) + ) + } + // Why: browsers expose every open tab through AX; retaining only the active + // tab keeps snapshots focused on the current page instead of stale tab titles. + return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) + } + private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) { guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return } let indent = String(repeating: "\t", count: depth) @@ -1647,44 +1669,301 @@ private final class TreeRenderer { } } -private func valueString(_ element: AXUIElement) -> String? { - if isSecureTextElement(element) { - return "[redacted]" +private final class AXSnapshotReader { + private enum CachedAttribute { + case missing + case found(CFTypeRef) } - if let string = stringAttribute(element, kAXValueAttribute as String) { - return string + + private final class ElementCache { + let element: AXUIElement + var loadedAttributeNames = false + var advertisedAttributes: Set? + var attributes: [String: CachedAttribute] = [:] + var actions: [String]? + var settable: [String: Bool] = [:] + + init(element: AXUIElement) { + self.element = element + } } - if let number = numberAttribute(element, kAXValueAttribute as String) { - return number.stringValue + + private var elementsByHash: [CFHashCode: [ElementCache]] = [:] + + func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + guard let value = copyAttribute(element, attribute) else { return nil } + if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL { + return url.absoluteString + } + return nil } - return nil -} -private func isSecureTextElement(_ element: AXUIElement) -> Bool { - let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? "" - let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? "" - let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? "" - let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? "" - let placeholder = placeholderString(element)?.lowercased() ?? "" - let haystack = [role, subrole, title, description, placeholder].joined(separator: " ") - return haystack.contains("secure") || - haystack.contains("password") || - haystack.contains("passcode") || - haystack.contains("verification code") || - haystack.contains("one-time code") -} + func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? { + copyAttribute(element, attribute) as? Bool + } -private func placeholderString(_ element: AXUIElement) -> String? { - stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") -} + func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? { + copyAttribute(element, attribute) as? NSNumber + } -private func traitsFor(_ element: AXUIElement, role: String) -> [String] { - var traits: [String] = [] - if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } - if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } - if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } - if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } - return traits + func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? { + copyAttribute(element, attribute) as? [AXUIElement] + } + + func actions(_ element: AXUIElement) -> [String] { + let cache = cache(for: element) + if let actions = cache.actions { + return actions + } + var value: CFArray? + let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : [] + cache.actions = actions + return actions + } + + func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool { + let cache = cache(for: element) + if let cached = cache.settable[attribute] { + return cached + } + var settable = DarwinBoolean(false) + let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue + cache.settable[attribute] = value + return value + } + + func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? { + guard let absolute = absoluteFrame(element) else { return nil } + return CGRect( + x: absolute.minX - windowBounds.minX, + y: absolute.minY - windowBounds.minY, + width: absolute.width, + height: absolute.height + ) + } + + func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { + if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { + return visibleRows(rows, parent: element, windowBounds: windowBounds) + } + return copyArray(element, kAXChildrenAttribute as String) ?? [] + } + + func valueString(_ element: AXUIElement, role: String) -> String? { + if isSecureTextElement(element, role: role) { + return "[redacted]" + } + if let string = stringAttribute(element, kAXValueAttribute as String) { + return string + } + if let number = numberAttribute(element, kAXValueAttribute as String) { + return number.stringValue + } + return nil + } + + func placeholderString(_ element: AXUIElement) -> String? { + stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") + } + + func traitsFor(_ element: AXUIElement, role: String) -> [String] { + var traits: [String] = [] + if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } + if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } + if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } + if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } + return traits + } + + func genericTextSummary( + _ element: AXUIElement, + role: String, + name: String?, + actions: [String], + traits: [String] + ) -> String? { + guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), + name == nil, + actions.isEmpty, + traits.isEmpty, + isPlainTextSubtree(element, maxDepth: 4) + else { + return nil + } + let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) + guard texts.count >= 2 else { return nil } + let summary = texts.joined(separator: " ") + guard summary.count <= 220 else { return nil } + return summary + } + + func rowTextSummary(_ element: AXUIElement, role: String) -> String? { + guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } + let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) + guard !texts.isEmpty else { return nil } + return texts.joined(separator: " ") + } + + func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { + var values: [String] = [] + var seen = Set() + + func collect(_ node: AXUIElement, depth: Int) { + guard values.count < limit, depth <= maxDepth else { return } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" + if role == kAXStaticTextRole as String || role == "AXLink" { + for candidate in [ + stringAttribute(node, kAXValueAttribute as String), + stringAttribute(node, kAXTitleAttribute as String), + stringAttribute(node, kAXDescriptionAttribute as String), + ] { + guard let candidate else { continue } + let text = preview(candidate, maxLength: 80) + guard !text.isEmpty, seen.insert(text).inserted else { continue } + values.append(text) + if values.count >= limit { return } + } + } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + collect(child, depth: depth + 1) + if values.count >= limit { return } + } + } + + collect(element, depth: 0) + return values + } + + func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { + if role == "AXWebArea" { return 0 } + guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { + return nil + } + return ancestors.count - index + } + + private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? { + let cache = cache(for: element) + if let cached = cache.attributes[attribute] { + switch cached { + case .missing: + return nil + case let .found(value): + return value + } + } + if let advertisedAttributes = advertisedAttributes(cache), + !SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) { + cache.attributes[attribute] = .missing + return nil + } + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + cache.attributes[attribute] = .missing + return nil + } + cache.attributes[attribute] = .found(value) + return value + } + + private func advertisedAttributes(_ cache: ElementCache) -> Set? { + if cache.loadedAttributeNames { + return cache.advertisedAttributes + } + cache.loadedAttributeNames = true + var value: CFArray? + guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else { + return nil + } + cache.advertisedAttributes = Set(attributes) + return cache.advertisedAttributes + } + + private func absoluteFrame(_ element: AXUIElement) -> CGRect? { + guard let positionValue = copyAttribute(element, kAXPositionAttribute as String), + let sizeValue = copyAttribute(element, kAXSizeAttribute as String) + else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { + return nil + } + return CGRect(origin: point, size: size) + } + + private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { + guard let parentFrame = frame(parent, windowBounds: windowBounds) else { + return Array(rows.prefix(20)) + } + let visible = rows.filter { row in + guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } + return rowFrame.intersects(parentFrame) + } + return Array((visible.isEmpty ? rows : visible).prefix(20)) + } + + private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool { + guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else { + return false + } + let haystack = [ + role, + stringAttribute(element, kAXSubroleAttribute as String) ?? "", + stringAttribute(element, kAXTitleAttribute as String) ?? "", + stringAttribute(element, kAXDescriptionAttribute as String) ?? "", + placeholderString(element) ?? "", + ].joined(separator: " ").lowercased() + return haystack.contains("secure") || + haystack.contains("password") || + haystack.contains("passcode") || + haystack.contains("verification code") || + haystack.contains("one-time code") + } + + private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { + var sawText = false + let allowedContainerRoles: Set = [ + kAXGroupRole as String, + kAXUnknownRole as String, + kAXStaticTextRole as String, + "AXLink", + "AXImage", + ] + + func visit(_ node: AXUIElement, depth: Int) -> Bool { + guard depth <= maxDepth else { return false } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" + guard allowedContainerRoles.contains(role) else { return false } + if role == kAXStaticTextRole as String || role == "AXLink" { + sawText = true + } + guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + guard visit(child, depth: depth + 1) else { return false } + } + return true + } + + return visit(element, depth: 0) && sawText + } + + private func cache(for element: AXUIElement) -> ElementCache { + let hash = CFHash(element) + if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) { + return cache + } + let cache = ElementCache(element: element) + elementsByHash[hash, default: []].append(cache) + return cache + } } private let valueSettableRoles: Set = [ @@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set = [ kAXTextFieldRole as String, ] -private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { - if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { - return visibleRows(rows, parent: element, windowBounds: windowBounds) - } - return copyArray(element, kAXChildrenAttribute as String) ?? [] -} - -private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { - guard let parentFrame = frame(parent, windowBounds: windowBounds) else { - return Array(rows.prefix(20)) - } - let visible = rows.filter { row in - guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } - return rowFrame.intersects(parentFrame) - } - return Array((visible.isEmpty ? rows : visible).prefix(20)) -} - private func usesRowsAsPrimaryChildren(role: String) -> Bool { [ kAXBrowserRole as String, @@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool { ].contains(role) } -private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { - let childNodes = children.map { child in - let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" - return SnapshotRenderNode( - role: role, - roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String), - title: stringAttribute(child, kAXTitleAttribute as String), - label: stringAttribute(child, kAXDescriptionAttribute as String), - value: valueString(child), - traits: traitsFor(child, role: role) - ) - } - // Why: browsers expose every open tab through AX; retaining only the active - // tab keeps snapshots focused on the current page instead of stale tab titles. - return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) -} - private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool { guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else { return false @@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? { return Int(digits) } -private func genericTextSummary( - _ element: AXUIElement, - role: String, - name: String?, - actions: [String], - traits: [String] -) -> String? { - guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), - name == nil, - actions.isEmpty, - traits.isEmpty, - isPlainTextSubtree(element, maxDepth: 4) - else { - return nil - } - let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) - guard texts.count >= 2 else { return nil } - let summary = texts.joined(separator: " ") - guard summary.count <= 220 else { return nil } - return summary -} - -private func rowTextSummary(_ element: AXUIElement, role: String) -> String? { - guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } - let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) - guard !texts.isEmpty else { return nil } - return texts.joined(separator: " ") -} - -private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { - var sawText = false - let allowedContainerRoles: Set = [ - kAXGroupRole as String, - kAXUnknownRole as String, - kAXStaticTextRole as String, - "AXLink", - "AXImage", - ] - - func visit(_ node: AXUIElement, depth: Int) -> Bool { - guard depth <= maxDepth else { return false } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" - guard allowedContainerRoles.contains(role) else { return false } - if role == kAXStaticTextRole as String || role == "AXLink" { - sawText = true - } - guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - guard visit(child, depth: depth + 1) else { return false } - } - return true - } - - return visit(element, depth: 0) && sawText -} - -private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { - var values: [String] = [] - var seen = Set() - - func collect(_ node: AXUIElement, depth: Int) { - guard values.count < limit, depth <= maxDepth else { return } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" - if role == kAXStaticTextRole as String || role == "AXLink" { - for candidate in [ - stringAttribute(node, kAXValueAttribute as String), - stringAttribute(node, kAXTitleAttribute as String), - stringAttribute(node, kAXDescriptionAttribute as String), - ] { - guard let candidate else { continue } - let text = preview(candidate, maxLength: 80) - guard !text.isEmpty, seen.insert(text).inserted else { continue } - values.append(text) - if values.count >= limit { return } - } - } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - collect(child, depth: depth + 1) - if values.count >= limit { return } - } - } - - collect(element, depth: 0) - return values -} - -private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { - if role == "AXWebArea" { return 0 } - guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { - return nil - } - return ancestors.count - index -} - private func sanitize(_ value: String) -> String { value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift index 275738028cd..53205adf5d3 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift @@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable { } public enum SnapshotRenderHeuristics { + public static func shouldProbeSecureTextMetadata(role: String) -> Bool { + let normalized = role.lowercased() + return normalized.contains("text") || + normalized.contains("field") || + normalized.contains("password") || + normalized.contains("search") || + normalized.contains("combo") + } + + public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set?) -> Bool { + guard let advertisedAttributes else { return true } + return advertisedAttributes.contains(attribute) + } + public static func displayName(_ node: SnapshotRenderNode) -> String? { if let title = clean(node.title) { return title diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift index b85e70cc801..dce1bdc304c 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift @@ -2,6 +2,24 @@ import XCTest @testable import OrcaComputerUseMacOSCore final class SnapshotRenderingTests: XCTestCase { + func testSkipsUnsupportedAdvertisedAttributes() { + let advertised: Set = ["AXRole", "AXChildren"] + + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised)) + XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised)) + } + + func testUnknownAttributeAdvertisementsStayPermissive() { + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil)) + } + + func testSecureTextMetadataOnlyProbesTextLikeRoles() { + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField")) + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton")) + } + func testElidesAnonymousWrappers() { let node = SnapshotRenderNode(role: "AXGroup", childCount: 1) From fe8eb45ca28d41669ae426d93e6e3f852b8cea13 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:33:52 -0700 Subject: [PATCH 09/57] Add relay upstream negative cache (#6105) --- src/relay/git-handler-status-ops.test.ts | 145 ++++++++++++++++++ src/relay/git-handler-status-ops.ts | 22 +-- .../git-status-upstream-negative-cache.ts | 101 ++++++++++++ 3 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 src/relay/git-status-upstream-negative-cache.ts diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 47ddb8a671d..d55e86d7584 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -5,6 +5,7 @@ import * as path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -16,14 +17,20 @@ function buildLargeStatusOutput(count: number): string { return lines.join('\n') } +function buildBranchStatusOutput(head: string, branch: string): string { + return [`# branch.oid ${head}`, `# branch.head ${branch}`].join('\n') +} + describe('getStatusOp', () => { let tmpDir: string beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -70,4 +77,142 @@ describe('getStatusOp', () => { expect(result.didHitLimit).toBeUndefined() expect(result.entries).toHaveLength(5) }) + + it('caches no-effective-upstream probes across status polls for the same head', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + const first = await getStatusOp(git, { worktreePath: tmpDir }) + const firstCallCount = git.mock.calls.length + const second = await getStatusOp(git, { worktreePath: tmpDir }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(second.upstreamStatus).toEqual(first.upstreamStatus) + expect(git.mock.calls).toHaveLength(firstCallCount + 1) + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('coalesces concurrent no-effective-upstream probes', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + await Promise.resolve() + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + await Promise.resolve() + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await Promise.all([ + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }) + ]) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('invalidates cached no-effective-upstream probes when the branch changes', async () => { + let branch = 'feature' + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', branch), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: `${branch}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + branch = 'other-feature' + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls + .filter( + ([args]) => + args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/')) + ) + .map(([args]) => args.at(-1)) + ).toEqual(['refs/remotes/origin/feature', 'refs/remotes/origin/other-feature']) + }) + + it('does not cache a configured push target signal', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature/fix'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature/fix\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature/fix') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('remote.pushDefault')) { + throw new Error('missing push default') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { + return { stdout: 'refs/heads/feature/fix\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { + throw new Error('missing branch base') + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(2) + }) }) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index d0c8842f502..8619e6fd1e9 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -11,10 +11,8 @@ import { parseUnmergedEntry } from './git-handler-utils' import { parseStatusOutput } from './git-status-output-parser' import type { GitExec } from './git-handler-ops' import type { GitUpstreamStatus } from '../shared/types' -import { - getEffectiveGitUpstreamStatus, - splitRemoteBranchName -} from '../shared/git-effective-upstream' +import { splitRemoteBranchName } from '../shared/git-effective-upstream' +import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' import { applyLineStats, collectUntrackedAdditions, @@ -133,11 +131,17 @@ export async function getStatusOp( if (!didHitLimit) { if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) { - try { - upstreamStatus = await getEffectiveGitUpstreamStatus((args) => git(args, worktreePath)) - } catch { - // Why: status polling should keep returning working-tree entries even - // if the richer upstream probe hits a transient SSH/git ref error. + const branchName = getShortBranchName(branch) + if (branchName) { + try { + upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, + (args) => git(args, worktreePath) + ) + } catch { + // Why: status polling should keep returning working-tree entries even + // if the richer upstream probe hits a transient SSH/git ref error. + } } } diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts new file mode 100644 index 00000000000..ec1764891c8 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -0,0 +1,101 @@ +import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' +import type { GitCommandRunner } from '../shared/git-effective-upstream' +import type { GitUpstreamStatus } from '../shared/types' + +const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 30_000 + +type NoEffectiveUpstreamCacheIdentity = { + worktreePath: string + branchName: string + upstreamName?: string +} + +type NoEffectiveUpstreamCacheEntry = { + status: GitUpstreamStatus + expiresAt: number +} + +const noEffectiveUpstreamByIdentity = new Map() +const noEffectiveUpstreamInFlight = new Map>() + +function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { + return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') +} + +function readCachedNoEffectiveUpstreamStatus( + cacheKey: string, + nowMs = Date.now() +): GitUpstreamStatus | null { + const entry = noEffectiveUpstreamByIdentity.get(cacheKey) + if (!entry) { + return null + } + if (entry.expiresAt <= nowMs) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + return null + } + return entry.status +} + +function cacheNoEffectiveUpstreamStatus( + cacheKey: string, + status: GitUpstreamStatus, + probedSameNameOriginRef: boolean, + nowMs = Date.now() +): void { + // Why: hasConfiguredPushTarget controls publish behavior; keep that signal + // fresh rather than serving a stale positive from status polling. + if (status.hasUpstream || status.hasConfiguredPushTarget) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + return + } + // Why: only cache negatives after probing origin/; other resolution + // paths can fail without proving the same-name publish branch is absent. + if (!probedSameNameOriginRef) { + return + } + noEffectiveUpstreamByIdentity.set(cacheKey, { + status, + expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS + }) +} + +export async function readOrProbeNoEffectiveUpstreamStatus( + identity: NoEffectiveUpstreamCacheIdentity, + runGit: GitCommandRunner +): Promise { + const cacheKey = noEffectiveUpstreamCacheKey(identity) + const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) + if (cachedStatus) { + return cachedStatus + } + + const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + let probedSameNameOriginRef = false + const probe = getEffectiveGitUpstreamStatus((args) => { + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { + probedSameNameOriginRef = true + } + return runGit(args) + }).then((status) => { + cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef) + return status + }) + noEffectiveUpstreamInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { + noEffectiveUpstreamInFlight.delete(cacheKey) + } + } +} + +export function clearNoEffectiveUpstreamStatusCache(): void { + noEffectiveUpstreamByIdentity.clear() + noEffectiveUpstreamInFlight.clear() +} From 1bbac5c1da0b63f8865e394c1b127b94da1d4de2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:41:17 +0000 Subject: [PATCH 10/57] release: v1.4.92-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 81822285236..c297241ff36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.92-rc.0", + "version": "1.4.92-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From d34c7fd589bcb2c31e5a7bf8b01ede780edcd53d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:55:08 -0700 Subject: [PATCH 11/57] fix: restore Linux chrome-sandbox postinstall (#6133) * fix: restore linux chrome sandbox postinstall Co-authored-by: Orca * fix: check unprivileged user namespaces for sandbox Co-authored-by: Orca * fix: always repair linux chrome sandbox permissions Co-authored-by: Orca --------- Co-authored-by: Orca --- .../package-electron-runtime-contract.test.mjs | 11 +++++++++++ resources/linux/packaging/after-install.sh | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index dea2b89ad0b..54c7314706d 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -101,6 +101,17 @@ describe('Electron runtime package contract', () => { expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}') }) + it('keeps Linux postinstall repairing Chromium sandbox permissions', () => { + const afterInstallScript = readFileSync( + join(projectDir, 'resources/linux/packaging/after-install.sh'), + 'utf8' + ) + + expect(afterInstallScript).toContain('chrome-sandbox') + expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') + expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + }) + it('lets release-cut tag a version that is already present on main', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh index 393bfb6a89c..a5b598e2bf2 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -12,6 +12,13 @@ set -e link="/usr/bin/orca-ide" for dir in /opt/Orca /opt/orca-ide /opt/orca; do + sandbox="$dir/chrome-sandbox" + if [ -f "$sandbox" ]; then + # Why: packaged Linux installs must leave Chromium's sandbox helper usable + # on hosts where unprivileged user namespaces are unavailable. + chmod 4755 "$sandbox" || true + fi + shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. From 6090c671c441e78af9b4cf77a5599f7156d54497 Mon Sep 17 00:00:00 2001 From: Ricardo Sawir <37329575+sawirricardo@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:28:11 +0700 Subject: [PATCH 12/57] Show project icon colors for provider-backed headers (#6118) --- .../components/sidebar/project-header-color.test.ts | 10 ++++++++++ .../src/components/sidebar/project-header-color.ts | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/project-header-color.test.ts b/src/renderer/src/components/sidebar/project-header-color.test.ts index e02d622e3b3..931319f242d 100644 --- a/src/renderer/src/components/sidebar/project-header-color.test.ts +++ b/src/renderer/src/components/sidebar/project-header-color.test.ts @@ -39,6 +39,16 @@ describe('resolveProjectGroupHeaderColor', () => { ).toBe(REPO_COLORS[5]) }) + it('returns the repo color for provider-backed project headers', () => { + expect( + resolveProjectGroupHeaderColor({ + groupBy: 'repo', + headerKey: 'project:github:stablyai/orca', + badgeColor: REPO_COLORS[6] + }) + ).toBe(REPO_COLORS[6]) + }) + it('falls back to gray for unknown project group headers', () => { expect( resolveProjectGroupHeaderColor({ diff --git a/src/renderer/src/components/sidebar/project-header-color.ts b/src/renderer/src/components/sidebar/project-header-color.ts index 8a83920bd11..c92f22bf555 100644 --- a/src/renderer/src/components/sidebar/project-header-color.ts +++ b/src/renderer/src/components/sidebar/project-header-color.ts @@ -2,6 +2,7 @@ import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/consta import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:' +const PROVIDER_PROJECT_HEADER_KEY_PREFIX = 'project:' export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string { const normalizedBadgeColor = normalizeRepoBadgeColor(badgeColor) @@ -19,9 +20,13 @@ export function resolveProjectGroupHeaderColor(args: { headerKey: string badgeColor: string | null | undefined }): string | undefined { - // Why: pinned headers can appear while grouped by repo, but only repo:* headers - // represent a repo folder whose user-authored badge color should be shown. - if (args.groupBy !== 'repo' || !args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX)) { + // Why: pinned/project-folder headers can appear while grouped by repo; only + // actual project headers should inherit user-authored project colors. + if ( + args.groupBy !== 'repo' || + (!args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX) && + !args.headerKey.startsWith(PROVIDER_PROJECT_HEADER_KEY_PREFIX)) + ) { return undefined } return resolveRepoHeaderColor(args.badgeColor) From 814363c45fc4307810aa38fd622541b936a7c6b4 Mon Sep 17 00:00:00 2001 From: ChaDongWun <66347959+lovewave02@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:45:00 +0900 Subject: [PATCH 13/57] fix: preserve source control commit drafts across remounts (#6120) Co-authored-by: Jinwoo-H --- .../SourceControl.commit-drafts.test.ts | 19 +- .../right-sidebar/SourceControl.tsx | 16 +- .../source-control-commit-draft-session.ts | 20 ++ .../src/store/slices/store-cascades.test.ts | 10 + src/renderer/src/store/slices/worktrees.ts | 4 + ...e-control-commit-draft-persistence.spec.ts | 172 ++++++++++++++++++ 6 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/lib/source-control-commit-draft-session.ts create mode 100644 tests/e2e/source-control-commit-draft-persistence.spec.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts index d503abbe213..50eab6dbbce 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildResolveConflictsPrompt, normalizeSourceControlViewMode, @@ -9,8 +9,16 @@ import { writeCommitDraftForWorktree } from './SourceControl' import { getNextSourceControlViewMode } from './source-control-header-toolbar' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' describe('SourceControl commit drafts by worktree', () => { + afterEach(() => { + saveSessionCommitDrafts({}) + }) + it('returns an empty draft when the selected worktree has no message', () => { expect(readCommitDraftForWorktree({}, 'wt-a')).toBe('') }) @@ -28,6 +36,15 @@ describe('SourceControl commit drafts by worktree', () => { // than leaking the active worktree's text into all worktree views. expect(readCommitDraftForWorktree(drafts, 'wt-a')).toBe('feat: message for A') }) + + it('restores commit drafts after Source Control remounts in the same session', () => { + let drafts = loadSessionCommitDrafts() + + drafts = writeCommitDraftForWorktree(drafts, 'wt-a', 'feat: keep draft') + saveSessionCommitDrafts(drafts) + + expect(readCommitDraftForWorktree(loadSessionCommitDrafts(), 'wt-a')).toBe('feat: keep draft') + }) }) describe('SourceControl conflict resolution state', () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 245555f2d2c..45da0012ddd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -200,6 +200,10 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' import { getCommitFailureDialogWorktreeKey, shouldShowCommitFailureDialog, @@ -951,9 +955,11 @@ function SourceControlInner(): React.JSX.Element { // falsy until we have a real answer from the main process. const [defaultBaseRef, setDefaultBaseRef] = useState(null) const [filterQuery, setFilterQuery] = useState('') - // Why: commit drafts/errors are worktree-scoped during the mounted session, - // so switching worktrees restores each draft instead of wiping it. - const [commitDrafts, setCommitDrafts] = useState({}) + // Why: Source Control unmounts when the user switches tabs, so keep commit + // drafts in a module-scoped session cache and restore them on remount. + const [commitDrafts, setCommitDrafts] = useState(() => + loadSessionCommitDrafts() + ) const commitDraftsRef = useRef(commitDrafts) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< @@ -1780,6 +1786,10 @@ function SourceControlInner(): React.JSX.Element { } }, [updateCommitDrafts, worktreeMap]) + useEffect(() => { + saveSessionCommitDrafts(commitDrafts) + }, [commitDrafts]) + useEffect(() => { // Why: users often finish merge/rebase conflicts in a terminal. Once git // status observes that operation end, the old Source Control failure banner diff --git a/src/renderer/src/lib/source-control-commit-draft-session.ts b/src/renderer/src/lib/source-control-commit-draft-session.ts new file mode 100644 index 00000000000..45811235d0a --- /dev/null +++ b/src/renderer/src/lib/source-control-commit-draft-session.ts @@ -0,0 +1,20 @@ +type CommitDraftsByWorktree = Record + +let sessionCommitDraftsByWorktree: CommitDraftsByWorktree = {} + +export function loadSessionCommitDrafts(): CommitDraftsByWorktree { + return sessionCommitDraftsByWorktree +} + +export function saveSessionCommitDrafts(nextDrafts: CommitDraftsByWorktree): void { + sessionCommitDraftsByWorktree = nextDrafts +} + +export function clearSessionCommitDraftForWorktree(worktreeId: string): void { + if (!(worktreeId in sessionCommitDraftsByWorktree)) { + return + } + const next = { ...sessionCommitDraftsByWorktree } + delete next[worktreeId] + sessionCommitDraftsByWorktree = next +} diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 032fa0d81e5..4f2d06848e0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -80,6 +80,10 @@ import { } from './store-test-helpers' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' // ─── Tests ──────────────────────────────────────────────────────────── @@ -87,6 +91,7 @@ describe('removeWorktree cascade', () => { beforeEach(() => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() + saveSessionCommitDrafts({}) mockApi.worktrees.remove.mockResolvedValue(undefined) mockApi.worktrees.forceDeletePreservedBranch.mockResolvedValue({ deleted: true }) mockApi.runtimeEnvironments.call.mockReset() @@ -149,6 +154,10 @@ describe('removeWorktree cascade', () => { activeTabTypeByWorktree: { [worktreeId]: 'editor' }, rightSidebarExplorerViewByWorktree: { [worktreeId]: 'search' } }) + saveSessionCommitDrafts({ + [worktreeId]: 'feat: stale draft', + 'repo1::/path/wt2': 'fix: keep draft' + }) const result = await store.getState().removeWorktree(worktreeId) const s = store.getState() @@ -170,6 +179,7 @@ describe('removeWorktree cascade', () => { expect(s.activeFileIdByWorktree[worktreeId]).toBeUndefined() expect(s.activeTabTypeByWorktree[worktreeId]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[worktreeId]).toBeUndefined() + expect(loadSessionCommitDrafts()).toEqual({ 'repo1::/path/wt2': 'fix: keep draft' }) }) it('warns when workspace removal keeps the local branch', async () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index bd353c9fc1b..b44bece7790 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -39,6 +39,7 @@ import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { translate } from '@/i18n/i18n' @@ -2423,6 +2424,9 @@ export const createWorktreeSlice: StateCreator // assemblies (some unit tests) omit the generation slices. get().prunePullRequestGenerationRecords?.(liveWorktreeKeys) get().pruneCommitMessageGenerationRecords?.(liveWorktreeKeys) + // Why: Source Control may be unmounted during deletion, so its local + // prune effect cannot be the only stale-draft cleanup path. + clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch if (preservedBranch) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { diff --git a/tests/e2e/source-control-commit-draft-persistence.spec.ts b/tests/e2e/source-control-commit-draft-persistence.spec.ts new file mode 100644 index 00000000000..5e8fbe770c3 --- /dev/null +++ b/tests/e2e/source-control-commit-draft-persistence.spec.ts @@ -0,0 +1,172 @@ +import { execFileSync } from 'child_process' +import { rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type E2eWorktree = { + branchName: string + worktreePath: string +} + +function createWorktreeWithStagedChange(repoPath: string): E2eWorktree { + const branchName = `e2e-commit-draft-${Date.now()}-${Math.random().toString(16).slice(2)}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + writeFileSync( + path.join(worktreePath, 'README.md'), + '# Commit Draft Persistence E2E\n\nPreserve this draft across remounts.\n' + ) + execFileSync('git', ['add', 'README.md'], { cwd: worktreePath, stdio: 'pipe' }) + return { branchName, worktreePath } +} + +function cleanupWorktree(repoPath: string, worktreePath: string, branchName: string): void { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + try { + rmSync(worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // Best effort: the branch delete below is still worth attempting. + } + } + try { + execFileSync('git', ['branch', '-D', branchName], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // The branch may already be gone when git prunes it with the worktree. + } +} + +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, + testRepoPath + }) => { + let firstWorktree: E2eWorktree | null = null + let secondWorktree: E2eWorktree | null = null + + try { + firstWorktree = createWorktreeWithStagedChange(testRepoPath) + secondWorktree = createWorktreeWithStagedChange(testRepoPath) + await waitForSessionReady(orcaPage) + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + + const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + + const draft = 'fix: keep draft after leaving Source Control' + await textarea.fill(draft) + await expect(textarea).toHaveValue(draft) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('explorer') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('explorer') + await expect(textarea).toBeHidden() + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('source-control') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('source-control') + + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + + await openSourceControlForWorktree(orcaPage, testRepoPath, secondWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue('') + + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + } finally { + for (const worktree of [firstWorktree, secondWorktree]) { + if (worktree) { + cleanupWorktree(testRepoPath, worktree.worktreePath, worktree.branchName) + } + } + } + }) +}) From 23db6a71101dbe1c4f635f481260ff66a35f6a0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:18:04 -0700 Subject: [PATCH 14/57] Preserve linked work drafts during workspace startup (#6107) Co-authored-by: Orca --- .../folder-workspace-composer-submit.ts | 7 + .../terminal-pane/pty-connection.test.ts | 54 ++ .../terminal-pane/pty-connection.ts | 14 + src/renderer/src/hooks/useComposerState.ts | 7 + .../src/lib/agent-followup-delivery.ts | 49 ++ .../src/lib/agent-paste-draft.test.ts | 25 + src/renderer/src/lib/agent-paste-draft.ts | 95 +++- .../src/lib/agent-startup-delayed-delivery.ts | 212 ++++++++ src/renderer/src/lib/new-workspace.test.ts | 461 +++++++++++++++++- src/renderer/src/lib/new-workspace.ts | 108 ++-- .../src/lib/worktree-creation-flow.ts | 6 + src/shared/tui-agent-startup.ts | 1 + 12 files changed, 975 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/lib/agent-followup-delivery.ts create mode 100644 src/renderer/src/lib/agent-startup-delayed-delivery.ts diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 1d3d26a77f9..14a51127c2e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -5,6 +5,7 @@ import { } from '@/lib/new-workspace' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' +import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, @@ -223,6 +224,11 @@ export async function submitFolderWorkspaceCreate({ workspacePath: workspace.folderPath, connectionId: workspace.connectionId ?? projectGroup.connectionId }) + if (startupPlan && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const startup = quickAgent && startupPlan @@ -230,6 +236,7 @@ export async function submitFolderWorkspaceCreate({ command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } 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 ab99e7b28a3..d6b2d548c79 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4057,6 +4057,60 @@ describe('connectPanePty', () => { expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) }) + it('clears launch config when an agent startup spawn produces no PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-token-1', + launchAgent: 'codex' + } + }) as never + ) + try { + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { + agentType: 'codex', + launchToken: 'launch-token-1', + tabId: 'tab-1', + leafId: LEAF_1 + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + + const registerOrder = mockStoreState.registerAgentLaunchConfig.mock.invocationCallOrder[0] + const clearOrder = mockStoreState.clearAgentLaunchConfig.mock.invocationCallOrder[0] + expect(registerOrder).toBeDefined() + expect(clearOrder).toBeDefined() + expect(registerOrder!).toBeLessThan(clearOrder!) + } finally { + binding.dispose() + } + }) + it('prefers live-entry launch config for pane cold restore when status survived PTY loss', 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 5807981a5c5..2bb0555d6b2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -940,6 +940,9 @@ export function connectPanePty( leafId: pane.leafId }) } + const clearRegisteredStartupLaunchConfig = (): void => { + useAppStore.getState().clearAgentLaunchConfig(cacheKey) + } const pendingSpawnKey = cacheKey const neutralTerminalTitle = (): string => { const state = useAppStore.getState() @@ -2411,6 +2414,14 @@ export function connectPanePty( } if (resolvedPtyId) { clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + } else if ( + paneStartup?.launchConfig || + (startupOverride && 'launchConfig' in startupOverride) + ) { + // Why: delayed draft/follow-up delivery keys off this launch + // registry. If spawn produced no PTY, the launch is no longer a + // viable delivery target and must not wait for a future pane. + clearRegisteredStartupLaunchConfig() } const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { @@ -2427,6 +2438,9 @@ export function connectPanePty( return resolvedPtyId }) .catch(async () => { + if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) { + clearRegisteredStartupLaunchConfig() + } const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 8b89522a0de..699675d8b3d 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -9,6 +9,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' +import { createBrowserUuid } from '@/lib/browser-uuid' import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink, @@ -3119,6 +3120,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : undefined const backendSpawnedStartup = result.startupTerminal?.spawned === true + if (startupPlan && !backendSpawnedStartup && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this + // queued startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, @@ -3130,6 +3136,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: tuiAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts new file mode 100644 index 00000000000..d5a4c6ff9b9 --- /dev/null +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -0,0 +1,49 @@ +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import type { GlobalSettings } from '../../../shared/types' + +type RuntimeOwnerSettings = Pick | null | undefined + +export async function sendFollowupPromptWhenAgentReady(args: { + ptyId: string + expectedProcess: string + prompt: string + settings: RuntimeOwnerSettings +}): Promise { + const { ptyId, expectedProcess, prompt, settings } = args + if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) { + return false + } + try { + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + } catch { + return false + } +} + +// Why: delayed follow-ups must not type into an arbitrary shell. Require a +// positive expected-process match before writing user/task text to the PTY. +async function waitForAgentForeground( + ptyId: string, + expectedProcess: string, + settings: RuntimeOwnerSettings +): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) + } + try { + const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + } + return false +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ae687021224..2c6ae9ab6f0 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -6,6 +6,7 @@ import { chunkAgentDraftPasteContent, getSettingsForAgentTabRuntimeOwner, iterateAgentDraftPasteContentChunks, + pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, sendAgentDraftPasteContent, sendBracketedPasteToRunningAgent @@ -275,6 +276,30 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('honors the fallback inspection deadline for pty-bound draft paste', async () => { + const onTimeout = vi.fn() + testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {})) + + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: ISSUE_URL, + agent: 'codex', + forcePaste: true, + timeoutMs: 1, + onTimeout + }) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(1) + await flushMicrotasks(5) + await vi.advanceTimersByTimeAsync(1000) + + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + it('routes tab-owned paste writes through the worktree runtime owner', async () => { testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 4847004d309..6fc186a1326 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,7 +1,10 @@ import type { TuiAgent } from '../../../shared/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { useAppStore } from '@/store' -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -12,6 +15,7 @@ import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, @@ -123,6 +127,45 @@ export async function pasteDraftWhenAgentReady(args: { }) } +export async function pasteDraftToAgentPtyWhenReady(args: { + tabId: string + ptyId: string + content: string + agent?: TuiAgent + submit?: boolean + forcePaste?: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + + if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + return false + } + + const budget = timeoutMs ?? READINESS_TIMEOUT_MS + const settings = getSettingsForAgentTabRuntimeOwner(tabId) + const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) + if (!ready) { + const fallbackReady = agentConfig + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false + if (!fallbackReady) { + onTimeout?.() + return false + } + } + + return await sendBracketedPasteToAgent({ + settings, + ptyId, + content, + submit: submit === true + }) +} + export async function submitPromptToAgentTab(args: { tabId: string content: string @@ -191,3 +234,53 @@ async function waitForPtyId(tabId: string, timeoutMs: number): Promise | null | undefined +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const process = await withDeadline( + inspectRuntimeTerminalProcess(settings, ptyId), + Math.max(0, deadline - Date.now()) + ) + if (!process) { + return false + } + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + const delayMs = Math.min(120, Math.max(0, deadline - Date.now())) + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)) + } + } + return false +} + +function withDeadline(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return Promise.resolve(null) + } + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => resolve(null), timeoutMs) + promise.then( + (value) => { + window.clearTimeout(timer) + resolve(value) + }, + (error) => { + window.clearTimeout(timer) + reject(error) + } + ) + }) +} diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts new file mode 100644 index 00000000000..deba11170a6 --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -0,0 +1,212 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +type AppStoreSnapshot = ReturnType + +type PendingAgentStartupDelivery = { + worktreeId: string + tabId: string + launchToken: string + startup: AgentStartupPlan + deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise +} + +const pendingAgentStartupDeliveries = new Map() +const consumedAgentStartupDeliveries = new Set() +const staleStartupRecheckTimers = new Map>() +let unsubscribePendingAgentStartupDeliveries: (() => void) | null = null + +export function resolveAgentStartupTabId( + state: AppStoreSnapshot, + worktreeId: string, + primaryTabId: string | null | undefined +): string | null { + // Why: the caller may know the exact tab that received the queued startup + // command. Prefer it over focus-derived state, which can change mid-create. + return ( + primaryTabId ?? + state.activeTabIdByWorktree[worktreeId] ?? + state.tabsByWorktree[worktreeId]?.[0]?.id ?? + null + ) +} + +export function getAgentStartupTabPtyId( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): string | null { + const livePtyIds = new Set(state.ptyIdsByTabId[tabId] ?? []) + if (livePtyIds.size === 0) { + return null + } + for (const [paneKey, entry] of Object.entries(state.agentLaunchConfigByPaneKey ?? {})) { + const identity = entry.identity + if (identity.tabId !== tabId || identity.launchToken !== launchToken) { + continue + } + const leafId = identity.leafId ?? parsePaneKey(paneKey)?.leafId + if (!leafId) { + continue + } + const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + if (ptyId && livePtyIds.has(ptyId)) { + return ptyId + } + } + return null +} + +function worktreeStillOwnsStartupTab( + state: AppStoreSnapshot, + worktreeId: string, + tabId: string +): boolean { + return (state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function getPendingStartupLaunchToken(state: AppStoreSnapshot, tabId: string): string | undefined { + return state.pendingStartupByTabId?.[tabId]?.launchToken +} + +function hasRegisteredStartupLaunch( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): boolean { + return Object.values(state.agentLaunchConfigByPaneKey ?? {}).some( + (entry) => entry.identity.tabId === tabId && entry.identity.launchToken === launchToken + ) +} + +function ensurePendingAgentStartupSubscription(): void { + if (unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries = useAppStore.subscribe(() => { + flushPendingAgentStartupDeliveries() + }) +} + +function stopPendingAgentStartupSubscriptionIfIdle(): void { + if (pendingAgentStartupDeliveries.size > 0 || !unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries() + unsubscribePendingAgentStartupDeliveries = null +} + +export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDelivery): void { + const key = deliveryKey(delivery) + if (consumedAgentStartupDeliveries.has(key)) { + return + } + pendingAgentStartupDeliveries.set(key, delivery) + ensurePendingAgentStartupSubscription() + flushPendingAgentStartupDeliveries() +} + +export function resetAgentStartupDelayedDeliveryForTests(): void { + pendingAgentStartupDeliveries.clear() + consumedAgentStartupDeliveries.clear() + for (const timer of staleStartupRecheckTimers.values()) { + globalThis.clearTimeout(timer) + } + staleStartupRecheckTimers.clear() + unsubscribePendingAgentStartupDeliveries?.() + unsubscribePendingAgentStartupDeliveries = null +} + +export function beginAgentStartupDeliveryAttempt(args: { + worktreeId: string + tabId: string + launchToken: string +}): boolean { + const key = deliveryKey(args) + if (consumedAgentStartupDeliveries.has(key)) { + return false + } + consumedAgentStartupDeliveries.add(key) + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + return true +} + +function deliveryKey( + delivery: Pick +): string { + return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` +} + +function flushPendingAgentStartupDeliveries(): void { + const state = useAppStore.getState() + for (const [key, delivery] of pendingAgentStartupDeliveries) { + const { tabId, launchToken } = delivery + if (!worktreeStillOwnsStartupTab(state, delivery.worktreeId, tabId)) { + pendingAgentStartupDeliveries.delete(key) + continue + } + const queuedLaunchToken = getPendingStartupLaunchToken(state, tabId) + const launchRegistered = hasRegisteredStartupLaunch(state, tabId, launchToken) + if (queuedLaunchToken !== launchToken && !launchRegistered && queuedLaunchToken !== undefined) { + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + continue + } + if (queuedLaunchToken === undefined && !launchRegistered) { + scheduleStaleStartupRecheck(key) + continue + } + const ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) + if (!ptyId) { + continue + } + // Why: once the launch-bound PTY exists, the bounded readiness/paste path + // owns success or failure. Consume before awaiting so store churn cannot + // duplicate a linked-work-item draft. + if (beginAgentStartupDeliveryAttempt(delivery)) { + void delivery.deliver(tabId, ptyId, delivery.startup).catch((error) => { + console.warn('Queued agent startup delivery failed', error) + }) + } + } + stopPendingAgentStartupSubscriptionIfIdle() +} + +function scheduleStaleStartupRecheck(key: string): void { + if (staleStartupRecheckTimers.has(key)) { + return + } + staleStartupRecheckTimers.set( + key, + globalThis.setTimeout(() => { + staleStartupRecheckTimers.delete(key) + const delivery = pendingAgentStartupDeliveries.get(key) + if (!delivery) { + stopPendingAgentStartupSubscriptionIfIdle() + return + } + const state = useAppStore.getState() + const queuedLaunchToken = getPendingStartupLaunchToken(state, delivery.tabId) + if ( + queuedLaunchToken === undefined && + !hasRegisteredStartupLaunch(state, delivery.tabId, delivery.launchToken) + ) { + pendingAgentStartupDeliveries.delete(key) + } else { + flushPendingAgentStartupDeliveries() + } + stopPendingAgentStartupSubscriptionIfIdle() + }, 1000) + ) +} + +function clearStaleStartupRecheck(key: string): void { + const timer = staleStartupRecheckTimers.get(key) + if (!timer) { + return + } + globalThis.clearTimeout(timer) + staleStartupRecheckTimers.delete(key) +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 4626a06d259..d753e0977d0 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -1,27 +1,72 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, - mockPasteDraftWhenAgentReady, + mockPasteDraftToAgentPtyWhenReady, mockTrack, - store + store, + storeListeners, + startupLeafId } = vi.hoisted(() => ({ mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), - mockPasteDraftWhenAgentReady: vi.fn(), + mockPasteDraftToAgentPtyWhenReady: vi.fn(), mockTrack: vi.fn(), + storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), + startupLeafId: '11111111-1111-4111-8111-111111111111', store: { settings: {}, activeTabIdByWorktree: { 'wt-1': 'tab-1' } as Record, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as Record, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + pendingStartupByTabId: {} as Record, + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { '11111111-1111-4111-8111-111111111111': 'pty-1' } + } + } as Record< + string, + { + root: null + activeLeafId: null + expandedLeafId: null + ptyIdsByLeafId?: Record + } + >, + agentLaunchConfigByPaneKey: { + 'tab-1:11111111-1111-4111-8111-111111111111': { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + launchToken: 'launch-token-1' + } + } + } as Record< + string, + { + launchConfig: { agentCommand: string; agentArgs: string; agentEnv: Record } + registeredAt: number + identity: { tabId?: string; leafId?: string; launchToken?: string } + } + > } })) vi.mock('@/store', () => ({ useAppStore: { - getState: () => store + getState: () => store, + subscribe: (listener: (state: unknown, previousState: unknown) => void) => { + storeListeners.add(listener) + return () => { + storeListeners.delete(listener) + } + } } })) @@ -31,7 +76,12 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ - pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady + getSettingsForAgentTabRuntimeOwner: () => store.settings, + pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'launch-token-1' })) vi.mock('@/lib/telemetry', () => ({ @@ -44,6 +94,7 @@ import { getWorkspaceSeedName, isGitLabIssueUrl } from './new-workspace' +import { resetAgentStartupDelayedDeliveryForTests } from './agent-startup-delayed-delivery' describe('getWorkspaceSeedName', () => { it('prefers an explicit name', () => { @@ -171,17 +222,40 @@ describe('isGitLabIssueUrl', () => { describe('ensureAgentStartupInTerminal prompt delivery', () => { beforeEach(() => { + vi.useRealTimers() vi.clearAllMocks() + storeListeners.clear() store.settings = {} store.activeTabIdByWorktree = { 'wt-1': 'tab-1' } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } store.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-1' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } mockInspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'aider', hasChildProcesses: true }) mockSendRuntimePtyInputVerified.mockResolvedValue(true) - mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + resetAgentStartupDelayedDeliveryForTests() }) it('sends a follow-up prompt through the terminal runtime without renderer telemetry', async () => { @@ -249,10 +323,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'tab-1', + ptyId: 'pty-1', content: 'review this before sending', - agent: 'claude' + agent: 'claude', + forcePaste: true }) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) @@ -261,6 +337,21 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { store.activeTabIdByWorktree = { 'wt-1': 'setup-tab' } store.tabsByWorktree = { 'wt-1': [{ id: 'setup-tab' }, { id: 'agent-tab' }] } store.ptyIdsByTabId = { 'setup-tab': ['setup-pty'], 'agent-tab': ['agent-pty'] } + store.terminalLayoutsByTabId = { + 'agent-tab': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'agent-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`agent-tab:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'agent-tab', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } await ensureAgentStartupInTerminal({ worktreeId: 'wt-1', @@ -275,12 +366,358 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'agent-tab', + ptyId: 'agent-pty', content: 'Linear context draft', - agent: 'codex' + agent: 'codex', + forcePaste: true }) }) + + it('pastes a draft after the seeded tab receives a delayed PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'https://github.com/stablyai/orca/pull/2051' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'pty-delayed', + content: 'https://github.com/stablyai/orca/pull/2051', + agent: 'codex', + forcePaste: true + }) + }) + + it('keeps waiting when a non-startup split PTY appears before the startup PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + const splitLeafId = '22222222-2222-4222-8222-222222222222' + store.ptyIdsByTabId = { 'tab-1': ['split-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['split-pty', 'startup-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty', [startupLeafId]: 'startup-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + }, + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 2, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'startup-pty', + content: 'linked draft', + agent: 'codex', + forcePaste: true + }) + }) + + it('does not duplicate delayed delivery across repeated store updates', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('does not duplicate immediate delivery for the same launch token', async () => { + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('keeps one delayed subscription and tears it down after delivery drains', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + const first = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + const second = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + await vi.advanceTimersByTimeAsync(5_000) + await Promise.all([first, second]) + + expect(storeListeners.size).toBe(1) + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(storeListeners.size).toBe(0) + }) + + it('does not let a newer same-tab launch satisfy an older pending delivery', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-old' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'old linked draft', + launchToken: 'launch-token-old' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-new' } } + for (const listener of storeListeners) { + listener(store, store) + } + + store.ptyIdsByTabId = { 'tab-1': ['pty-new'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-new' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-old' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + }) + + it('does not write a delayed follow-up prompt on readiness timeout', async () => { + vi.useFakeTimers() + mockInspectRuntimeTerminalProcess.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) }) describe('getSetupConfig', () => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 2402b68e84e..149c0e7708f 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,16 +1,21 @@ import { useAppStore } from '@/store' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' + getSettingsForAgentTabRuntimeOwner, + pasteDraftToAgentPtyWhenReady +} from '@/lib/agent-paste-draft' +import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import { isShellProcess } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' +import { + beginAgentStartupDeliveryAttempt, + getAgentStartupTabPtyId, + queuePendingAgentStartupDelivery, + resolveAgentStartupTabId +} from '@/lib/agent-startup-delayed-delivery' import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' -import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { slugifyForWorkspaceName } from '../../../shared/workspace-name' +import { createBrowserUuid } from '@/lib/browser-uuid' export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name' export { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' export { getWorkspaceIntentName } from '../../../shared/workspace-name' @@ -244,6 +249,7 @@ export async function ensureAgentStartupInTerminal(args: { if (startup.followupPrompt === null && draftPrompt === null) { return } + const launchToken = ensureStartupLaunchToken(startup) // Why: poll until a terminal tab + PTY exists for the worktree before we // can interact with it. Activation creates the tab synchronously but the @@ -252,79 +258,79 @@ export async function ensureAgentStartupInTerminal(args: { let ptyId: string | null = null for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) } const state = useAppStore.getState() - // Why: workspace activation tells us the exact tab that received the agent - // startup command. Use it for draft paste instead of re-deriving from - // active tab state, which can move while setup/background panes mount. - tabId = - primaryTabId ?? - state.activeTabIdByWorktree[worktreeId] ?? - state.tabsByWorktree[worktreeId]?.[0]?.id ?? - null + tabId = resolveAgentStartupTabId(state, worktreeId, primaryTabId) if (!tabId) { continue } - ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null + ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) if (ptyId) { break } } if (!tabId || !ptyId) { + if (tabId) { + // Why: background-created workspaces can remain unmounted longer than a + // bounded readiness wait. Keep the draft/follow-up tied to the seeded + // tab until opening the workspace creates its PTY. + queuePendingAgentStartupDelivery({ + worktreeId, + tabId, + launchToken, + startup, + deliver: deliverAgentStartupToTerminal + }) + } return } + if (beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken })) { + await deliverAgentStartupToTerminal(tabId, ptyId, startup) + } +} + +async function deliverAgentStartupToTerminal( + tabId: string, + ptyId: string, + startup: AgentStartupPlan +): Promise { + const draftPrompt = startup.draftPrompt ?? null + const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) // Why: followupPrompt is the legacy path for stdin-after-start agents // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await waitForAgentForeground(ptyId, startup.expectedProcess) - await sendFollowupPrompt(ptyId, startup.followupPrompt) + await sendFollowupPromptWhenAgentReady({ + ptyId, + expectedProcess: startup.expectedProcess, + prompt: startup.followupPrompt, + settings: runtimeSettings + }) } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the // agent's input buffer (no per-char echo, no auto-submit). Shared with the // launch-work-item-direct flow so both behave identically. if (draftPrompt) { - await pasteDraftWhenAgentReady({ + await pasteDraftToAgentPtyWhenReady({ tabId, + ptyId, content: draftPrompt, - agent: startup.agent + agent: startup.agent, + // Why: startup.draftPrompt is only attached after native draft launch + // planning is unavailable, so this paste is the first delivery attempt. + forcePaste: true }) } } -async function sendFollowupPrompt(ptyId: string, prompt: string): Promise { - try { - return await sendRuntimePtyInputVerified(useAppStore.getState().settings, ptyId, `${prompt}\r`) - } catch { - return false - } -} - -// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with -// a hasChildProcesses fallback after several polls). Preserve that behavior so -// stdin-after-start agents still receive their prompt under the same -// conditions. Returns when the agent appears ready or the budget expires. -async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { - for (let attempt = 0; attempt < 30; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) - } - try { - const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) - const foreground = process.foregroundProcess?.toLowerCase() ?? '' - if (isExpectedAgentProcess(foreground, expectedProcess)) { - return - } - if (attempt >= 4 && !isShellProcess(foreground)) { - if (process.hasChildProcesses) { - return - } - } - } catch { - // Ignore transient PTY inspection failures and keep polling. - } +function ensureStartupLaunchToken(startup: AgentStartupPlan): string { + if (!startup.launchToken) { + // Why: delayed delivery retries must reuse the startup plan's launch token + // so they match the pane launch registration for this agent. + startup.launchToken = createBrowserUuid() } + return startup.launchToken } diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 052777529c4..576a2a341ad 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -32,6 +32,7 @@ function buildStartupOpt( command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), launchConfig: plan.launchConfig, + ...(plan.launchToken ? { launchToken: plan.launchToken } : {}), ...(request.agent ? { launchAgent: request.agent } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), // Why: command-code shows its prompt in the tab status before the first @@ -144,6 +145,11 @@ async function executeWorktreeCreation( } const backendSpawned = result.startupTerminal?.spawned === true + if (request.startupPlan && !backendSpawned && !request.startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves of the handoff share one renderer-session token. + request.startupPlan.launchToken = createBrowserUuid() + } const startupOpt = buildStartupOpt(request, backendSpawned) if (worktree.path) { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 274869eee69..a5e691543a0 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -25,6 +25,7 @@ export type AgentStartupPlan = { expectedProcess: string followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig + launchToken?: string draftPrompt?: string | null env?: Record startupCommandDelivery?: StartupCommandDelivery From b6271bd9f645b7836250774796e00902429d5b7b Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 22 Jun 2026 23:20:33 -0700 Subject: [PATCH 15/57] feat(activity): focus filter with Cmd/Ctrl+F in Agents view (#6046) Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../activity/ActivityPrototypePage.test.ts | 188 ++++++++++++++++++ .../activity/ActivityPrototypePage.tsx | 82 +++++++- 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 66c48919045..b9e03ecc492 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -13,6 +13,9 @@ import { ACTIVITY_SEARCH_QUERY_MAX_BYTES, activityThreadResponseRenderPreview, activityThreadMatchesSearchQuery, + handleActivityFilterFocusShortcut, + isActivityFilterFocusShortcut, + shouldIgnoreActivityFilterFocusShortcutTarget, buildActivityThreadGroups, buildActivityEvents, buildAgentPaneThreads, @@ -724,3 +727,188 @@ describe('activity thread grouping', () => { expect(buildActivityThreadGroups([], 'status')).toEqual([]) }) }) + +describe('activity filter focus shortcut', () => { + it('matches Cmd+F on Mac and Ctrl+F elsewhere without extra modifiers', () => { + expect( + isActivityFilterFocusShortcut( + { key: 'f', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false }, + true + ) + ).toBe(true) + expect( + isActivityFilterFocusShortcut( + { key: 'F', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false }, + false + ) + ).toBe(true) + expect( + isActivityFilterFocusShortcut( + { key: 'f', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false }, + true + ) + ).toBe(false) + expect( + isActivityFilterFocusShortcut( + { key: 'f', metaKey: true, ctrlKey: false, shiftKey: true, altKey: false }, + true + ) + ).toBe(false) + expect( + isActivityFilterFocusShortcut( + { key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false }, + true + ) + ).toBe(false) + expect( + isActivityFilterFocusShortcut( + { key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false }, + false + ) + ).toBe(false) + }) + + it('prevents default, focuses, and selects only for handled filter shortcuts', () => { + let prevented = 0 + let stopped = 0 + let stoppedImmediate = 0 + let focused = 0 + let selected = 0 + const input = { + focus: () => { + focused += 1 + }, + select: () => { + selected += 1 + } + } as Pick + const activeElement = { + classList: { contains: () => false } + } as unknown as Element + const terminalElement = { + classList: { contains: (className: string) => className === 'xterm-helper-textarea' } + } as unknown as Element + const terminalPortalTarget = { + contains: (target: Element) => target === terminalElement + } as unknown as HTMLElement + + expect( + handleActivityFilterFocusShortcut({ + activeElement, + event: { + key: 'f', + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => { + prevented += 1 + }, + stopPropagation: () => { + stopped += 1 + }, + stopImmediatePropagation: () => { + stoppedImmediate += 1 + } + }, + input, + isMac: true, + terminalPortalTargets: [] + }) + ).toBe(true) + expect(prevented).toBe(1) + expect(stopped).toBe(1) + expect(stoppedImmediate).toBe(1) + expect(focused).toBe(1) + expect(selected).toBe(1) + + expect( + handleActivityFilterFocusShortcut({ + activeElement: terminalElement, + event: { + key: 'f', + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => { + prevented += 1 + }, + stopPropagation: () => { + stopped += 1 + }, + stopImmediatePropagation: () => { + stoppedImmediate += 1 + } + }, + input, + isMac: true, + terminalPortalTargets: [terminalPortalTarget] + }) + ).toBe(false) + expect(prevented).toBe(1) + expect(stopped).toBe(1) + expect(stoppedImmediate).toBe(1) + expect(focused).toBe(1) + expect(selected).toBe(1) + }) + + it('does not prevent default when the filter input is unavailable', () => { + let prevented = 0 + const activeElement = { + classList: { contains: () => false } + } as unknown as Element + + expect( + handleActivityFilterFocusShortcut({ + activeElement, + event: { + key: 'f', + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => { + prevented += 1 + }, + stopPropagation: () => { + throw new Error('unavailable input must not stop propagation') + }, + stopImmediatePropagation: () => { + throw new Error('unavailable input must not stop immediate propagation') + } + }, + input: null, + isMac: true, + terminalPortalTargets: [] + }) + ).toBe(false) + expect(prevented).toBe(0) + }) + + it('ignores shortcut handling while terminal-owned elements have focus', () => { + const terminalTextarea = { + classList: { contains: (className: string) => className === 'xterm-helper-textarea' } + } as unknown as Element + const portalChild = { + classList: { contains: () => false } + } as unknown as Element + const outside = { + classList: { contains: () => false } + } as unknown as Element + const portalTarget = { + contains: (target: Element) => target === portalChild || target === terminalTextarea + } as unknown as HTMLElement + const hiddenWorkbenchTerminal = { + classList: { contains: (className: string) => className === 'xterm-helper-textarea' } + } as unknown as Element + expect(shouldIgnoreActivityFilterFocusShortcutTarget(terminalTextarea, [portalTarget])).toBe( + true + ) + expect(shouldIgnoreActivityFilterFocusShortcutTarget(portalChild, [portalTarget])).toBe(true) + expect(shouldIgnoreActivityFilterFocusShortcutTarget(outside, [portalTarget])).toBe(false) + expect( + shouldIgnoreActivityFilterFocusShortcutTarget(hiddenWorkbenchTerminal, [portalTarget]) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index de6276a5007..38cd942c765 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: this prototype keeps the real-data adapter and current visual skeleton together until the next refinement pass decides which pieces become production modules. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { Bell, @@ -1028,6 +1028,70 @@ export function activityThreadMatchesSearchQuery({ return threadSearchText(thread).includes(trimmedQuery.toLowerCase()) } +export function isActivityFilterFocusShortcut( + event: Pick, + isMac = navigator.userAgent.includes('Mac') +): boolean { + if (event.key.toLowerCase() !== 'f' || event.shiftKey || event.altKey) { + return false + } + return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey +} + +export function shouldIgnoreActivityFilterFocusShortcutTarget( + target: Element | null, + terminalPortalTargets: (HTMLElement | null)[] +): boolean { + if (!target) { + return false + } + // Why: the workspace terminal stays mounted while Activity is open; only the + // Activity-portaled terminal should keep Cmd/Ctrl+F for terminal search. + return terminalPortalTargets.some((portalTarget) => portalTarget?.contains(target) ?? false) +} + +export function handleActivityFilterFocusShortcut({ + activeElement, + event, + input, + isMac, + terminalPortalTargets +}: { + activeElement: Element | null + event: Pick< + KeyboardEvent, + | 'altKey' + | 'ctrlKey' + | 'key' + | 'metaKey' + | 'preventDefault' + | 'shiftKey' + | 'stopImmediatePropagation' + | 'stopPropagation' + > + input: Pick | null + isMac?: boolean + terminalPortalTargets: (HTMLElement | null)[] +}): boolean { + if (shouldIgnoreActivityFilterFocusShortcutTarget(activeElement, terminalPortalTargets)) { + return false + } + if (!isActivityFilterFocusShortcut(event, isMac)) { + return false + } + if (!input) { + return false + } + event.preventDefault() + // Why: hidden workspace xterms can retain focus behind Activity; capture-phase + // handling must stop the chord before xterm forwards it to a local/SSH PTY. + event.stopPropagation() + event.stopImmediatePropagation() + input.focus() + input.select() + return true +} + function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element { const state = threadAgentState(thread) const label = threadAgentStateLabel(thread) @@ -1283,6 +1347,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { const [readFilter, setReadFilter] = useState('all') const [groupBy, setGroupBy] = useState('status') const [query, setQuery] = useState('') + const activityFilterInputRef = useRef(null) const [compactMode, setCompactMode] = useState(false) const [selectedPaneKey, setSelectedPaneKey] = useState(null) const [displayedPaneKey, setDisplayedPaneKey] = useState(null) @@ -1555,6 +1620,20 @@ export default function ActivityPrototypePage(): React.JSX.Element { } }, []) + useEffect(() => { + const focusActivityFilter = (event: KeyboardEvent): void => { + handleActivityFilterFocusShortcut({ + activeElement: document.activeElement, + event, + input: activityFilterInputRef.current, + terminalPortalTargets: [activePortalTargetEl, inactivePortalTargetEl] + }) + } + + window.addEventListener('keydown', focusActivityFilter, { capture: true }) + return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true }) + }, [activePortalTargetEl, inactivePortalTargetEl]) + const markThreadRead = (thread: AgentPaneThread): void => { storeData.acknowledgeAgents([thread.paneKey]) } @@ -1660,6 +1739,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
setQuery(event.target.value)} placeholder={translate( From 20a39999684d453b9cecacd73e22eacf4f4ced66 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:10:55 -0700 Subject: [PATCH 16/57] Fix source control base branch truncation (#6146) Co-authored-by: Orca --- ...source-control-branch-context-row.test.tsx | 40 +++++++++++++++++++ .../source-control-branch-context-row.tsx | 40 +++++++++++-------- 2 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx new file mode 100644 index 00000000000..c9eb2fad92d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { SourceControlBranchContextRow } from './source-control-branch-context-row' +import type { GitBranchCompareSummary } from '../../../../shared/types' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip', + baseOid: 'base', + compareRef: 'feature', + headOid: 'head', + mergeBase: 'base', + changedFiles: 0, + commitsAhead: 0, + status: 'ready' +} + +describe('SourceControlBranchContextRow', () => { + it('lets the base ref use the full available header width', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('refs/remotes/origin/FRONT-192-ZisVoucherStrip') + expect(markup).toContain('max-w-full') + expect(markup).toContain('min-w-0 flex-1') + expect(markup).not.toContain('max-w-[9rem]') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx index e2c51b1d81d..27d1183d5ac 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx @@ -25,7 +25,7 @@ function BaseRefButton({ return (
) } @@ -102,11 +104,13 @@ export function SourceControlBranchContextRow({ if (summary.status !== 'ready') { return (
- + + + {summary.errorMessage ?? translate( @@ -131,15 +135,17 @@ export function SourceControlBranchContextRow({ return (
-
+
{translate('auto.components.right.sidebar.SourceControl.e8a1c4b203', 'vs')} - + + +
{stats.length > 0 ? ( From 17dee0861fc228a8ed06aae4606281a16fde746d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:23:34 -0700 Subject: [PATCH 17/57] Fix terminal tab switch resume rendering (#6041) Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 4 + .../components/terminal-pane/TerminalPane.tsx | 3 + .../TerminalPaneOverlayLayer.tsx | 34 +- .../terminal-pane/pty-connection.test.ts | 77 ++ .../terminal-pane/pty-connection.ts | 6 +- ...erminal-startup-command-classifier.test.ts | 16 + .../terminal-startup-command-classifier.ts | 32 +- .../terminal-visibility-resume.ts | 150 +++ .../use-terminal-pane-global-effects.test.ts | 257 +++++- .../use-terminal-pane-global-effects.ts | 95 +- tests/e2e/terminal-screenshot-diff.ts | 47 + tests/e2e/terminal-tab-screenshot.ts | 21 + ...terminal-tab-switch-visual-restore.spec.ts | 867 ++++++++++++++++++ 13 files changed, 1545 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts create mode 100644 tests/e2e/terminal-screenshot-diff.ts create mode 100644 tests/e2e/terminal-tab-screenshot.ts create mode 100644 tests/e2e/terminal-tab-switch-visual-restore.spec.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 133e9a0704b..d9439d07c48 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1888,6 +1888,10 @@ function Terminal(): React.JSX.Element | null { // Keeping `isVisible` true for the portaled tab lets // xterm fit and stream foreground output in-place. isVisible={isActiveTerminalTab || isActivityPortalTab} + // Why: inactive tabs in the visible legacy surface + // are tab-hidden, not worktree-hidden, so they need + // the same light resume path as split-group overlays. + isWorktreeActive={isVisible || isActivityPortalTab} // Why: when portaled to Activity for a specific agent // pane, isolate that leaf so split siblings stay // hidden. Workspace renders pass null → no override. diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e8d91971126..e8079e8caa8 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -146,6 +146,7 @@ type TerminalPaneProps = { cwd?: string isActive: boolean isVisible?: boolean + isWorktreeActive?: boolean // Why: when set (Activity portal), this pane visually isolates the given // split pane so only that leaf is shown. Implemented as a transient layout // override (separate snapshot ref) — does NOT touch expandedPaneId state @@ -220,6 +221,7 @@ export default function TerminalPane({ cwd, isActive, isVisible = true, + isWorktreeActive = isVisible, isolatedPaneKey = null, onPtyExit, onCloseTab @@ -1318,6 +1320,7 @@ export default function TerminalPane({ cwd, isActive, isVisible, + isWorktreeActive, // Why: hidden startup probes are opacity-hidden but measurable; ordinary // hidden tabs are display:none and refit on visibility resume instead. isSyncFitEnabled: isVisible || shouldMeasureHiddenStartup, diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 4bcf72a9350..0eeb013a375 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -26,6 +26,8 @@ const HAS_CSS_ANCHOR_POSITIONING = CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') +const MIN_OVERLAY_FIT_WIDTH_PX = 48 +const MIN_OVERLAY_FIT_HEIGHT_PX = 24 function shouldUseCssAnchorPositioning(): boolean { return ( @@ -47,6 +49,7 @@ type TerminalOverlaySlotProps = { worktreeId: string worktreePath: string groupId: string | undefined + isWorktreeActive: boolean isVisible: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null @@ -62,6 +65,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ worktreeId, worktreePath, groupId, + isWorktreeActive, isVisible, isActive, activityTerminalPortal, @@ -128,21 +132,37 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ }, [anchorName, groupId, isVisible]) useLayoutEffect(() => { - if (!isVisible || !anchorName || shouldUseCssAnchorPositioning()) { + if (!isVisible || !anchorName) { return } - // Why: worktree switches resume visibility before fallback positioning - // settles. Re-fit on show and again after the measured rect lands so the - // PTY never stays pinned at a stale ~2-col width. - const frameId = requestAnimationFrame(() => { + const dispatchFitIfMeasurable = (): void => { + const rect = overlayRef.current?.getBoundingClientRect() + if ( + !rect || + rect.width < MIN_OVERLAY_FIT_WIDTH_PX || + rect.height < MIN_OVERLAY_FIT_HEIGHT_PX + ) { + return + } window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + } + + // Why: tab switches can resume visibility before anchor/fallback geometry + // settles. Re-fit only after the overlay has real dimensions so the PTY + // never stays pinned at a stale ~2-col width. + const frameId = requestAnimationFrame(() => { + dispatchFitIfMeasurable() }) const retryId = window.setTimeout(() => { - window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + dispatchFitIfMeasurable() }, 50) + const settledRetryId = window.setTimeout(() => { + dispatchFitIfMeasurable() + }, 150) return () => { cancelAnimationFrame(frameId) window.clearTimeout(retryId) + window.clearTimeout(settledRetryId) } }, [anchorName, isVisible, measuredFallbackRect]) @@ -202,6 +222,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. isVisible={isVisible || activityTerminalPortal !== null} + isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { @@ -332,6 +353,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId={worktreeId} worktreePath={worktreePath} groupId={assignment?.groupId} + isWorktreeActive={isWorktreeActive} isVisible={isVisible} isActive={isActive} activityTerminalPortal={activityTerminalPortal} 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 d6b2d548c79..69b24da6aab 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4786,6 +4786,48 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden Grok telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'grok', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('keeps hidden bare Codex startup commands parsing briefly', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -4821,6 +4863,41 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden bare Grok startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('skips arbitrary hidden startup output parsing', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2bb0555d6b2..e33355fbc0b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,7 +84,7 @@ import { import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isCodexTerminalStartupCommand } from './terminal-startup-command-classifier' +import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -316,8 +316,8 @@ function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { return ( - startup?.telemetry?.agent_kind === 'codex' || - isCodexTerminalStartupCommand(startup?.command ?? '') + Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index c6851a92066..3444bf412d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getTerminalStartupCommandToken, isCodexTerminalStartupCommand, + isKnownTuiAgentTerminalStartupCommand, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS } from './terminal-startup-command-classifier' @@ -22,6 +23,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('codex') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) @@ -30,6 +32,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('C:\\Program Files\\Orca\\codex.cmd') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) }) it('recognizes POSIX Codex wrapper names', () => { @@ -37,6 +40,18 @@ describe('terminal startup command classifier', () => { expect(isCodexTerminalStartupCommand('/usr/local/bin/not-codex --continue')).toBe(false) }) + it('recognizes non-Codex Orca agent startup commands', () => { + expect(isKnownTuiAgentTerminalStartupCommand('grok --permission-mode bypassPermissions')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/Users/me/.grok/bin/grok --resume abc')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-grok --resume abc')).toBe( + false + ) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) @@ -45,6 +60,7 @@ describe('terminal startup command classifier', () => { TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS ) expect(isCodexTerminalStartupCommand(command)).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(false) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index 9bfd858185c..f171efc01cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -1,7 +1,24 @@ +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' + const TERMINAL_STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i // Why: startup commands can carry pasted scripts; classifier work should stay bounded. export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = 4096 +const KNOWN_TUI_AGENT_EXECUTABLES = new Set() + +for (const config of Object.values(TUI_AGENT_CONFIG)) { + for (const candidate of [ + config.detectCmd, + config.expectedProcess, + ...getTuiAgentDetectCommands(config) + ]) { + const executable = normalizeTerminalStartupCommandExecutableName(candidate) + if (executable) { + KNOWN_TUI_AGENT_EXECUTABLES.add(executable) + } + } +} + export function getTerminalStartupCommandToken(command: string): string { const scanLimit = Math.min(command.length, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) let index = 0 @@ -38,10 +55,23 @@ export function isCodexTerminalStartupCommand(command: string): boolean { return executable === 'codex' || executable.startsWith('codex-') } +export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean { + const executable = getTerminalStartupCommandExecutableName(command) + return ( + KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || + executable.startsWith('codex-') || + executable.startsWith('grok-') + ) +} + function getTerminalStartupCommandExecutableName(command: string): string { const token = getTerminalStartupCommandToken(command) const segmentStart = getTerminalStartupCommandPathSegmentStart(token) - return token.slice(segmentStart).toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') + return normalizeTerminalStartupCommandExecutableName(token.slice(segmentStart)) +} + +function normalizeTerminalStartupCommandExecutableName(executable: string): string { + return executable.toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') } function getTerminalStartupCommandPathSegmentStart(token: string): number { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts new file mode 100644 index 00000000000..87db96c3a22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -0,0 +1,150 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' +import { + flushTerminalOutput, + requestTerminalBacklogRecovery +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' + +const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 + +export type TerminalHiddenReason = 'surface' | 'tab' + +type ResumeTerminalVisibilityArgs = { + manager: PaneManager + isActive: boolean + wasVisible: boolean + shouldUseLightTabResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map + withSuppressedScrollTracking: (callback: () => void) => void +} + +type HideTerminalVisibilityArgs = { + manager: PaneManager + wasVisible: boolean + wasWorktreeActive: boolean + isWorktreeActive: boolean + hasCompletedVisibleResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map +} + +type HideTerminalVisibilityResult = { + hiddenReason: TerminalHiddenReason | null + renderingSuspended: boolean +} + +export function resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking +}: ResumeTerminalVisibilityArgs): void { + // Why: WebGL resume can disturb xterm's viewport bookkeeping before the + // post-resume fit runs. Capture numeric viewport positions first; the + // restore path avoids content matching so duplicate agent log lines do + // not jump to the wrong history entry. + const viewportPositions = captureViewportPositions(!wasVisible) + withSuppressedScrollTracking(() => { + if (shouldUseLightTabResume) { + // Why: intra-worktree tab switches only toggle the overlay. Keeping + // synchronous drain and atlas rebuilds off this path avoids racing the + // overlay's delayed geometry fit. Still request hidden-output recovery: + // agent TUIs can suppress hidden bytes until the pane is foregrounded. + requestLightTabBacklogRecovery(manager) + if (isActive) { + focusActivePane(manager) + } + } else { + resumeTerminalVisibilityHeavy(manager, isActive) + } + restoreTerminalViewportPositions(manager, viewportPositions) + if (!shouldUseLightTabResume) { + // Why: this clear wipes the glyph atlas shared with other same-config + // terminals; the global reset rebuilds their render models too. + resetAllTerminalWebglAtlases() + } + }) +} + +export function hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume, + captureViewportPositions +}: HideTerminalVisibilityArgs): HideTerminalVisibilityResult { + const surfaceBecameHidden = wasWorktreeActive && !isWorktreeActive + if (wasVisible) { + // Why: hidden DOM/layout churn can mutate xterm's viewport before the + // pane becomes visible again. Preserve the last visible position. + captureViewportPositions(false) + } + if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { + // Suspend WebGL when going hidden. xterm.write() continues to land in + // the (now DOM-renderer-fallback or paused-canvas) terminal; the + // suspend is purely a GPU resource decision. + manager.suspendRendering() + return { hiddenReason: 'surface', renderingSuspended: true } + } + if (!hasCompletedVisibleResume && wasVisible && wasWorktreeActive && isWorktreeActive) { + // Why: the visibility hook starts wasVisible=true so terminal tabs that + // first mount hidden still release WebGL contexts instead of exhausting + // Chromium's small context budget. + manager.suspendRendering() + return { hiddenReason: 'tab', renderingSuspended: true } + } + if (wasVisible && isWorktreeActive) { + return { hiddenReason: 'tab', renderingSuspended: false } + } + if (!isWorktreeActive) { + return { hiddenReason: 'surface', renderingSuspended: false } + } + return { hiddenReason: null, renderingSuspended: false } +} + +function requestLightTabBacklogRecovery(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + } +} + +function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): void { + // Why: hidden panes can accumulate large PTY bursts while Chromium is + // occluded. Drain a bounded slice before fitting; the scheduler keeps + // ordering and continues the rest asynchronously so return-to-app does + // not beachball behind an entire backlog. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) + } + // Resume WebGL immediately so the terminal shows its last-known state + // on the first painted frame. macOS context creation is ~5 ms; on + // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume + // would paint a stretched DOM-fallback flash, which is worse UX. + manager.resumeRendering() + // Single fit on resume. Background bytes have been pushed into xterm + // above, so this fit only absorbs container dimension changes that + // happened while hidden (e.g. sidebar toggle on another worktree). + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } +} + +function restoreTerminalViewportPositions( + manager: PaneManager, + viewportPositions: Map +): void { + for (const pane of manager.getPanes()) { + const position = viewportPositions.get(pane.id) + if (position) { + restoreScrollStateAfterLayout(pane.terminal, position) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 82a185783ec..df9f4ba45fc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ captureScrollState: vi.fn(), fitAndFocusPanes: vi.fn(), fitPanes: vi.fn(), + focusActivePane: vi.fn(), flushTerminalOutput: vi.fn(), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), @@ -58,7 +59,8 @@ vi.mock('react', async (importOriginal) => { vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: mocks.fitAndFocusPanes, - fitPanes: mocks.fitPanes + fitPanes: mocks.fitPanes, + focusActivePane: mocks.focusActivePane })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ @@ -113,6 +115,7 @@ function useMountForFileDrop( cwd?: string isActive?: boolean isVisible?: boolean + isWorktreeActive?: boolean isSyncFitEnabled?: boolean paneCount?: number } = {} @@ -150,6 +153,7 @@ function useMountForFileDrop( cwd: options.cwd, isActive: options.isActive ?? true, isVisible: options.isVisible ?? true, + isWorktreeActive: options.isWorktreeActive ?? options.isVisible ?? true, isSyncFitEnabled: options.isSyncFitEnabled ?? options.isVisible ?? true, paneCount: options.paneCount ?? 0, managerRef: { current: manager as never }, @@ -278,6 +282,257 @@ describe('useTerminalPaneGlobalEffects', () => { expect(isVisibleRef.current).toBe(true) }) + it('uses a light resume for tab switches while the worktree stays active', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.suspendRendering.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('keeps visible active-state updates on the light resume path', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('suspends rendering when a terminal tab first mounts hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.suspendRendering.mockClear() + manager.resumeRendering.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + }) + + it('suspends a tab-hidden terminal when its worktree surface becomes hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + manager.suspendRendering.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: true + }) + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + it('reports the active local PTY to the main output scheduler', () => { const manager = { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5fc62839abc..23a03049dc1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,22 +8,19 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' -import { - flushTerminalOutput, - requestTerminalBacklogRecovery -} from '@/lib/pane-manager/pane-terminal-output-scheduler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' -import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' import { handleTerminalProgrammaticTextPaste } from './terminal-programmatic-text-paste' - -const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +import { + hideTerminalVisibility, + resumeTerminalVisibility, + type TerminalHiddenReason +} from './terminal-visibility-resume' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -31,6 +28,7 @@ type UseTerminalPaneGlobalEffectsArgs = { cwd?: string isActive: boolean isVisible: boolean + isWorktreeActive?: boolean isSyncFitEnabled: boolean paneCount: number managerRef: React.RefObject @@ -47,6 +45,7 @@ export function useTerminalPaneGlobalEffects({ cwd, isActive, isVisible, + isWorktreeActive = isVisible, isSyncFitEnabled, paneCount, managerRef, @@ -65,6 +64,10 @@ export function useTerminalPaneGlobalEffects({ // otherwise leak WebGL contexts — openTerminal() unconditionally creates // one — and exhaust Chromium's ~8-context budget across worktrees. const wasVisibleRef = useRef(true) + const wasWorktreeActiveRef = useRef(isWorktreeActive) + const hasCompletedVisibleResumeRef = useRef(false) + const renderingSuspendedByVisibilityRef = useRef(false) + const hiddenReasonRef = useRef(null) const { captureViewportPositions, withSuppressedScrollTracking, @@ -83,61 +86,47 @@ export function useTerminalPaneGlobalEffects({ if (!manager) { return } + const wasVisible = wasVisibleRef.current + const wasWorktreeActive = wasWorktreeActiveRef.current isActiveRef.current = isActive isVisibleRef.current = isVisible if (isVisible) { - // Why: WebGL resume can disturb xterm's viewport bookkeeping before the - // post-resume fit runs. Capture numeric viewport positions first; the - // restore path avoids content matching so duplicate agent log lines do - // not jump to the wrong history entry. - const viewportPositions = captureViewportPositions(!wasVisibleRef.current) - withSuppressedScrollTracking(() => { - // Why: hidden panes can accumulate large PTY bursts while Chromium is - // occluded. Drain a bounded slice before fitting; the scheduler keeps - // ordering and continues the rest asynchronously so return-to-app does - // not beachball behind an entire backlog. - for (const pane of manager.getPanes()) { - requestTerminalBacklogRecovery(pane.terminal) - flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) - } - // Resume WebGL immediately so the terminal shows its last-known state - // on the first painted frame. macOS context creation is ~5 ms; on - // Windows (ANGLE → D3D11) it can be 100–500 ms but a deferred resume - // would paint a stretched DOM-fallback flash, which is worse UX. - manager.resumeRendering() - // Single fit on resume. Background bytes have been pushed into xterm - // above, so this fit only absorbs container dimension changes that - // happened while hidden (e.g. sidebar toggle on another worktree). - if (isActive) { - fitAndFocusPanes(manager) - } else { - fitPanes(manager) - } - for (const pane of manager.getPanes()) { - const position = viewportPositions.get(pane.id) - if (position) { - restoreScrollStateAfterLayout(pane.terminal, position) - } - } - // Why: this clear wipes the glyph atlas shared with other same-config - // terminals; the global reset rebuilds their render models too. - resetAllTerminalWebglAtlases() + const shouldUseLightTabResume = + isWorktreeActive && + hasCompletedVisibleResumeRef.current && + !renderingSuspendedByVisibilityRef.current && + (wasVisible || hiddenReasonRef.current === 'tab') + resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking }) + renderingSuspendedByVisibilityRef.current = false wasVisibleRef.current = true + wasWorktreeActiveRef.current = isWorktreeActive + hasCompletedVisibleResumeRef.current = true + hiddenReasonRef.current = null applyPendingFollowOutputRequests() return - } else if (wasVisibleRef.current) { - // Why: hidden DOM/layout churn can mutate xterm's viewport before the - // pane becomes visible again. Preserve the last visible position. - captureViewportPositions(false) - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. - manager.suspendRendering() + } else { + const hiddenState = hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, + captureViewportPositions + }) + renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended + hiddenReasonRef.current = hiddenState.hiddenReason } wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive, isVisible, isWorktreeActive]) useEffect(() => { if (!isVisible) { diff --git a/tests/e2e/terminal-screenshot-diff.ts b/tests/e2e/terminal-screenshot-diff.ts new file mode 100644 index 00000000000..1a3819a2691 --- /dev/null +++ b/tests/e2e/terminal-screenshot-diff.ts @@ -0,0 +1,47 @@ +import { PNG } from 'pngjs' + +export type ScreenshotDiffSummary = { + matches: boolean + diffPixels: number + diffRatio: number + width: number + height: number +} + +export function compareTerminalScreenshots( + baselineBuffer: Buffer, + candidateBuffer: Buffer +): ScreenshotDiffSummary { + const baseline = PNG.sync.read(baselineBuffer) + const candidate = PNG.sync.read(candidateBuffer) + if (baseline.width !== candidate.width || baseline.height !== candidate.height) { + return { + matches: false, + diffPixels: Number.POSITIVE_INFINITY, + diffRatio: Number.POSITIVE_INFINITY, + width: candidate.width, + height: candidate.height + } + } + + let diffPixels = 0 + for (let offset = 0; offset < baseline.data.length; offset += 4) { + const redDiff = Math.abs((baseline.data[offset] ?? 0) - (candidate.data[offset] ?? 0)) + const greenDiff = Math.abs((baseline.data[offset + 1] ?? 0) - (candidate.data[offset + 1] ?? 0)) + const blueDiff = Math.abs((baseline.data[offset + 2] ?? 0) - (candidate.data[offset + 2] ?? 0)) + const alphaDiff = Math.abs((baseline.data[offset + 3] ?? 0) - (candidate.data[offset + 3] ?? 0)) + if (redDiff + greenDiff + blueDiff + alphaDiff > 48) { + diffPixels += 1 + } + } + + const pixelCount = baseline.width * baseline.height + const diffRatio = pixelCount > 0 ? diffPixels / pixelCount : Number.POSITIVE_INFINITY + return { + matches: diffRatio <= 0.015, + diffPixels, + diffRatio, + width: baseline.width, + height: baseline.height + } +} diff --git a/tests/e2e/terminal-tab-screenshot.ts b/tests/e2e/terminal-tab-screenshot.ts new file mode 100644 index 00000000000..506ed1a200f --- /dev/null +++ b/tests/e2e/terminal-tab-screenshot.ts @@ -0,0 +1,21 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' + +function tabScreenLocator(page: Page, tabId: string): ReturnType { + return page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() +} + +export async function captureStableTabScreenshot(page: Page, tabId: string): Promise { + const screen = tabScreenLocator(page, tabId) + await expect(screen).toBeVisible() + let previous = await screen.screenshot({ animations: 'disabled' }) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screen.screenshot({ animations: 'disabled' }) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error(`Terminal surface for tab ${tabId} did not stabilize for screenshot`) +} diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts new file mode 100644 index 00000000000..d154882ec23 --- /dev/null +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -0,0 +1,867 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' + +const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r' +const TAB_A_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~' +const TAB_B_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' + +type TabTerminalGeometry = { + tabId: string + overlayWidth: number + overlayHeight: number + overlayDisplay: string + cols: number + rows: number + cellWidth: number + screenWidth: number + screenRight: number + rowRight: number + contentWidthRatio: number + markerPresent: boolean + hasWebgl: boolean +} + +const TAB_SWITCH_MARKER_PREFIX = 'TAB_SWITCH_VISUAL_RESTORE' + +type TerminalOutputSchedulerSnapshot = { + backgroundEnqueueCount: number + scheduledDrainCount: number + queuedChars: number +} + +type SchedulerDebugWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => TerminalOutputSchedulerSnapshot + } +} + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void + } +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function ensureTwoTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + if ((await page.locator('[data-testid="sortable-tab"]').count()) < 2) { + await page.getByRole('button', { name: 'New tab' }).click({ force: true }) + await page + .getByRole('menuitem', { name: /New Terminal/i }) + .first() + .click({ force: true }) + await expect + .poll(() => page.locator('[data-testid="sortable-tab"]').count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + } + const firstTabId = (await getActiveTabId(page))! + const secondTabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const other = tabs.find((tab) => tab.id !== state.activeTabId) + return other?.id ?? null + }, worktreeId) + if (!secondTabId) { + throw new Error('Expected a second terminal tab') + } + return { firstTabId, secondTabId } +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex' | 'grok', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function createCodexMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'codex', 'node -e "setInterval(() => {}, 1000)"') +} + +async function createGrokMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'grok', 'node -e "setInterval(() => {}, 1000)"') +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + return page + .waitForFunction( + (id) => { + const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((entry) => entry.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() + const ptyId = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId) + if (!ptyId) { + throw new Error(`Pane for tab ${tabId} has no PTY binding`) + } + return ptyId +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +async function resetTerminalOutputSchedulerDebug(page: Page): Promise { + await page.evaluate(() => { + const debug = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API unavailable') + } + debug.reset() + }) +} + +async function waitForHiddenOutputSchedulerActivity( + page: Page +): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const snapshot = ( + window as SchedulerDebugWindow + ).__terminalOutputSchedulerDebug?.snapshot() + return snapshot?.backgroundEnqueueCount ?? 0 + }), + { + timeout: 5_000, + message: 'hidden PTY output did not reach the background output scheduler' + } + ) + .toBeGreaterThan(0) + return page.evaluate(() => { + const snapshot = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug?.snapshot() + if (!snapshot) { + throw new Error('Terminal output scheduler debug API unavailable') + } + return { + backgroundEnqueueCount: snapshot.backgroundEnqueueCount, + scheduledDrainCount: snapshot.scheduledDrainCount, + queuedChars: snapshot.queuedChars + } + }) +} + +async function startHiddenPtyOutputBurst(page: Page, ptyId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_PTY_${runId}` + const script = [ + `const marker=${JSON.stringify(marker)};`, + 'setTimeout(()=>{', + 'let frame=0;', + 'const timer=setInterval(()=>{', + 'console.log(`${marker} frame=${String(frame).padStart(3,"0")} abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~`);', + 'frame+=1;', + 'if(frame>=180) clearInterval(timer);', + '},1);', + '},30);' + ].join('') + await sendToTerminal(page, ptyId, `node -e ${JSON.stringify(script)}\r`) +} + +async function writeStaticTabContent( + page: Page, + tabId: string, + marker: string, + glyphRow: string +): Promise { + await page.evaluate( + async ({ id, marker, glyphRow }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`Pane unavailable for tab ${id}`) + } + const rows = Array.from( + { length: 14 }, + (_, row) => `${marker} row ${row} | ${glyphRow} |\r\n` + ).join('') + await new Promise((resolve) => + pane.terminal.write(`\x1b[2J\x1b[3J\x1b[H\x1b[?25l${rows}`, resolve) + ) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, + { id: tabId, marker, glyphRow } + ) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function resetAtlasOnTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.() + }, tabId) +} + +async function injectHiddenStreamingBurst(page: Page, tabId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + await page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + // Why: Grok sessions can emit large formatted bursts while the tab is + // hidden; stress the visibility-resume flush path beyond a few lines. + const burst = Array.from({ length: 400 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 16) + 1)}${'░'.repeat(16 - ((frame % 16) + 1))}` + return [ + `hidden_stream frame=${String(frame).padStart(3, '0')} ${marker}`, + `Dimension │ Rating │`, + `status ${frame % 2 === 0 ? 'thinking' : 'streaming'} ${progress}`, + `abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~` + ].join('\r\n') + }).join('\r\n') + return new Promise((resolve) => { + pane.terminal.write(`${burst}\r\n`, resolve) + }) + }, + { tabId, marker } + ) +} + +async function readTabTerminalGeometry( + page: Page, + tabId: string, + runId: string +): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + return page.evaluate( + ({ tabId, marker }) => { + const overlay = document.querySelector( + `[data-terminal-overlay-tab-id="${tabId}"]` + ) + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const overlayRect = overlay?.getBoundingClientRect() + const screen = pane.container.querySelector('.xterm-screen') + if (!screen) { + throw new Error(`No xterm screen for tab ${tabId}`) + } + const screenRect = screen.getBoundingClientRect() + const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 + const renderedContentWidth = pane.terminal.cols * cellWidth + const rowRight = screenRect.left + renderedContentWidth + const contentWidthRatio = screenRect.width > 0 ? renderedContentWidth / screenRect.width : 0 + const buffer = pane.terminal.buffer.active + let markerPresent = false + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + if (line.includes(marker)) { + markerPresent = true + break + } + } + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + const hasWebgl = diagnostics.some((entry) => entry.hasWebgl) + return { + tabId, + overlayWidth: overlayRect?.width ?? 0, + overlayHeight: overlayRect?.height ?? 0, + overlayDisplay: overlay ? window.getComputedStyle(overlay).display : 'missing', + cols: pane.terminal.cols, + rows: pane.terminal.rows, + cellWidth, + screenWidth: screenRect.width, + screenRight: screenRect.right, + rowRight, + contentWidthRatio, + markerPresent, + hasWebgl + } + }, + { tabId, marker } + ) +} + +function geometryLooksCorrupted(geometry: TabTerminalGeometry): string | null { + if (geometry.overlayDisplay === 'none') { + return 'overlay still display:none after activation' + } + if (geometry.overlayWidth < 200 || geometry.overlayHeight <= 0) { + return `overlay dimensions invalid (${geometry.overlayWidth}x${geometry.overlayHeight}px)` + } + if (geometry.cols < 40 || geometry.rows <= 0) { + return `terminal grid invalid (${geometry.cols}x${geometry.rows})` + } + // Why: half-width bug paints content in only ~50% of the screen; rowRight + // lags far behind screenRight when cols are stale. + if (geometry.contentWidthRatio > 0 && geometry.contentWidthRatio < 0.82) { + return `content width ratio ${geometry.contentWidthRatio.toFixed(3)} < 0.82` + } + if ( + geometry.screenWidth > 0 && + geometry.rowRight < geometry.screenRight - geometry.cellWidth * 4 + ) { + return `rowRight ${geometry.rowRight.toFixed(1)} lags screenRight ${geometry.screenRight.toFixed(1)}` + } + return null +} + +async function captureTabScreenshot( + page: Page, + tabId: string, + testInfo: TestInfo, + label: string +): Promise { + const overlay = page.locator(`[data-terminal-overlay-tab-id="${tabId}"]`) + const path = testInfo.outputPath(`${label}-${tabId}.png`) + await overlay.screenshot({ path }) + await testInfo.attach(`${label}.png`, { path, contentType: 'image/png' }) +} + +test.describe('Terminal tab switch visual restore', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps full-width geometry after switching away and back', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + await writeStaticTabContent(orcaPage, firstTabId, marker, TAB_A_GLYPH_ROW) + + const baseline = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + expect(baseline.markerPresent).toBe(true) + expect(baseline.overlayWidth).toBeGreaterThan(300) + expect(geometryLooksCorrupted(baseline)).toBeNull() + + const corruptionReports: string[] = [] + await resetTerminalOutputSchedulerDebug(orcaPage) + await startHiddenPtyOutputBurst(orcaPage, firstPtyId, runId) + + for (let cycle = 0; cycle < 12; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await injectHiddenStreamingBurst(orcaPage, firstTabId, runId) + // Why: rapid back-to-back switches mirror the user's leave/return pattern + // and race the overlay's rAF/50ms refit retries. + await activateTerminalTab(orcaPage, firstTabId) + if (cycle % 3 === 0) { + await activateTerminalTab(orcaPage, secondTabId) + await activateTerminalTab(orcaPage, firstTabId) + } + + // Sample immediately — bug often shows before the 50ms overlay refit retry. + const immediate = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const immediateIssue = geometryLooksCorrupted(immediate) + if (immediateIssue) { + corruptionReports.push(`cycle ${cycle} immediate: ${immediateIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-immediate-cycle-${cycle}` + ) + } + + await orcaPage.waitForTimeout(60) + const settled = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const settledIssue = geometryLooksCorrupted(settled) + if (settledIssue) { + corruptionReports.push(`cycle ${cycle} settled: ${settledIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-settled-cycle-${cycle}` + ) + } + } + const schedulerActivity = await waitForHiddenOutputSchedulerActivity(orcaPage) + expect(schedulerActivity.scheduledDrainCount).toBeGreaterThan(0) + + if (corruptionReports.length > 0) { + console.log('[tab-switch-repro] corruption reports:', corruptionReports) + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `tab switch left stale terminal geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('keeps geometry after hidden alt-screen TUI redraws during tab switches', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const finalMarker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}_ALT_24` + + await orcaPage.evaluate( + ({ tabId, finalMarker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frames = Array.from({ length: 25 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + }).join('') + return new Promise((resolve) => pane.terminal.write(frames, resolve)) + }, + { tabId: firstTabId, finalMarker } + ) + + const corruptionReports: string[] = [] + for (let cycle = 0; cycle < 6; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate( + ({ tabId, finalMarker, cycle }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frame = cycle * 4 + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const redraw = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + return new Promise((resolve) => pane.terminal.write(redraw, resolve)) + }, + { tabId: firstTabId, finalMarker, cycle } + ) + await activateTerminalTab(orcaPage, firstTabId) + + const geometry = await readTabTerminalGeometry(orcaPage, firstTabId, `${runId}_ALT`) + const issue = geometryLooksCorrupted(geometry) + if (issue || !geometry.markerPresent) { + corruptionReports.push( + `cycle ${cycle}: ${issue ?? 'marker missing after alt-screen redraw'}` + ) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `alt-screen-corrupt-cycle-${cycle}` + ) + } + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `alt-screen hidden redraw left corrupted geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('restores skipped hidden agent output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createCodexMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_AGENT_${runId}` + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped hidden-output recovery' + }) + .toContain(marker) + }) + + test('restores skipped hidden Grok output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const grokTabId = await createGrokMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, grokTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, grokTabId) + const paneKey = `${grokTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_GROK_${runId}` + // Why: synchronized-output mode exercises the hidden renderer skip path + // used by agent TUIs before light tab resume requests recovery. + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Grok-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, grokTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped Grok hidden-output recovery' + }) + .toContain(marker) + }) + + test('keeps returned tab glyphs intact across tab switches', async ({ orcaPage }, testInfo) => { + // Why: screenshot equality catches WebGL atlas corruption on the tab being + // resumed, not just stale cols/rows geometry checks. + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate((id) => { + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + if (!firstWebgl || !secondWebgl) { + test.skip(true, 'WebGL never attached on both tabs') + return + } + + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + const secondPtyId = await waitForPanePtyIdOnTab(orcaPage, secondTabId) + await sendToTerminal(orcaPage, firstPtyId, SILENT_FOREGROUND_COMMAND) + await sendToTerminal(orcaPage, secondPtyId, SILENT_FOREGROUND_COMMAND) + await orcaPage.waitForTimeout(1_000) + + const runId = `${Date.now()}` + const markerA = `${TAB_SWITCH_MARKER_PREFIX}_A_${runId}` + const markerB = `${TAB_SWITCH_MARKER_PREFIX}_B_${runId}` + await writeStaticTabContent(orcaPage, firstTabId, markerA, TAB_A_GLYPH_ROW) + await activateTerminalTab(orcaPage, secondTabId) + await writeStaticTabContent(orcaPage, secondTabId, markerB, TAB_B_GLYPH_ROW) + + await activateTerminalTab(orcaPage, firstTabId) + await resetAtlasOnTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(800) + const baseline = await captureStableTabScreenshot(orcaPage, firstTabId) + + const screenshotMismatches: string[] = [] + for (let cycle = 0; cycle < 8; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + // Why: do not write into the hidden tab here — new bytes would change the + // screenshot even when rendering is healthy. This cycle only exercises the + // suspend/resume + atlas reset path on unchanged content. + await activateTerminalTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(100) + const afterReturn = await captureStableTabScreenshot(orcaPage, firstTabId) + const diff = compareTerminalScreenshots(baseline, afterReturn) + if (!diff.matches) { + screenshotMismatches.push( + `cycle ${cycle}: ${diff.diffPixels} px (${(diff.diffRatio * 100).toFixed(2)}%)` + ) + await testInfo.attach(`after-return-cycle-${cycle}`, { + body: afterReturn, + contentType: 'image/png' + }) + } + } + + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + expect( + screenshotMismatches, + screenshotMismatches.length > 0 + ? `returned tab glyphs changed after switch cycles: ${screenshotMismatches.join(', ')}` + : undefined + ).toEqual([]) + }) +}) From edf98f65786bc7ed11ad23a60e09fb0ca7952e7c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:25:56 -0700 Subject: [PATCH 18/57] Add copy link action for linked work items (#6140) Co-authored-by: Orca --- .../WorktreeCardIssueDetailSection.tsx | 147 ++++++++++++++++++ .../WorktreeCardMeta.interaction.test.tsx | 84 +++++++++- .../sidebar/WorktreeCardMeta.test.tsx | 10 +- .../components/sidebar/WorktreeCardMeta.tsx | 119 +++++++------- .../WorktreeCardReviewDetailSection.tsx | 104 ++++++++----- .../worktree-card-details-hover-state.ts | 26 ++-- src/renderer/src/i18n/locales/en.json | 7 + 7 files changed, 372 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx new file mode 100644 index 00000000000..76fa707c30b --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx @@ -0,0 +1,147 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { CircleDot, Copy, Ellipsis, ExternalLink, MonitorUp, Pencil } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { + WorktreeCardDetailSection, + WorktreeCardDetailSectionContent +} from './WorktreeCardDetailSection' +import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { IssueStateBadge } from './WorktreeCardMetadataStatusBadges' +import type { WorktreeCardIssueDisplay } from './worktree-card-meta-types' + +type WorktreeCardIssueDetailSectionProps = { + issue: WorktreeCardIssueDisplay | null + issueMenuOpen: boolean + onIssueMenuOpenChange: (open: boolean) => void + onCopyIssueLink?: () => void + onEditIssue?: (event: React.MouseEvent) => void + onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void +} + +export function WorktreeCardIssueDetailSection({ + issue, + issueMenuOpen, + onIssueMenuOpenChange, + onCopyIssueLink, + onEditIssue, + onOpenGitHubIssueInOrca +}: WorktreeCardIssueDetailSectionProps): React.JSX.Element | null { + if (!issue) { + return null + } + + const issueLabels = issue.labels ?? [] + const moreActionsLabel = translate( + 'auto.components.sidebar.WorktreeCardMeta.moreIssueActions', + 'More issue actions' + ) + const moreActionsTrigger = ( + + + + ) + + return ( + + } + label={translate( + 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', + 'Issue #{{value0}}', + { + value0: issue.number + } + )} + actions={ + <> + {issue.url && onCopyIssueLink && ( + + {issueMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} + + + + {translate('auto.components.sidebar.WorktreeCardMeta.copyLink', 'Copy link')} + + + + )} + {onEditIssue && ( + + + + )} + {issue.url && onOpenGitHubIssueInOrca && ( + + + + )} + {issue.url && ( + + + + )} + + } + /> + +
+ {issue.title} +
+ {(issue.state || issueLabels.length > 0) && ( +
+ {issue.state && } + {issueLabels.map((label) => ( + + {label} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 07c73ec5620..7464b6f5477 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -2,9 +2,14 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn() +})) + const interactionMocks = vi.hoisted(() => ({ hoverOpen: false, onHoverOpenChange: undefined as ((open: boolean) => void) | undefined, @@ -13,6 +18,10 @@ const interactionMocks = vi.hoisted(() => ({ onUnlinkSelect: undefined as (() => void) | undefined })) +vi.mock('sonner', () => ({ + toast: toastMocks +})) + vi.mock('@/components/ui/hover-card', () => ({ HoverCard: ({ children, @@ -78,6 +87,7 @@ const reviewFixture = { describe('WorktreeCardDetailsHover interactions', () => { let container: HTMLDivElement let root: Root + const writeClipboardText = vi.fn() afterEach(() => { act(() => { @@ -89,6 +99,21 @@ describe('WorktreeCardDetailsHover interactions', () => { interactionMocks.onHoverOpenChange = undefined interactionMocks.onReviewMenuOpenChange = undefined interactionMocks.onUnlinkSelect = undefined + writeClipboardText.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() + }) + + beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + writeClipboardText + } + } + }) + writeClipboardText.mockResolvedValue(undefined) }) function renderHover(onUnlinkReview = vi.fn()): ReturnType { @@ -142,16 +167,14 @@ describe('WorktreeCardDetailsHover interactions', () => { ) }) - it('suppresses the tooltip while the review menu is open', () => { + it('omits the review trigger tooltip while the review menu is open', () => { renderHover() act(() => { interactionMocks.onReviewMenuOpenChange?.(true) }) - expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe( - 'false' - ) + expect(container.textContent).not.toContain('More PR actions') }) it('invokes unlink and closes the hover from the menu item', () => { @@ -178,4 +201,55 @@ describe('WorktreeCardDetailsHover interactions', () => { container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') ).toBe('false') }) + + it('copies the review URL and closes the hover from the menu item', async () => { + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.success).toHaveBeenCalledWith('PR link copied') + expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe( + 'false' + ) + expect( + container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') + ).toBe('false') + }) + + it('reports clipboard failures without unlinking the review', async () => { + writeClipboardText.mockRejectedValueOnce(new Error('clipboard unavailable')) + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledWith('Failed to copy link') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 54be63c528a..895efe1151a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -109,13 +109,15 @@ describe('WorktreeCardDetailsHover', () => { expect(moreActionsIndex).toBeGreaterThan(-1) expect(markup).toContain('More PR actions') + expect(markup).toContain('Copy link') expect(markup).toContain('Unlink PR') expect(moreActionsIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) expect(markup).not.toContain('aria-label="Unlink PR"') + expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR')) }) - it('puts issue edit before open actions and keeps GitHub last', () => { + it('puts issue copy menu before edit and open actions and keeps GitHub last', () => { const markup = renderToStaticMarkup( { ) + const moreActionsIndex = markup.indexOf('aria-label="More issue actions"') + const copyLinkIndex = markup.indexOf('Copy link') const editIssueIndex = markup.indexOf('aria-label="Edit issue"') const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"') const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"') + expect(moreActionsIndex).toBeGreaterThan(-1) + expect(copyLinkIndex).toBeGreaterThan(-1) expect(editIssueIndex).toBeGreaterThan(-1) + expect(moreActionsIndex).toBeLessThan(editIssueIndex) + expect(copyLinkIndex).toBeLessThan(editIssueIndex) expect(editIssueIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 9025c86e6f6..a33e167669b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -12,7 +13,7 @@ import { WorktreeCardDetailSectionContent } from './WorktreeCardDetailSection' import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls' -import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges' +import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' import type { @@ -25,6 +26,7 @@ import type { import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' +import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection' export type { WorktreeCardIssueDisplay, @@ -155,8 +157,10 @@ export function WorktreeCardDetailsHover({ const internalHoverControl = useWorktreeCardDetailsHoverControl() const { hoverOpen, + issueMenuOpen, reviewMenuOpen, handleHoverOpenChange, + handleIssueMenuOpenChange, handleReviewMenuOpenChange, closeHover } = hoverControl ?? internalHoverControl @@ -167,6 +171,43 @@ export function WorktreeCardDetailsHover({ }, [closeHover] ) + const copyLinkedWorkItemLink = React.useCallback(async (url: string, label: string) => { + try { + // Why: Electron clipboard IPC remains reliable from nested hover/dropdown + // overlays where browser clipboard activation can be lost. + await window.api.ui.writeClipboardText(url) + toast.success( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkSuccess', '{{value0}} copied', { + value0: label + }) + ) + } catch { + toast.error( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkFailure', 'Failed to copy link') + ) + } + }, []) + const handleCopyIssueLink = React.useCallback((): void => { + if (!issue?.url) { + return + } + closeHover() + void copyLinkedWorkItemLink( + issue.url, + translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link') + ) + }, [closeHover, copyLinkedWorkItemLink, issue?.url]) + const handleCopyReviewLink = React.useCallback((): void => { + if (!review?.url) { + return + } + void copyLinkedWorkItemLink( + review.url, + translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', { + value0: getReviewLabel(review) + }) + ) + }, [copyLinkedWorkItemLink, review]) const showIdentityHeader = Boolean(branchName || workspaceTitle) @@ -178,7 +219,6 @@ export function WorktreeCardDetailsHover({ return children } - const issueLabels = issue?.labels ?? [] const branchIdentity = branchName ? (
)} - {issue && ( - - } - label={translate( - 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', - 'Issue #{{value0}}', - { value0: issue.number } - )} - actions={ - <> - {onEditIssue && ( - - - - )} - {issue.url && onOpenGitHubIssueInOrca && ( - - - - )} - {issue.url && ( - - - - )} - - } - /> - -
- {issue.title} -
- {(issue.state || issueLabels.length > 0) && ( -
- {issue.state && } - {issueLabels.map((label) => ( - - {label} - - ))} -
- )} -
-
- )} + {linearIssue && ( @@ -357,6 +343,7 @@ export function WorktreeCardDetailsHover({ reviewMenuOpen={reviewMenuOpen} onReviewMenuOpenChange={handleReviewMenuOpenChange} onOpenReviewInOrca={onOpenReviewInOrca} + onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined} onUnlinkReview={onUnlinkReview} closeHover={closeHover} /> diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx index 8b8f774fd34..257ccd70c70 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' +import { Copy, Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' import { translate } from '@/i18n/i18n' import { WorktreeCardDetailSection, @@ -23,6 +23,7 @@ type WorktreeCardReviewDetailSectionProps = { reviewMenuOpen: boolean onReviewMenuOpenChange: (open: boolean) => void onOpenReviewInOrca?: (event: React.MouseEvent) => void + onCopyReviewLink?: () => void onUnlinkReview?: () => void closeHover: () => void } @@ -32,6 +33,7 @@ export function WorktreeCardReviewDetailSection({ reviewMenuOpen, onReviewMenuOpenChange, onOpenReviewInOrca, + onCopyReviewLink, onUnlinkReview, closeHover }: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null { @@ -41,6 +43,25 @@ export function WorktreeCardReviewDetailSection({ const reviewLabel = getReviewLabel(review) const reviewProvider = getProviderName(review) + const moreActionsLabel = translate( + 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', + 'More {{value0}} actions', + { value0: reviewLabel } + ) + const moreActionsTrigger = ( + + + + ) const dismissAndOpenReview = (event: React.MouseEvent): void => { closeHover() onOpenReviewInOrca?.(event) @@ -57,53 +78,52 @@ export function WorktreeCardReviewDetailSection({ )} actions={ <> - {onUnlinkReview && ( + {(onCopyReviewLink || onUnlinkReview) && ( - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', - 'More {{value0}} actions', - { value0: reviewLabel } - )} - - + {reviewMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} - { - closeHover() - onUnlinkReview?.() - }} - > - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', - 'Unlink {{value0}}', - { value0: reviewLabel } - )} - + {onCopyReviewLink && ( + { + closeHover() + onCopyReviewLink() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink', + 'Copy link' + )} + + )} + {onUnlinkReview && ( + { + closeHover() + onUnlinkReview() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', + 'Unlink {{value0}}', + { value0: reviewLabel } + )} + + )} )} diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts index b90b20d55eb..e17c12b7a26 100644 --- a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts @@ -1,32 +1,34 @@ import { useCallback, useRef, useState } from 'react' +type WorktreeCardDetailMenu = 'issue' | 'review' + export function useWorktreeCardDetailsHoverControl() { const [open, setOpen] = useState(false) - const [reviewMenuOpen, setReviewMenuOpen] = useState(false) + const [openMenu, setOpenMenu] = useState(null) const pendingHoverCloseRef = useRef(false) const closeHover = useCallback(() => { pendingHoverCloseRef.current = false - setReviewMenuOpen(false) + setOpenMenu(null) setOpen(false) }, []) const handleHoverOpenChange = useCallback( (next: boolean) => { - // Why: the portaled PR menu sits outside HoverCardContent — keep the card - // mounted until the menu closes so the unlink item stays clickable. - if (reviewMenuOpen) { + // Why: portaled detail menus sit outside HoverCardContent — keep the card + // mounted until the menu closes so the menu items stay clickable. + if (openMenu) { pendingHoverCloseRef.current = !next return } pendingHoverCloseRef.current = false setOpen(next) }, - [reviewMenuOpen] + [openMenu] ) - const handleReviewMenuOpenChange = useCallback((next: boolean) => { - setReviewMenuOpen(next) + const setDetailMenuOpen = useCallback((menu: WorktreeCardDetailMenu, next: boolean) => { + setOpenMenu(next ? menu : null) if (!next && pendingHoverCloseRef.current) { pendingHoverCloseRef.current = false setOpen(false) @@ -34,10 +36,12 @@ export function useWorktreeCardDetailsHoverControl() { }, []) return { - hoverOpen: open || reviewMenuOpen, - reviewMenuOpen, + hoverOpen: open || Boolean(openMenu), + issueMenuOpen: openMenu === 'issue', + reviewMenuOpen: openMenu === 'review', handleHoverOpenChange, - handleReviewMenuOpenChange, + handleIssueMenuOpenChange: (next: boolean) => setDetailMenuOpen('issue', next), + handleReviewMenuOpenChange: (next: boolean) => setDetailMenuOpen('review', next), closeHover } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4483a71a20c..f08537a2769 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3833,6 +3833,7 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { + "copyLink": "Copy link", "reviewHeader": "{{value0}} #{{value1}}" }, "WorktreeCardMeta": { @@ -3849,6 +3850,12 @@ "807b13b9ec": "Edit issue", "b22f058067": "View on GitHub", "e97d8f2876": "Issue #{{value0}}", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link", "3ea2702e62": "Linked {{value0}} #{{value1}}", "b105fd3057": "Linked Linear {{value0}}", "3f2649eeb8": "Linked issue #{{value0}}", From 5311ab3867fe787fefc5101da273a2ea9af22d64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:27:21 -0700 Subject: [PATCH 19/57] Group agent session history by project (#5432) Co-authored-by: Orca --- .../components/right-sidebar/AiVaultPanel.tsx | 109 +++- .../right-sidebar/AiVaultPanelControls.tsx | 57 +- .../right-sidebar/AiVaultPanelHeader.tsx | 18 +- .../ai-vault-scope-state.test.ts | 67 +++ .../right-sidebar/ai-vault-scope-state.ts | 23 + .../ai-vault-session-filters.test.ts | 83 +++ .../right-sidebar/ai-vault-session-filters.ts | 62 ++- .../ai-vault-session-projects.test.ts | 506 ++++++++++++++++++ .../ai-vault-session-projects.ts | 284 ++++++++++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 31 +- src/renderer/src/i18n/locales/ko.json | 29 +- src/renderer/src/i18n/locales/zh.json | 27 +- src/shared/ai-vault-types.ts | 4 +- 15 files changed, 1280 insertions(+), 70 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 4a642958000..810ecdc8926 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -3,13 +3,25 @@ import { toast } from 'sonner' import { buildAiVaultResumeCommandForWorktree } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { useAppStore } from '@/store' -import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { + useActiveRepo, + useActiveWorktree, + useAllWorktrees, + useProjectHostSetupProjection, + useRepoById, + useRepos +} from '@/store/selectors' import { agentLabel, deriveAiVaultWorkspaceScopePaths, filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters' +import { + normalizeAiVaultScopeForContext, + shouldRestoreAiVaultProjectScope +} from './ai-vault-scope-state' +import { buildAiVaultProjectContext } from './ai-vault-session-projects' import { AI_VAULT_AGENTS, type AiVaultAgent, @@ -28,13 +40,16 @@ const SESSION_LIMIT = 500 export default function AiVaultPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() - const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const activeRepo = useActiveRepo() + const activeWorktreeRepo = useRepoById(activeWorktree?.repoId ?? null) + const repos = useRepos() + const allWorktrees = useAllWorktrees() + const projectHostSetupProjection = useProjectHostSetupProjection() const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {}) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const [query, setQuery] = useState('') - const [scope, setScope] = useState('workspace') + const [scope, setScope] = useState('project') const [sort, setSort] = useState('updated') - const [group, setGroup] = useState('folder') + const [group, setGroup] = useState('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) const [sessions, setSessions] = useState([]) @@ -45,27 +60,61 @@ export default function AiVaultPanel(): React.JSX.Element { const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const mountedRef = useRef(true) + const userChangedScopeRef = useRef(false) - const isRemoteWorktree = Boolean(activeRepo?.connectionId) + const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. - const liveWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo]) const activeWorktreePaths = useMemo( - () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, liveWorktrees), - [activeWorktree, liveWorktrees] + () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees), + [activeWorktree, allWorktrees] ) + const projectContext = useMemo( + () => + buildAiVaultProjectContext({ + repos, + worktrees: allWorktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions + }), + [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions] + ) + const activeProjectKey = projectContext.activeProjectKey + const projectLabelByKey = projectContext.projectLabelByKey + const sessionProjectById = projectContext.sessionProjectById const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length const viewAdjustmentCount = (hasAllAgentsSelected ? 0 : 1) + (sort === 'updated' ? 0 : 1) + - (group === 'folder' ? 0 : 1) + + (group === 'project' ? 0 : 1) + (hideEmptySessions ? 0 : 1) + // Project scope depends on active project context, but should come back after + // transient context loss unless the user intentionally chose another scope. useEffect(() => { - if (!activeWorktreePath && scope === 'workspace') { - setScope('all') + const normalizedScope = normalizeAiVaultScopeForContext({ + scope, + activeProjectKey, + activeWorktreePath + }) + if (normalizedScope !== scope) { + setScope(normalizedScope) } - }, [activeWorktreePath, scope]) + }, [activeProjectKey, activeWorktreePath, scope]) + + useEffect(() => { + if ( + shouldRestoreAiVaultProjectScope({ + scope, + activeProjectKey, + userChangedScope: userChangedScopeRef.current + }) + ) { + setScope('project') + } + }, [activeProjectKey, scope]) const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { if (refreshInFlightRef.current) { @@ -120,14 +169,32 @@ export default function AiVaultPanel(): React.JSX.Element { scope, sort, activeWorktreePaths, + activeProjectKey, + sessionProjectById, + projectLabelByKey, hideEmptySessions }), - [activeWorktreePaths, agents, hideEmptySessions, query, scope, sessions, sort] + [ + activeProjectKey, + activeWorktreePaths, + agents, + hideEmptySessions, + projectLabelByKey, + query, + scope, + sessionProjectById, + sessions, + sort + ] ) const groups = useMemo( - () => groupAiVaultSessions(filteredSessions, group), - [filteredSessions, group] + () => + groupAiVaultSessions(filteredSessions, group, { + sessionProjectById, + projectLabelByKey + }), + [filteredSessions, group, projectLabelByKey, sessionProjectById] ) const buildResumeCommand = useCallback( @@ -212,10 +279,15 @@ export default function AiVaultPanel(): React.JSX.Element { const resetViewOptions = useCallback(() => { setAgents([...AI_VAULT_AGENTS]) setSort('updated') - setGroup('folder') + setGroup('project') setHideEmptySessions(true) }, []) + const handleScopeChange = useCallback((nextScope: AiVaultScope) => { + userChangedScopeRef.current = nextScope !== 'project' + setScope(nextScope) + }, []) + const toggleGroup = useCallback((key: string) => { setCollapsedGroups((current) => { const next = new Set(current) @@ -237,6 +309,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionCount={sessions.length} hasScanResult={Boolean(scanResult)} activeWorktreePath={activeWorktreePath} + activeProjectKey={activeProjectKey} scope={scope} agents={agents} sort={sort} @@ -244,7 +317,7 @@ export default function AiVaultPanel(): React.JSX.Element { hideEmptySessions={hideEmptySessions} adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} - onScopeChange={setScope} + onScopeChange={handleScopeChange} 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 a241117a458..f9feda064f4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -6,7 +6,8 @@ import { Clock3, FolderOpen, ListFilter, - LoaderCircle + LoaderCircle, + PanelsTopLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -36,7 +37,7 @@ import { translate } from '@/i18n/i18n' const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0' const VAULT_SCOPE_TOGGLE_ITEM_CLASS = - 'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1' + 'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5' export function VaultGroupHeader({ group, @@ -99,15 +100,21 @@ export function SessionLoadingState(): React.JSX.Element { export function VaultScopeSwitch({ scope, workspaceAvailable, + projectAvailable, onScopeChange }: { scope: AiVaultScope workspaceAvailable: boolean + projectAvailable: boolean onScopeChange: (scope: AiVaultScope) => void }): React.JSX.Element { - const worktreeLabel = translate( - 'auto.components.right.sidebar.AiVaultPanelControls.worktreeScope', - 'Worktree' + const workspaceLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.workspaceScope', + 'Workspace' + ) + const projectLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.projectScope', + 'Project' ) const allLabel = translate('auto.components.right.sidebar.AiVaultPanelControls.allScope', 'All') @@ -116,12 +123,12 @@ export function VaultScopeSwitch({ type="single" value={scope} onValueChange={(value) => { - if (value === 'workspace' || value === 'all') { + if (value === 'workspace' || value === 'project' || value === 'all') { onScopeChange(value) } }} variant="outline" - className="h-6 shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" + className="h-7 w-full rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" aria-label={translate( 'auto.components.right.sidebar.AiVaultPanelControls.scopeAriaLabel', 'Session History scope: {{value0}}', @@ -129,25 +136,37 @@ export function VaultScopeSwitch({ value0: scope === 'workspace' ? translate( - 'auto.components.right.sidebar.AiVaultPanelControls.currentWorktreeLower', - 'current worktree' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', - 'all sessions' + 'auto.components.right.sidebar.AiVaultPanelControls.currentWorkspaceLower', + 'current workspace' ) + : scope === 'project' + ? translate( + 'auto.components.right.sidebar.AiVaultPanelControls.currentProjectLower', + 'current project' + ) + : translate( + 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', + 'all sessions' + ) } )} > - - {allLabel} - - {worktreeLabel} + {workspaceLabel} + + + {projectLabel} + + + {allLabel} ) @@ -253,6 +272,10 @@ export function VaultViewMenu({ value={group} onValueChange={(value) => onGroupChange(value as AiVaultGroup)} > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 9c0af246830..309ecdbeae1 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -16,6 +16,7 @@ type AiVaultPanelHeaderProps = { sessionCount: number hasScanResult: boolean activeWorktreePath: string | null + activeProjectKey: string | null scope: AiVaultScope agents: readonly AiVaultAgent[] sort: AiVaultSort @@ -39,6 +40,7 @@ export function AiVaultPanelHeader({ sessionCount, hasScanResult, activeWorktreePath, + activeProjectKey, scope, agents, sort, @@ -56,7 +58,7 @@ export function AiVaultPanelHeader({ }: AiVaultPanelHeaderProps): React.JSX.Element { return (
-
+
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */} @@ -97,11 +99,6 @@ export function AiVaultPanelHeader({
-
+
+ +
+
{ + it('falls back from project to all when no active project is available', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'project', + activeProjectKey: null, + activeWorktreePath: '/repo' + }) + ).toBe('all') + }) + + it('falls back from workspace to all when no active workspace path is available', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'workspace', + activeProjectKey: 'project:orca', + activeWorktreePath: null + }) + ).toBe('all') + }) + + it('keeps available project and workspace scopes selected', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'project', + activeProjectKey: 'project:orca', + activeWorktreePath: '/repo' + }) + ).toBe('project') + + expect( + normalizeAiVaultScopeForContext({ + scope: 'workspace', + activeProjectKey: null, + activeWorktreePath: '/repo' + }) + ).toBe('workspace') + }) +}) + +describe('shouldRestoreAiVaultProjectScope', () => { + it('restores project after automatic fallback when a project becomes available', () => { + expect( + shouldRestoreAiVaultProjectScope({ + scope: 'all', + activeProjectKey: 'project:orca', + userChangedScope: false + }) + ).toBe(true) + }) + + it('does not restore project after the user manually changed scope', () => { + expect( + shouldRestoreAiVaultProjectScope({ + scope: 'all', + activeProjectKey: 'project:orca', + userChangedScope: true + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts new file mode 100644 index 00000000000..5f564cdcdcd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts @@ -0,0 +1,23 @@ +import type { AiVaultScope } from '../../../../shared/ai-vault-types' + +export function normalizeAiVaultScopeForContext(args: { + scope: AiVaultScope + activeProjectKey: string | null + activeWorktreePath: string | null +}): AiVaultScope { + if (args.scope === 'project' && !args.activeProjectKey) { + return 'all' + } + if (args.scope === 'workspace' && !args.activeWorktreePath) { + return 'all' + } + return args.scope +} + +export function shouldRestoreAiVaultProjectScope(args: { + scope: AiVaultScope + activeProjectKey: string | null + userChangedScope: boolean +}): boolean { + return Boolean(args.activeProjectKey && args.scope === 'all' && !args.userChangedScope) +} 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 97772cac169..5acfd125b09 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 @@ -251,6 +251,62 @@ describe('filterAiVaultSessions', () => { }) ).toEqual([]) }) + + it('filters project scope by the resolved active project key', () => { + const projectSession = { ...baseSession, id: 'claude:project', cwd: '/repo/project' } + const otherSession = { ...baseSession, id: 'claude:other', cwd: '/repo/other' } + const sessionProjectById = new Map([ + [projectSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Orca' }], + [otherSession.id, { kind: 'repo' as const, key: 'project:other', label: 'Other' }] + ]) + + expect( + filterAiVaultSessions([projectSession, otherSession], { + query: '', + agents: ['claude'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey: 'project:orca', + sessionProjectById, + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:project']) + }) + + it('does not show all sessions for project scope without an active project key', () => { + expect( + filterAiVaultSessions([baseSession], { + query: '', + agents: ['claude'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey: null, + hideEmptySessions: true + }) + ).toEqual([]) + }) + + it('matches repo: queries against resolved project labels before folder fallback', () => { + const sessionProjectById = new Map([ + [baseSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Canonical Orca' }] + ]) + const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']]) + + expect( + filterAiVaultSessions([baseSession], { + query: 'repo:canonical', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + sessionProjectById, + projectLabelByKey, + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) }) describe('deriveAiVaultWorkspaceScopePaths', () => { @@ -384,6 +440,33 @@ describe('groupAiVaultSessions', () => { 'Codex' ]) }) + + it('groups sibling worktree sessions by project label when resolved', () => { + const sessions: AiVaultSession[] = [ + { ...baseSession, id: 'claude:1', cwd: '/repo/main' }, + { ...baseSession, id: 'codex:2', agent: 'codex', cwd: '/repo/worktree' } + ] + const sessionProjectById = new Map( + sessions.map((session) => [ + session.id, + { kind: 'repo' as const, key: 'project:orca', label: 'Orca' } + ]) + ) + const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']]) + + expect( + groupAiVaultSessions(sessions, 'project', { + sessionProjectById, + projectLabelByKey + }) + ).toEqual([{ key: 'project:orca', label: 'Canonical Orca', sessions }]) + }) + + it('falls back to folder grouping when project metadata is unavailable', () => { + expect(groupAiVaultSessions([baseSession], 'project')).toEqual([ + { key: '/users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } + ]) + }) }) describe('parseVaultQuery', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 376e52a220f..f0422547761 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -17,6 +17,7 @@ import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { sessionPreviewSearchText } from './ai-vault-session-display' +import type { AiVaultSessionProject } from './ai-vault-session-projects' export type AiVaultSessionFilterState = { query: string @@ -24,6 +25,9 @@ export type AiVaultSessionFilterState = { scope: AiVaultScope sort: AiVaultSort activeWorktreePaths: readonly string[] + activeProjectKey?: string | null + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap hideEmptySessions: boolean } @@ -78,7 +82,15 @@ export function filterAiVaultSessions( return false } } - return matchesQuery(session, parsedQuery) + if (filters.scope === 'project') { + if (!filters.activeProjectKey) { + return false + } + if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { + return false + } + } + return matchesQuery(session, parsedQuery, filters) }) .sort((left, right) => compareSessions(left, right, filters.sort)) } @@ -110,13 +122,16 @@ export function deriveAiVaultWorkspaceScopePaths( export function groupAiVaultSessions( sessions: readonly AiVaultSession[], - group: AiVaultGroup + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } = {} ): AiVaultSessionGroup[] { const groups = new Map() for (const session of sessions) { - const key = group === 'agent' ? session.agent : getFolderGroupKey(session.cwd) - const label = group === 'agent' ? agentLabel(session.agent) : folderLabel(session.cwd) + const { key, label } = getGroupIdentity(session, group, options) const existing = groups.get(key) if (existing) { existing.sessions.push(session) @@ -170,7 +185,11 @@ export function parseVaultQuery(query: string): ParsedQuery { return { terms, repoTerms, pathTerms } } -function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean { +function matchesQuery( + session: AiVaultSession, + parsed: ParsedQuery, + filters: Pick +): boolean { const searchable = [ session.title, session.sessionId, @@ -189,7 +208,12 @@ function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean { return false } - const repoLabel = folderLabel(session.cwd).toLowerCase() + const sessionProject = filters.sessionProjectById?.get(session.id) + const repoLabel = ( + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : folderLabel(session.cwd) + ).toLowerCase() if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { return false } @@ -210,6 +234,32 @@ function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVa return rightTime - leftTime } +function getGroupIdentity( + session: AiVaultSession, + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } +): Pick { + if (group === 'agent') { + return { key: session.agent, label: agentLabel(session.agent) } + } + if (group === 'project') { + const sessionProject = options.sessionProjectById?.get(session.id) + if (sessionProject) { + return { + key: sessionProject.key, + label: + options.projectLabelByKey?.get(sessionProject.key) || + sessionProject.label || + folderLabel(session.cwd) + } + } + } + return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } +} + function getFolderGroupKey(pathValue: string | null): string { return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' } 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 new file mode 100644 index 00000000000..fa53c685da8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-session-projects' + +const baseSession: AiVaultSession = { + id: 'claude:1', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement project history', + cwd: '/Users/ada/orca', + branch: 'feature/history', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [], + resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'" +} + +describe('toAiVaultProjectKey', () => { + it('does not double-wrap compatibility repo project ids', () => { + expect(toAiVaultProjectKey('project-1', 'repo-1')).toBe('project:project-1') + expect(toAiVaultProjectKey('repo:repo-1', 'repo-1')).toBe('repo:repo-1') + expect(toAiVaultProjectKey(null, 'repo-1')).toBe('repo:repo-1') + }) +}) + +describe('buildAiVaultProjectContext', () => { + it('uses durable worktree project ids before repo fallback', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' }) + const project = makeProject({ id: 'project-1', displayName: 'Canonical Orca' }) + const worktree = makeWorktree({ + id: 'wt-1', + repoId: repo.id, + projectId: project.id, + path: '/Users/ada/orca' + }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [project], + setups: [makeSetup({ repoId: repo.id, projectId: project.id, path: repo.path })] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [baseSession] + }) + + expect(context.activeProjectKey).toBe('project:project-1') + expect(context.sessionProjectById.get(baseSession.id)).toMatchObject({ + kind: 'repo', + key: 'project:project-1', + label: 'Canonical Orca' + }) + }) + + it('normalizes compatibility project ids to repo keys', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/Users/ada/orca' }) + const worktree = makeWorktree({ + id: 'wt-1', + repoId: repo.id, + projectId: 'repo:repo-1', + path: '/Users/ada/orca' + }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'repo:repo-1', displayName: 'Compatibility Orca' })], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: repo.path })] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [baseSession] + }) + + expect(context.activeProjectKey).toBe('repo:repo-1') + expect(context.sessionProjectById.get(baseSession.id)?.key).toBe('repo:repo-1') + expect(context.projectLabelByKey.get('repo:repo-1')).toBe('Orca') + }) + + it('falls back to repo ids for legacy records without project metadata', () => { + const repo = makeRepo({ id: 'repo-legacy', displayName: 'Legacy', path: '/repo/legacy' }) + const session = makeSession({ id: 'codex:legacy', cwd: '/repo/legacy/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('repo:repo-legacy') + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'repo:repo-legacy', + label: 'Legacy' + }) + }) + + it('inherits setup project ids for legacy worktrees without project metadata', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' }) + const worktree = makeWorktree({ + id: 'wt-legacy', + repoId: repo.id, + path: '/repo/orca' + }) + const session = makeSession({ id: 'claude:legacy-worktree', cwd: '/repo/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'github:stablyai/orca', + path: repo.path + }) + ] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('project:github:stablyai/orca') + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'project:github:stablyai/orca', + label: 'Canonical Orca' + }) + }) + + it('uses active worktree setup project ids when active repo is unavailable', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' }) + const worktree = makeWorktree({ + id: 'wt-restored', + repoId: repo.id, + path: '/repo/orca' + }) + const session = makeSession({ id: 'claude:restored', cwd: '/repo/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'github:stablyai/orca', + path: repo.path + }) + ] + }), + activeRepo: null, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('project:github:stablyai/orca') + expect(context.sessionProjectById.get(session.id)?.key).toBe('project:github:stablyai/orca') + }) + + it('inherits setup host ids for legacy worktrees without host metadata', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Runtime Repo', path: '/runtime/orca' }) + const worktree = makeWorktree({ + id: 'wt-runtime', + repoId: repo.id, + path: '/runtime/orca' + }) + const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'project-runtime', displayName: 'Runtime Project' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'project-runtime', + path: repo.path, + hostId: 'runtime:preview' + }) + ] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'project:project-runtime', + hostKey: 'runtime:preview' + }) + }) + + it('keeps project labels canonical instead of using first matched session order', () => { + const repoA = makeRepo({ id: 'repo-a', displayName: 'Fork Checkout', path: '/work/fork' }) + const repoB = makeRepo({ id: 'repo-b', displayName: 'Main Checkout', path: '/work/main' }) + const project = makeProject({ id: 'project-1', displayName: 'Canonical Project' }) + const firstSession = makeSession({ id: 'claude:first', cwd: '/work/fork/src' }) + const secondSession = makeSession({ id: 'codex:second', cwd: '/work/main/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repoA, repoB], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [project], + setups: [ + makeSetup({ repoId: repoA.id, projectId: project.id, path: repoA.path }), + makeSetup({ repoId: repoB.id, projectId: project.id, path: repoB.path }) + ] + }), + activeRepo: repoA, + activeWorktree: null, + sessions: [firstSession, secondSession] + }) + + expect(context.projectLabelByKey.get('project:project-1')).toBe('Canonical Project') + expect(context.sessionProjectById.get(firstSession.id)?.label).toBe('Canonical Project') + expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project') + }) + + it('chooses the most specific nested path and lets worktrees win equal-length ties', () => { + const repo = makeRepo({ id: 'repo-root', displayName: 'Root', path: '/repo' }) + const childRepo = makeRepo({ id: 'repo-child', displayName: 'Child Repo', path: '/repo/pkg' }) + const worktree = makeWorktree({ + id: 'wt-child', + repoId: childRepo.id, + projectId: 'child-project', + path: '/repo/pkg' + }) + const session = makeSession({ id: 'claude:nested', cwd: '/repo/pkg/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo, childRepo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })], + setups: [ + makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }), + makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path }) + ] + }), + activeRepo: childRepo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + key: 'project:child-project', + label: 'Child Project' + }) + }) + + it('matches Windows paths case-insensitively across separators', () => { + const repo = makeRepo({ + id: 'repo-win', + displayName: 'Windows Repo', + path: 'C:\\Users\\Ada\\Repo' + }) + const session = makeSession({ id: 'claude:win', cwd: 'c:/users/ada/repo/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-win', path: repo.path })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') + }) + + it('falls back to folder when a hostless session matches multiple host buckets', () => { + const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) + const sshRepo = makeRepo({ + id: 'ssh', + displayName: 'SSH', + path: '/srv/orca', + connectionId: 'target-1' + }) + const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [localRepo, sshRepo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [ + makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }), + makeSetup({ + repoId: sshRepo.id, + projectId: 'repo:ssh', + path: sshRepo.path, + hostId: 'ssh:target-1', + connectionId: 'target-1' + }) + ] + }), + activeRepo: localRepo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/srv/orca/src', + label: 'orca/src' + }) + }) + + it('uses ProjectHostSetup host ids when detecting ambiguous 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 context = buildAiVaultProjectContext({ + repos: [localRepo, runtimeRepo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [ + makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }), + makeSetup({ + repoId: runtimeRepo.id, + projectId: 'repo:runtime', + path: runtimeRepo.path, + hostId: 'runtime:preview' + }) + ] + }), + activeRepo: localRepo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/srv/orca/src', + label: 'orca/src' + }) + }) + + it('ignores blank setup paths instead of treating them as catch-all candidates', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const session = makeSession({ id: 'claude:outside', cwd: '/outside/path' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/outside/path', + label: 'outside/path' + }) + }) + + it('uses repo fallback candidates when a setup for the repo has a blank path', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const session = makeSession({ id: 'claude:inside-repo', cwd: '/repo/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'repo:repo-1', + label: 'Repo' + }) + }) + + it('maps null cwd sessions to unknown', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/repo' }) + const session = makeSession({ id: 'claude:unknown', cwd: null }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toEqual({ + kind: 'unknown', + key: 'unknown', + label: '' + }) + }) +}) + +function makeSession(overrides: Partial): AiVaultSession { + return { ...baseSession, ...overrides } +} + +function makeRepo(overrides: Partial): Repo { + return { + id: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function makeProject(overrides: Partial): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSetup(overrides: Partial): ProjectHostSetup { + return { + id: overrides.repoId ?? 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + path: '/Users/ada/orca', + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function makeProjection( + overrides: Partial +): ProjectHostSetupProjection { + return { + projects: [], + setups: [], + ...overrides + } +} 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 new file mode 100644 index 00000000000..fdb78325025 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -0,0 +1,284 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } 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' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from '../../../../shared/cross-platform-path' + +export type AiVaultSessionProject = { + kind: 'repo' | 'folder' | 'unknown' + key: string + label: string + projectId?: string + repoId?: string + hostKey?: string +} + +export type AiVaultProjectContext = { + activeProjectKey: string | null + activeRepoId: string | null + projectLabelByKey: Map + sessionProjectById: Map +} + +type SessionProjectCandidate = { + source: 'worktree' | 'setup' + normalizedPath: string + hostKey: string + projectId: string | null + repoId: string | null +} + +type ProjectResolverArgs = { + repos: readonly Repo[] + worktrees: readonly Worktree[] + projectHostSetupProjection: ProjectHostSetupProjection + activeRepo: Repo | null + activeWorktree: Worktree | null + sessions: readonly AiVaultSession[] +} + +export function buildAiVaultProjectContext({ + repos, + worktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions +}: ProjectResolverArgs): AiVaultProjectContext { + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) + const candidates = buildProjectCandidates( + worktrees, + projectHostSetupProjection, + repoById, + setupByRepoId + ) + const sessionProjectById = new Map() + + for (const session of sessions) { + sessionProjectById.set( + session.id, + resolveSessionProject(session.cwd, candidates, projectLabelByKey) + ) + } + + return { + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), + activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, + projectLabelByKey, + sessionProjectById + } +} + +export function toAiVaultProjectKey( + projectId: string | null | undefined, + repoId?: string | null +): string | null { + if (projectId) { + // Why: legacy projections can already use repo-prefixed project ids; wrapping + // them again would split active scope and resolved session keys. + return projectId.startsWith('repo:') ? projectId : `project:${projectId}` + } + return repoId ? `repo:${repoId}` : null +} + +function buildSetupByRepoId( + setups: readonly ProjectHostSetup[] +): ReadonlyMap { + const setupByRepoId = new Map() + for (const setup of setups) { + if (setup.repoId && !setupByRepoId.has(setup.repoId)) { + setupByRepoId.set(setup.repoId, setup) + } + } + return setupByRepoId +} + +function buildProjectLabelByKey( + repos: readonly Repo[], + projection: ProjectHostSetupProjection +): Map { + const labels = new Map() + const projectLabelById = new Map( + projection.projects.map((project) => [project.id, project.displayName]) + ) + + for (const project of projection.projects) { + const key = toAiVaultProjectKey(project.id, project.sourceRepoIds[0]) + if (key && !key.startsWith('repo:')) { + labels.set(key, project.displayName) + } + } + + for (const repo of repos) { + labels.set(`repo:${repo.id}`, repo.displayName) + } + + for (const setup of projection.setups) { + const key = toAiVaultProjectKey(setup.projectId, setup.repoId) + if (key && !labels.has(key)) { + labels.set(key, projectLabelById.get(setup.projectId) ?? setup.displayName) + } + } + + return labels +} + +function buildProjectCandidates( + worktrees: readonly Worktree[], + projection: ProjectHostSetupProjection, + repoById: ReadonlyMap, + setupByRepoId: ReadonlyMap +): SessionProjectCandidate[] { + const candidates: SessionProjectCandidate[] = [] + const setupRepoIds = new Set() + + for (const worktree of worktrees) { + if (!hasCandidatePath(worktree.path)) { + continue + } + const repo = repoById.get(worktree.repoId) + const setup = setupByRepoId.get(worktree.repoId) + candidates.push({ + source: 'worktree', + normalizedPath: normalizeRuntimePathForComparison(worktree.path), + hostKey: + worktree.hostId ?? + setup?.hostId ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + projectId: worktree.projectId ?? setup?.projectId ?? null, + repoId: worktree.repoId + }) + } + + for (const setup of projection.setups) { + if (setup.repoId) { + if (hasCandidatePath(setup.path)) { + setupRepoIds.add(setup.repoId) + } + } + if (!hasCandidatePath(setup.path)) { + continue + } + candidates.push({ + source: 'setup', + normalizedPath: normalizeRuntimePathForComparison(setup.path), + hostKey: setup.hostId || getRepoExecutionHostId(setup), + projectId: setup.projectId, + repoId: setup.repoId || null + }) + } + + for (const repo of repoById.values()) { + if (setupRepoIds.has(repo.id)) { + continue + } + if (!hasCandidatePath(repo.path)) { + continue + } + candidates.push({ + source: 'setup', + normalizedPath: normalizeRuntimePathForComparison(repo.path), + hostKey: getRepoExecutionHostId(repo), + projectId: null, + repoId: repo.id + }) + } + + return candidates +} + +function hasCandidatePath(pathValue: string): boolean { + return pathValue.trim().length > 0 +} + +function resolveSessionProject( + cwd: string | null, + candidates: readonly SessionProjectCandidate[], + projectLabelByKey: ReadonlyMap +): AiVaultSessionProject { + if (!cwd) { + return { kind: 'unknown', key: 'unknown', label: '' } + } + + 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. + return folderProject(cwd) + } + + const bestCandidate = matches.sort(compareCandidates)[0] + if (!bestCandidate) { + return folderProject(cwd) + } + + const key = toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId) + if (!key) { + return folderProject(cwd) + } + + return { + kind: 'repo', + key, + label: projectLabelByKey.get(key) ?? key, + ...(bestCandidate.projectId ? { projectId: bestCandidate.projectId } : {}), + ...(bestCandidate.repoId ? { repoId: bestCandidate.repoId } : {}), + hostKey: bestCandidate.hostKey + } +} + +function compareCandidates(left: SessionProjectCandidate, right: SessionProjectCandidate): number { + const lengthDifference = right.normalizedPath.length - left.normalizedPath.length + if (lengthDifference !== 0) { + return lengthDifference + } + if (left.source === right.source) { + return 0 + } + return left.source === 'worktree' ? -1 : 1 +} + +function folderProject(cwd: string): AiVaultSessionProject { + const normalizedPath = normalizeRuntimePathForComparison(cwd) + return { + kind: 'folder', + key: `folder:${normalizedPath}`, + label: compactFolderLabel(cwd) + } +} + +function compactFolderLabel(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +function resolveActiveProjectKey( + activeRepo: Repo | null, + activeWorktree: Worktree | null, + setupByRepoId: ReadonlyMap +): string | null { + if (activeWorktree?.projectId) { + return toAiVaultProjectKey(activeWorktree.projectId, activeWorktree.repoId) + } + + const setup = + (activeRepo ? setupByRepoId.get(activeRepo.id) : null) ?? + (activeWorktree ? setupByRepoId.get(activeWorktree.repoId) : null) + if (setup) { + return toAiVaultProjectKey(setup.projectId, setup.repoId || activeRepo?.id) + } + + return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f08537a2769..164b8eaf230 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6263,7 +6263,19 @@ "ab20575a8a": "off", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Scroll Speed", + "description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed.", + "helper": "Adjust how wheel input feels in scrollback and in mouse-aware terminal apps.", + "reset": "Reset", + "normal": "Normal", + "normalDescription": "Scrollback wheel multiplier.", + "fast": "Fast", + "fastDescription": "Extra multiplier while scrolling with a modifier key.", + "tui": "TUI", + "tuiDescription": "Discrete wheel reports for full-screen terminal apps." + } }, "TerminalSettingsPreview": { "a63953a48a": "Preview {{value0}} theme", @@ -7899,7 +7911,11 @@ "0fe0073f0c": "Default terminal font size for new panes and live updates.", "5930244899": "Font Size", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Scroll Speed", + "description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed." + } }, "windows": { "search": { @@ -9122,7 +9138,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Project", + "currentProjectLower": "current project", + "project": "Project" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a60a6d2db2a..970eaeba0b5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6219,7 +6219,19 @@ "ab20575a8a": "apagado", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Velocidad de desplazamiento", + "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa.", + "helper": "Ajusta cómo se siente la entrada de la rueda en el historial y en apps de terminal compatibles con mouse.", + "reset": "Restablecer", + "normal": "Normal", + "normalDescription": "Multiplicador de rueda del historial.", + "fast": "Rápido", + "fastDescription": "Multiplicador adicional al desplazarse con una tecla modificadora.", + "tui": "TUI", + "tuiDescription": "Eventos discretos de rueda para apps de terminal de pantalla completa." + } }, "TerminalSettingsPreview": { "a63953a48a": "Vista previa del tema {{value0}}", @@ -7855,7 +7867,11 @@ "keyword_custom": "custom" }, "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Velocidad de desplazamiento", + "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa." + } }, "windows": { "search": { @@ -9115,7 +9131,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Proyecto", + "currentProjectLower": "proyecto actual", + "project": "Proyecto" }, "AiVaultSessionDetails": { "updated": "Updated", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b25277abe07..fd5617c8836 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2313,7 +2313,7 @@ "TerminalContextMenu": { "b4cdd9314e": "クリアスクリーン", "8c17d6786d": "ペインを閉じる", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "ターミナル ID をコピー", "2cf85a6a55": "ペインIDのコピー", "39809d152f": "タイトルを設定…", "06c2b0f043": "ペインのサイズを均等化する", @@ -2405,9 +2405,9 @@ "a29b9faa01": "ペイン ID がコピーされました", "terminal": { "id": { - "copied": "Terminal ID をコピーしました", + "copied": "ターミナル ID をコピーしました", "copy": { - "failed": "Terminal ID をコピーできません" + "failed": "ターミナル ID をコピーできません" } } } @@ -6241,7 +6241,19 @@ "ab20575a8a": "オフ", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "スクロール速度", + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。", + "helper": "ホイール入力の感触をスクロールバックとマウス対応のターミナルアプリで調整します。", + "reset": "リセット", + "normal": "通常", + "normalDescription": "スクロールバックのホイール倍率。", + "fast": "高速", + "fastDescription": "修飾キーを押しながらスクロールするときの追加倍率。", + "tui": "TUI", + "tuiDescription": "全画面ターミナルアプリ向けの離散的なホイール通知。" + } }, "TerminalSettingsPreview": { "a63953a48a": "{{value0}} テーマのプレビュー", @@ -7877,7 +7889,11 @@ "0fe0073f0c": "新規ペインとライブ アップデートのデフォルトの terminal フォント サイズ。", "5930244899": "フォントサイズ", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "スクロール速度", + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。" + } }, "windows": { "search": { @@ -9115,7 +9131,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "ワークスペース", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "プロジェクト", + "currentProjectLower": "現在のプロジェクト", + "project": "プロジェクト" }, "AiVaultSessionDetails": { "updated": "Updated", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index beda6a43a8e..86595e86d71 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2313,7 +2313,7 @@ "TerminalContextMenu": { "b4cdd9314e": "화면 지우기", "8c17d6786d": "창 닫기", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "터미널 ID 복사", "2cf85a6a55": "창 ID 복사", "39809d152f": "제목 설정…", "06c2b0f043": "창 크기 균등화", @@ -2405,7 +2405,7 @@ "a29b9faa01": "창 ID가 복사되었습니다.", "terminal": { "id": { - "copied": "터미널 ID가 복사되었습니다", + "copied": "터미널 ID를 복사했습니다", "copy": { "failed": "터미널 ID를 복사할 수 없습니다" } @@ -6204,7 +6204,19 @@ "ab20575a8a": "끄기", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인", - "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다." + "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다.", + "scrollSpeed": { + "title": "스크롤 속도", + "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다.", + "helper": "휠 입력이 스크롤백과 마우스 인식 터미널 앱에서 느껴지는 방식을 조정합니다.", + "reset": "재설정", + "normal": "일반", + "normalDescription": "스크롤백 휠 배율.", + "fast": "빠름", + "fastDescription": "수정 키로 스크롤할 때 적용되는 추가 배율.", + "tui": "TUI", + "tuiDescription": "전체 화면 터미널 앱을 위한 개별 휠 보고." + } }, "TerminalSettingsPreview": { "a63953a48a": "{{value0}} 테마 미리보기", @@ -7840,7 +7852,11 @@ "keyword_custom": "custom" }, "ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인", - "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다." + "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다.", + "scrollSpeed": { + "title": "스크롤 속도", + "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다." + } }, "windows": { "search": { @@ -9115,7 +9131,10 @@ "hideEmptySessions": "빈 세션 숨기기", "workspaceScope": "워크스페이스", "worktreeScope": "워크트리", - "globalScope": "전역" + "globalScope": "전역", + "projectScope": "프로젝트", + "currentProjectLower": "현재 프로젝트", + "project": "프로젝트" }, "AiVaultSessionDetails": { "updated": "업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0723b0e1ef0..5618cd61179 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2313,7 +2313,7 @@ "TerminalContextMenu": { "b4cdd9314e": "清晰的屏幕", "8c17d6786d": "关闭窗格", - "copyTerminalId": "复制 Terminal ID", + "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", "39809d152f": "设置标题...", "06c2b0f043": "均衡窗格大小", @@ -6204,7 +6204,19 @@ "ab20575a8a": "关", "ab3a1f9068": "执行程序", "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "scrollSpeed": { + "title": "滚动速度", + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。", + "helper": "调整滚轮输入在回滚和支持鼠标的终端应用中的手感。", + "reset": "重置", + "normal": "普通", + "normalDescription": "回滚滚轮倍数。", + "fast": "快速", + "fastDescription": "按住修饰键滚动时的额外倍数。", + "tui": "TUI", + "tuiDescription": "面向全屏终端应用的离散滚轮报告。" + } }, "TerminalSettingsPreview": { "a63953a48a": "预览 {{value0}} 主题", @@ -7840,7 +7852,11 @@ "keyword_custom": "自定义" }, "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "scrollSpeed": { + "title": "滚动速度", + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。" + } }, "windows": { "search": { @@ -9115,7 +9131,10 @@ "hideEmptySessions": "隐藏空会话", "workspaceScope": "工作区", "worktreeScope": "工作树", - "globalScope": "全局" + "globalScope": "全局", + "projectScope": "项目", + "currentProjectLower": "当前项目", + "project": "项目" }, "AiVaultSessionDetails": { "updated": "更新时间", diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 2df68373451..b977489b4bc 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -19,9 +19,9 @@ export const AI_VAULT_AGENTS = [ ] as const satisfies readonly TuiAgent[] export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] -export type AiVaultScope = 'workspace' | 'all' +export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' -export type AiVaultGroup = 'folder' | 'agent' +export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', From a7a22d5e183c37e25c80604a90f497f2e386a9e4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:38:17 -0700 Subject: [PATCH 20/57] Display draft PR status under tasks (#6119) * fix: address review findings * Improve accessibility and refactor GitHub work item status helpers - Add aria-label and aria-hidden attributes to the task ID pill and icons to make them screen-reader friendly. - Refactor getTaskPageGitHubPRIconTone to use an exhaustive switch statement with explicit type-safety fallbacks. - Add comprehensive test coverage for draft checks, state labels, and icon tone edge cases. --- src/renderer/src/components/TaskPage.tsx | 85 ++++++++++++------- ...ask-page-github-work-item-status-badge.tsx | 27 ++++++ .../task-page-github-work-item-status.test.ts | 67 +++++++++++++++ .../task-page-github-work-item-status.ts | 72 ++++++++++++++++ 4 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-work-item-status-badge.tsx create mode 100644 src/renderer/src/components/task-page-github-work-item-status.test.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-status.ts diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 02036030118..92c6ebe5544 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -22,6 +22,7 @@ import { Files, GitMerge, GitPullRequest, + GitPullRequestDraft, List, LoaderCircle, Lock, @@ -210,6 +211,11 @@ import { resolveTaskPageGitHubStatusStateDraft, updateTaskPageGitHubStatusLocalState } from '@/components/task-page-github-status-state' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' +import { + getTaskPageGitHubPRIconTone, + isTaskPageGitHubDraftPR +} from '@/components/task-page-github-work-item-status' import { createTaskPageJiraLoadFailureState, type TaskPageJiraLoadError @@ -1119,11 +1125,7 @@ function GHStatusCell({ ) if (item.type !== 'issue' || !repo) { - return ( - - {translate('auto.components.TaskPage.606a85c774', 'Open')} - - ) + return } return ( @@ -8817,6 +8819,29 @@ export default function TaskPage(): React.JSX.Element { const attachedWorkspaceLabel = attachedWorkspace ? getGithubWorkItemWorkspaceAttachmentLabel(attachedWorkspace) : null + const githubTaskIdPill = ( + + {item.type === 'pr' ? ( + isTaskPageGitHubDraftPR(item) ? ( + + ) return ( // Why: the row is a clickable container rather than a // - - - {selectedGitHubRepoExternalLink + ) + } + ) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ defaultRepoSelection: null }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ + + + + + {selectedGitHubRepoExternalLink + ? translate( + 'auto.components.TaskPage.8d1e17a3ef', + 'Open {{value0}} in GitHub', + { value0: selectedGitHubRepoExternalLink.label } + ) + : translate( + 'auto.components.TaskPage.bc46d8204e', + 'Select one project to open in GitHub' + )} + +
) : null} @@ -8635,7 +8628,7 @@ export default function TaskPage(): React.JSX.Element { ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? (
diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index f76361d3654..705d306072c 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' import ProjectItemSlugDialog from './ProjectItemSlugDialog' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' import { resolveMissingRepoProjectDialogState, resolveRepoBackedProjectDialogState } from './project-dialog-state' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, getVisibleProjectTable, type CachedVisibleProjectTable } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' -type Props = Record +type Props = { + selectedRepoIds: ReadonlySet +} const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' @@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable) @@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J const table: GitHubProjectTable | null = currentCacheKey ? (projectViewCache[currentCacheKey]?.data ?? null) : null + const selectedRepoFingerprint = useMemo( + () => getSelectedRepoFingerprint(selectedRepoIds), + [selectedRepoIds] + ) const filteredTable = useMemo( - () => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null), - [table, slugIndexReady, lookupSlug] + () => + table && slugIndexReady + ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + : null, + [table, slugIndexReady, lookupSlug, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: this cache only prevents a blank table while the repo slug index @@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J // a second render after every filtered-table change. lastFilteredTableRef.current = getNextVisibleProjectTableCache({ currentCacheKey, + selectedRepoFingerprint, sourceTable: table, slugIndexReady, filteredTable, @@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) const visibleTable = getVisibleProjectTable({ currentCacheKey, + selectedRepoFingerprint, slugIndexReady, filteredTable, cachedTable: lastFilteredTableRef.current @@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } | null>(null) const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos]) - const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds) + const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState( + dialogRepoItem, + liveRepoIds, + selectedRepoIds + ) if (resolvedDialogRepoItem !== dialogRepoItem) { // Why: repo-backed Project dialogs cannot edit after their repo leaves // Orca; clear them before the modal tree receives stale repo ids. @@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J slugIndexReady, slugDialog, repoNotInOrca, - lookupSlug + lookupSlug, + selectedRepoIds }) if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) { // Why: once a previously missing repo is registered, Project rows should @@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J [] ) + const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => { + if (row.content.url) { + void window.api.shell.openUrl(row.content.url) + } + toast.message(message) + }, []) + const handleOpenDialog = useCallback( (row: GitHubProjectRow) => { if (!currentCacheKey || !table) { @@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (matched) { - const workItem = buildWorkItem(row, matched.id) + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'selected_match') { + const workItem = buildWorkItem(row, resolution.repo.id) if (workItem) { - setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin }) + setDialogRepoItem({ + workItem, + repoPath: resolution.repo.path, + repoId: resolution.repo.id, + origin + }) return } } - // Unknown repo — use the simplified slug-mode dialog. - setSlugDialog({ origin }) + if (resolution.status === 'no_global_match') { + // Unknown repo — use the simplified slug-mode dialog. + setSlugDialog({ origin }) + return + } + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + } }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleStartWork = useCallback( @@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!origin) { return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (!matched) { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'no_global_match') { setRepoNotInOrca({ owner: origin.owner, repo: origin.repo, @@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) return } - const workItem = buildWorkItem(row, matched.id) + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + return + } + if (resolution.status !== 'selected_match') { + return + } + const workItem = buildWorkItem(row, resolution.repo.id) if (!workItem) { return } void launchWorkItemDirect({ item: workItem, - repoId: matched.id, + repoId: resolution.repo.id, launchSource: 'task_page', telemetrySource: 'sidebar', openModalFallback: () => { @@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }) }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleEditAssignees = useCallback( diff --git a/src/renderer/src/components/github-project/project-dialog-state.test.ts b/src/renderer/src/components/github-project/project-dialog-state.test.ts index 78aa6b1274a..99afa238841 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.test.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.test.ts @@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => { it('keeps a repo-backed dialog when the repo still exists', () => { const dialog = { repoId: 'repo-1', label: 'Issue 1' } - expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog) + expect( + resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1'])) + ).toBe(dialog) }) it('clears a repo-backed dialog when its repo is removed', () => { expect( - resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2'])) + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-2']), + new Set(['repo-1']) + ) + ).toBeNull() + }) + + it('clears a repo-backed dialog when its repo is no longer selected', () => { + expect( + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-1']), + new Set(['repo-2']) + ) ).toBeNull() }) }) describe('resolveMissingRepoProjectDialogState', () => { - it('waits for the slug index before closing missing-repo dialogs', () => { + it('clears fallback dialogs while the slug index is rebuilding', () => { const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } @@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: false, slugDialog, repoNotInOrca, - lookupSlug: () => ['repo-1'] + lookupSlug: () => [{ id: 'repo-1' }], + selectedRepoIds: new Set(['repo-1']) }) - ).toEqual({ slugDialog, repoNotInOrca }) + ).toEqual({ slugDialog: null, repoNotInOrca: null }) }) it('clears slug fallback dialogs once the repo slug resolves', () => { @@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBeNull() @@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBe(slugDialog) expect(result.repoNotInOrca).toBeNull() }) + + it('clears fallback dialogs when the repo is globally known but not selected', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [{ id: 'repo-2' }], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog: null, repoNotInOrca: null }) + }) + + it('keeps missing-repo fallback dialogs when there are no global matches', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog, repoNotInOrca }) + }) }) diff --git a/src/renderer/src/components/github-project/project-dialog-state.ts b/src/renderer/src/components/github-project/project-dialog-state.ts index cb092bcae10..88f71c9fcb1 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.ts @@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = { repo: string } -type LookupSlug = (slug: string) => readonly unknown[] +type RepoMatch = { + id: string +} -function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean { - return lookupSlug(`${owner}/${repo}`).length > 0 +type LookupSlug = (slug: string) => readonly RepoMatch[] + +function shouldCloseFallbackDialog(args: { + lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet + owner: string + repo: string +}): boolean { + const matches = args.lookupSlug(`${args.owner}/${args.repo}`) + const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length + const unselectedMatchCount = matches.length - selectedMatchCount + return selectedMatchCount > 0 || unselectedMatchCount > 0 } export function resolveRepoBackedProjectDialogState( dialog: T | null, - liveRepoIds: ReadonlySet + liveRepoIds: ReadonlySet, + selectedRepoIds: ReadonlySet ): T | null { - if (dialog && !liveRepoIds.has(dialog.repoId)) { + if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) { return null } return dialog @@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState< slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet }): { slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null } { - const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args + const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args if (!slugIndexReady) { - return { slugDialog, repoNotInOrca } + return { slugDialog: null, repoNotInOrca: null } } return { slugDialog: - slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo) + slugDialog && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: slugDialog.origin.owner, + repo: slugDialog.origin.repo + }) ? null : slugDialog, repoNotInOrca: - repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo) + repoNotInOrca && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: repoNotInOrca.owner, + repo: repoNotInOrca.repo + }) ? null : repoNotInOrca } diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index fb061c262b9..f70b44be4a6 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' import type { Repo } from '../../../../shared/types' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsByOpenRepos, + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' function repo(id: string): Repo { return { @@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => { expect(filtered.totalCount).toBe(0) }) }) + +describe('filterProjectTableRowsBySelectedRepos', () => { + it('keeps a row when at least one slug match is selected', () => { + const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + true, + new Set(['repo-1']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['visible']) + expect(filtered.totalCount).toBe(1) + }) + + it('filters a row when only unselected repos match', () => { + const rows = [row('hidden', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-2')], + true, + new Set(['repo-1']) + ) + + expect(filtered.rows).toEqual([]) + expect(filtered.totalCount).toBe(0) + }) + + it('keeps a row with multiple selected matches for action ambiguity handling', () => { + const rows = [row('ambiguous', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + true, + new Set(['repo-1', 'repo-2']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous']) + }) +}) + +describe('resolveSelectedProjectRowRepo', () => { + it('reports loading without reading stale slug matches', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('loading', 'acme/orca'), + lookupSlug: () => { + throw new Error('should not read stale matches') + }, + slugIndexReady: false, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('loading') + }) + + it('reports invalid slug for rows without a repository', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing-slug', null), + lookupSlug: () => [repo('repo-1')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('invalid_slug') + }) + + it('reports no global match when Orca has no repo for the slug', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing', 'acme/orca'), + lookupSlug: () => [], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('no_global_match') + }) + + it('reports global-only matches when the repo is not selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('unselected', 'acme/orca'), + lookupSlug: () => [repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('unselected_match') + }) + + it('returns the selected match when exactly one matching repo is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('selected', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-2']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } }) + }) + + it('reports ambiguity when multiple matching repos are selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('ambiguous', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1', 'repo-2']) + }) + + expect(resolution.status).toBe('ambiguous_selected_match') + }) +}) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 5e5f90d113c..a0c23c20d66 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types' export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[] +export type SelectedProjectRowResolution = + | { status: 'loading' } + | { status: 'invalid_slug' } + | { status: 'no_global_match' } + | { status: 'unselected_match'; globalMatches: readonly Repo[] } + | { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] } + | { + status: 'ambiguous_selected_match' + selectedMatches: readonly Repo[] + globalMatches: readonly Repo[] + } + +export function resolveSelectedProjectRowRepo(input: { + row: GitHubProjectRow + lookupSlug: ProjectRowSlugLookup + slugIndexReady: boolean + selectedRepoIds: ReadonlySet +}): SelectedProjectRowResolution { + if (!input.slugIndexReady) { + return { status: 'loading' } + } + + const repository = input.row.content.repository + if (!repository) { + return { status: 'invalid_slug' } + } + const [owner, repo] = repository.split('/') + if (!owner || !repo) { + return { status: 'invalid_slug' } + } + + const globalMatches = input.lookupSlug(repository) + if (globalMatches.length === 0) { + return { status: 'no_global_match' } + } + + const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + if (selectedMatches.length === 0) { + return { status: 'unselected_match', globalMatches } + } + if (selectedMatches.length === 1) { + return { status: 'selected_match', repo: selectedMatches[0], globalMatches } + } + return { status: 'ambiguous_selected_match', selectedMatches, globalMatches } +} + export function projectRowHasOpenRepo( row: GitHubProjectRow, lookupSlug: ProjectRowSlugLookup @@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos( } return { ...table, rows, totalCount: rows.length } } + +export function filterProjectTableRowsBySelectedRepos( + table: GitHubProjectTable, + lookupSlug: ProjectRowSlugLookup, + slugIndexReady: boolean, + selectedRepoIds: ReadonlySet +): GitHubProjectTable { + const rows = table.rows.filter((row) => { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + return ( + resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match' + ) + }) + if (rows.length === table.rows.length && table.totalCount === rows.length) { + return table + } + return { ...table, rows, totalCount: rows.length } +} diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts index e2bc44cca24..e5eeabc40fc 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectTable } from '../../../../shared/github-project-types' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, - getVisibleProjectTable + getVisibleProjectTable, + getVisibleProjectTableCacheKey } from './project-visible-table-cache' function table(id: string): GitHubProjectTable { @@ -17,20 +19,22 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable, slugIndexReady: true, filteredTable, previous: null }) - ).toEqual({ cacheKey: 'project:view', table: filteredTable }) + ).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable }) }) it('keeps the previous cache while the slug index is rebuilding', () => { - const previous = { cacheKey: 'project:view', table: table('previous') } + const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') } expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: table('source'), slugIndexReady: false, filteredTable: null, @@ -45,6 +49,7 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: null, + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: null, slugIndexReady: false, filteredTable: null, @@ -54,11 +59,12 @@ describe('project visible table cache', () => { }) it('shows a matching cached table while the slug index is rebuilding', () => { - const cachedTable = { cacheKey: 'project:view', table: table('cached') } + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable @@ -67,15 +73,36 @@ describe('project visible table cache', () => { }) it('does not show stale cached data for a different cache key', () => { - const cachedTable = { cacheKey: 'other:view', table: table('cached') } + const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable }) ).toBeNull() }) + + it('does not show stale cached data for a different repo selection', () => { + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } + + expect( + getVisibleProjectTable({ + currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])), + slugIndexReady: false, + filteredTable: null, + cachedTable + }) + ).toBeNull() + }) + + it('layers selection only onto the renderer visible-table cache key', () => { + const storeCacheKey = 'organization:stablyai:1:view-id:local' + + expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey) + }) }) diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts index 2411012259c..6390e897281 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts @@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = { table: GitHubProjectTable } +export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet): string { + return JSON.stringify([...selectedRepoIds].sort()) +} + +export function getVisibleProjectTableCacheKey( + currentCacheKey: string | null, + selectedRepoFingerprint: string +): string | null { + return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null +} + export function getNextVisibleProjectTableCache(input: { currentCacheKey: string | null + selectedRepoFingerprint: string sourceTable: GitHubProjectTable | null slugIndexReady: boolean filteredTable: GitHubProjectTable | null previous: CachedVisibleProjectTable | null }): CachedVisibleProjectTable | null { - if (!input.currentCacheKey || !input.sourceTable) { + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + if (!visibleCacheKey || !input.sourceTable) { return null } if (input.slugIndexReady && input.filteredTable) { - return { cacheKey: input.currentCacheKey, table: input.filteredTable } + return { cacheKey: visibleCacheKey, table: input.filteredTable } } return input.previous } export function getVisibleProjectTable(input: { currentCacheKey: string | null + selectedRepoFingerprint: string slugIndexReady: boolean filteredTable: GitHubProjectTable | null cachedTable: CachedVisibleProjectTable | null @@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: { if (input.slugIndexReady || !input.currentCacheKey) { return input.filteredTable } - return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null } diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index a694b99c44c..1b1d98786a7 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => { ) const modalSection = sourceBetween( TASK_PAGE_SOURCE, - '', + '', ' Date: Tue, 23 Jun 2026 10:34:32 -0700 Subject: [PATCH 31/57] Remove textContentType none from iOS terminal input (#6176) - iOS `textContentType` overrides `autoComplete` and restricts keyboard layouts, preventing switching to non-Latin input methods (IMEs). - Use `autoComplete="off"` instead to ensure the keyboard remains default and IME switching stays available. - Update tests to assert these changes. --- mobile/app/h/[hostId]/session/[worktreeId].tsx | 7 ++++++- mobile/src/terminal/terminal-ios-ime-keyboard.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7be0e3894ea..542dbf53a47 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4915,12 +4915,14 @@ export default function SessionScreen() { autoCorrect={false} spellCheck={false} smartInsertDelete={false} + // Why: iOS textContentType wins over autoComplete and can + // narrow the keyboard surface; keep IME switching available. + autoComplete="off" keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)} returnKeyType="default" blurOnSubmit={false} editable={canSend} importantForAutofill="no" - textContentType="none" /> ) : ( @@ -4946,6 +4948,9 @@ export default function SessionScreen() { autoCorrect={autocompleteEnabled} spellCheck={autocompleteEnabled} smartInsertDelete={false} + // Why: terminal commands are not autofill content, but the + // keyboard must stay default so non-Latin IMEs remain selectable. + autoComplete="off" keyboardType={getTerminalCommandKeyboardType( Platform.OS, autocompleteEnabled diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index 412e9db664a..ac6ee0ff91f 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => { expect(sessionRouteSource).not.toContain("'ascii-capable'") expect(sessionRouteSource).not.toContain('"ascii-capable"') }) + + it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { + expect(sessionRouteSource).not.toContain('textContentType="none"') + expect(sessionRouteSource).toContain('autoComplete="off"') + }) }) From 953b325180385144731170c91271d15ad1e2602e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:38:37 -0700 Subject: [PATCH 32/57] Scope Claude Keychain lookup and disable PTY fallback for system auth (#6180) * Scope Claude Keychain lookup and disable PTY fallback for system auth * Scope macOS Keychain credential lookups to the config directory first to support Claude Code 2.1+, falling back to the legacy system default. * Disable Claude CLI PTY execution fallback for system-default accounts during usage refreshes to avoid launching unexpected browser auth flows. * Disable Claude PTY fallback when auth preparation is unavailable Treat missing Claude auth preparation as system-default. This aligns the PTY fallback gate to prevent background refreshes from triggering interactive authentication flows when no auth is configured. --- src/main/rate-limits/claude-fetcher.test.ts | 73 +++++++++++++++------ src/main/rate-limits/claude-fetcher.ts | 10 ++- src/main/rate-limits/service.test.ts | 63 +++++++++++++++++- src/main/rate-limits/service.ts | 28 ++++++-- 4 files changed, 144 insertions(+), 30 deletions(-) diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index b3c5dc54c20..5c984c607e8 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => { + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => { stripAuthEnv: false, provenance: 'system' } - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', @@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => { weekly: { usedPercent: 34 } }) - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ @@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'scoped-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer scoped-oauth-token' + }) + }) + ) + }) + it('falls back to the credentials file when Keychain access fails', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 2c8db5194cf..94f725c2366 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions( if (!authPreparation) { return undefined } + // Why: Claude Code 2.1+ can scope even the default config dir's macOS + // Keychain item. Try scoped first, with legacy still handled as fallback. const readOptions: OAuthCredentialReadOptions = { - credentialsFileConfigDir: authPreparation.configDir - } - // Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so - // their Keychain lookup must mirror Claude's legacy service ordering. - if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) { - readOptions.keychainConfigDir = authPreparation.configDir + credentialsFileConfigDir: authPreparation.configDir, + keychainConfigDir: authPreparation.configDir } return readOptions } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dc48755b1e5..76bcc8672d6 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -353,7 +353,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: true + allowPtyFallback: false }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) @@ -464,6 +464,67 @@ describe('RateLimitService', () => { expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('does not use Claude PTY fallback for system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { + const service = new RateLimitService() + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: undefined, + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false + }) + }) + it('does not cache host Codex usage under an outgoing WSL account', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 6b6e3c064db..822dfa37101 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number { return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) } +function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles classify missing Claude auth as system-default; keep the + // PTY fallback gate aligned so background refresh cannot trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -792,10 +804,18 @@ export class RateLimitService { return process.platform !== 'win32' } - private shouldAllowClaudePtyFallback(): boolean { + private shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { // Why: automatic recovery uses Claude CLI as the next source, but Windows // hidden PTY support remains less reliable than host/WSL shells. - return process.platform !== 'win32' + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude is not an Orca-managed account. Background + // quota refresh may read existing OAuth, but must not launch Claude and + // trigger auth/browser flows for users who never configured Claude in Orca. + return !isSystemDefaultClaudeAuth(authPreparation) } private withFetchingStatus( @@ -861,7 +881,7 @@ export class RateLimitService { await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }), missingWslCodexHome ?? fetchCodexRateLimits({ @@ -1036,7 +1056,7 @@ export class RateLimitService { const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }).catch( (err): ProviderRateLimits => ({ provider: 'claude', From 0e6ecc3fc19f45b64c171f6b63eea450790c98dd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:43:44 -0700 Subject: [PATCH 33/57] Prevent restored inactive terminal tabs from being flagged as orphans (#6179) Consolidate session binding resolution into a dedicated helper that scans active PTY states, restored tab PTY IDs, and split-pane layout wake hints. This ensures that deferred-reattach terminal sessions are not falsely classified as orphaned before their UI panes mount. --- .../status-bar/ResourceUsageStatusSegment.tsx | 51 +++++---- .../mergeSnapshotAndSessions.test.ts | 21 ++++ .../status-bar/mergeSnapshotAndSessions.ts | 41 ++----- .../resource-session-bindings.test.ts | 101 ++++++++++++++++++ .../status-bar/resource-session-bindings.ts | 86 +++++++++++++++ .../status-bar/resource-usage-merge-types.ts | 4 +- 6 files changed, 241 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/status-bar/resource-session-bindings.test.ts create mode 100644 src/renderer/src/components/status-bar/resource-session-bindings.ts diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index f9f9e72afb5..67095fee7c8 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -68,6 +68,11 @@ import { getResourceManagerAriaLabel, getResourceManagerTooltipLines } from './resource-manager-terminal-copy' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions, + type ResourceSessionBindingInputs +} from './resource-session-bindings' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 @@ -732,6 +737,8 @@ export function ResourceUsageStatusSegment({ const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -778,6 +785,17 @@ export function ResourceUsageStatusSegment({ // While a runtime server is active, hiding local samples avoids showing or // killing sessions from the wrong machine. const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource + // Manager also reads restored wake hints, but only for classification. + const resourceSessionBindings = useMemo( + () => ({ + ptyIdsByTabId, + tabsByWorktree: tabsByWorktreeForPtyBindings, + terminalLayoutsByTabId, + workspaceSessionReady + }), + [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + ) // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -947,6 +965,7 @@ export function ResourceUsageStatusSegment({ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -960,6 +979,7 @@ export function ResourceUsageStatusSegment({ sessions, tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -969,28 +989,12 @@ export function ResourceUsageStatusSegment({ // Why: orphanCount drives the trigger badge (always visible in the status // bar, popover open or not) so it must compute outside the open-gate. - // Build the bound set with a single flat walk instead of nested Object - // iterations to keep this light on every store update. const orphanCount = useMemo(() => { if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } - let n = 0 - for (const s of sessions) { - if (!bound.has(s.id)) { - n++ - } - } - return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) + return countUnboundDaemonSessions(sessions, resourceSessionBindings) + }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1123,14 +1127,7 @@ export function ResourceUsageStatusSegment({ if (!workspaceSessionReady) { return } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } + const bound = buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds const orphans = sessions.filter((s) => !bound.has(s.id)) if (orphans.length === 0) { return @@ -1141,7 +1138,7 @@ export function ResourceUsageStatusSegment({ setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) void refreshSessions() - }, [sessions, ptyIdsByTabId, workspaceSessionReady, refreshSessions]) + }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions]) const runKillConfirmed = useCallback(async () => { if (!killConfirm) { diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 06ed8b088a4..ad0d13dd0da 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -186,6 +186,27 @@ describe('mergeSnapshotAndSessions', () => { expect(out[0].worktrees[0].sessions[0].bound).toBe(true) }) + it('treats startup deferred reattach tab ptyId wake hints as bound sessions', () => { + const tabId = 'tab-restored' + const sessionId = 'orca::/Users/me/Triton@@deferred' + const ds: DaemonSession[] = [{ id: sessionId, cwd: '/Users/me/Triton', title: 'orca/Triton' }] + const restoredTab = { ...makeTab(tabId, 'Restored'), ptyId: sessionId } + const ctx = baseCtx({ + tabsByWorktree: { + 'orca::/Users/me/Triton': [restoredTab] + }, + ptyIdsByTabId: { [tabId]: [] } + }) + + const out = mergeSnapshotAndSessions(null, ds, ctx) + + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId, + bound: true, + tabId + }) + }) + it('repo aggregate sums only worktrees with numeric metrics; remote-by-connectionId flags chip', () => { // Why: a single repo can be both reflected as a snapshot worktree // (covered by the local collector) and a daemon-only session diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 82aa54835fa..6fcfa3b2acf 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -31,6 +31,7 @@ import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' +import { buildResourceSessionBindingIndex } from './resource-session-bindings' // ─── Helpers ──────────────────────────────────────────────────────── @@ -124,34 +125,6 @@ function resolveDaemonSessionLabel( return 'unknown' } -// Why: the previous implementation did O(N) linear scans over -// ptyIdsByTabId / tabsByWorktree for *every* session it processed. With a -// large workspace that's S * (T + W) work per merge — and the merge runs on -// every snapshot poll plus every store mutation. Pre-build O(1) lookup -// indices once per merge instead. -type MergeIndex = { - ptyIdToTabId: Map - tabIdToWorktreeId: Map -} - -function buildMergeIndex(ctx: MergeContext): MergeIndex { - const ptyIdToTabId = new Map() - for (const [tabId, ptyIds] of Object.entries(ctx.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - if (ptyId) { - ptyIdToTabId.set(ptyId, tabId) - } - } - } - const tabIdToWorktreeId = new Map() - for (const [worktreeId, tabs] of Object.entries(ctx.tabsByWorktree)) { - for (const tab of tabs) { - tabIdToWorktreeId.set(tab.id, worktreeId) - } - } - return { ptyIdToTabId, tabIdToWorktreeId } -} - // ─── Public merge function ───────────────────────────────────────── export const UNATTRIBUTED_REPO_ID = '__unattributed__' @@ -164,13 +137,11 @@ export function mergeSnapshotAndSessions( ): UnifiedProjectGroup[] { const repos = new Map() const seenSessionIds = new Set() - const index = buildMergeIndex(ctx) - // Why: bound = the daemon session id appears as a pty id under some tab. - // ptyIdToTabId already encodes that membership in O(1), so the bound set - // is just its keys. - const boundPtyIds = ctx.workspaceSessionReady - ? new Set(index.ptyIdToTabId.keys()) - : new Set() + // Why: pre-build O(1) lookup indices once per merge. This includes live + // ptyIdsByTabId plus deferred-reattach wake hints, so restored inactive + // sessions do not appear as Resource Manager orphans before their pane mounts. + const index = buildResourceSessionBindingIndex(ctx) + const boundPtyIds = index.boundPtyIds function isRepoRemote(repoId: string): boolean { // Why: missing entry === we don't know about this repo (typically the diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts new file mode 100644 index 00000000000..58b13ca0595 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions +} from './resource-session-bindings' + +function makeTab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/workspace', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + type: 'terminal', + paneCount: 1 + } as unknown as TerminalTab +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +describe('resource session bindings', () => { + it('combines live PTYs with restored tab and split-pane wake hints', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: { + 'tab-active': ['pty-live', 'pty-live'] + }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-active'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: { + 'tab-restored': makeLayout({ + 'leaf-1': 'pty-leaf-a', + 'leaf-2': 'pty-leaf-b' + }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId).toEqual( + new Map([ + ['pty-live', 'tab-active'], + ['pty-restored', 'tab-restored'], + ['pty-leaf-a', 'tab-restored'], + ['pty-leaf-b', 'tab-restored'] + ]) + ) + expect([...index.boundPtyIds].sort()).toEqual([ + 'pty-leaf-a', + 'pty-leaf-b', + 'pty-live', + 'pty-restored' + ]) + }) + + it('ignores stale layout-only PTYs after their tab is gone', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: {}, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live', null)] + }, + terminalLayoutsByTabId: { + 'tab-closed': makeLayout({ 'leaf-1': 'pty-closed' }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId.has('pty-closed')).toBe(false) + expect(index.boundPtyIds.has('pty-closed')).toBe(false) + }) + + it('counts only daemon sessions without live or restorable tab bindings', () => { + const count = countUnboundDaemonSessions( + [ + { id: 'pty-live', cwd: '/workspace', title: 'live' }, + { id: 'pty-restored', cwd: '/workspace', title: 'restored' }, + { id: 'pty-orphan', cwd: '/tmp', title: 'orphan' } + ], + { + ptyIdsByTabId: { 'tab-live': ['pty-live'] }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + ) + + expect(count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.ts b/src/renderer/src/components/status-bar/resource-session-bindings.ts new file mode 100644 index 00000000000..f90914f9669 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.ts @@ -0,0 +1,86 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { DaemonSession } from './resource-usage-merge-types' + +export type ResourceSessionBindingInputs = { + tabsByWorktree: Record + ptyIdsByTabId: Record + terminalLayoutsByTabId?: Record + workspaceSessionReady: boolean +} + +export type ResourceSessionBindingIndex = { + ptyIdToTabId: Map + tabIdToWorktreeId: Map + boundPtyIds: Set +} + +function addBinding( + ptyIdToTabId: Map, + tabId: string, + ptyId: string | null | undefined +): void { + if (!ptyId || ptyIdToTabId.has(ptyId)) { + return + } + ptyIdToTabId.set(ptyId, tabId) +} + +export function buildResourceSessionBindingIndex( + inputs: ResourceSessionBindingInputs +): ResourceSessionBindingIndex { + const ptyIdToTabId = new Map() + const tabIdToWorktreeId = new Map() + + for (const [worktreeId, tabs] of Object.entries(inputs.tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + + for (const [tabId, ptyIds] of Object.entries(inputs.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + // Why: startup-deferred reattach intentionally leaves inactive tabs out of + // ptyIdsByTabId, but their daemon sessions are still owned by tab/layout + // wake hints. Resource Manager should not classify those as orphans. + for (const tabs of Object.values(inputs.tabsByWorktree)) { + for (const tab of tabs) { + addBinding(ptyIdToTabId, tab.id, tab.ptyId) + } + } + + for (const [tabId, layout] of Object.entries(inputs.terminalLayoutsByTabId ?? {})) { + if (!tabIdToWorktreeId.has(tabId)) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + return { + ptyIdToTabId, + tabIdToWorktreeId, + boundPtyIds: inputs.workspaceSessionReady ? new Set(ptyIdToTabId.keys()) : new Set() + } +} + +export function countUnboundDaemonSessions( + sessions: readonly DaemonSession[], + inputs: ResourceSessionBindingInputs +): number { + if (!inputs.workspaceSessionReady) { + return 0 + } + const { boundPtyIds } = buildResourceSessionBindingIndex(inputs) + let count = 0 + for (const session of sessions) { + if (!boundPtyIds.has(session.id)) { + count += 1 + } + } + return count +} diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5cf962fcd10..88791b5d36e 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -50,6 +50,8 @@ export type MergeContext = { tabsByWorktree: Record /** From useAppStore: maps tabId -> ptyIds[] for the bound check. */ ptyIdsByTabId: Record + /** From useAppStore: persisted per-leaf PTY wake hints for deferred reattach. */ + terminalLayoutsByTabId?: Record /** From useAppStore: per-tab live pane titles (for label resolution). */ runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ From 0c8c248199e4616435ba0631526ccd3a8786f62b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:58:38 -0700 Subject: [PATCH 34/57] fix: address review findings (#6182) --- .../editor/CombinedDiffFileTree.test.ts | 15 ++++ .../editor/CombinedDiffFileTree.tsx | 75 +++++++++++------ .../components/editor/CombinedDiffViewer.tsx | 64 ++++++++++----- .../src/components/editor/EditorContent.tsx | 3 +- .../editor/combined-diff-entries.test.ts | 5 +- .../editor/combined-diff-entries.ts | 5 +- .../editor/combined-diff-file-tree-model.ts | 6 +- .../components/editor/editor-header.test.ts | 2 +- .../src/components/editor/editor-header.ts | 4 +- .../src/components/editor/editor-labels.ts | 5 +- .../editor/editor-panel-diff-reload.ts | 1 + .../editor/editor-panel-render-model.ts | 4 +- .../editor/markdown-preview-controls.ts | 1 + src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/store/slices/editor.test.ts | 81 ++++++++++++++++++- src/renderer/src/store/slices/editor.ts | 27 +++++-- 16 files changed, 236 insertions(+), 64 deletions(-) diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts index c744c933a1e..493a33af70e 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts @@ -41,10 +41,25 @@ describe('CombinedDiffFileTree navigation mapping', () => { it('maps branch and commit entries to combined section prefixes', () => { const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' } + expect(getCombinedDiffFileTreeSectionKey('all', entry)).toBe('combined-branch:src/view.ts') expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts') expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts') }) + it('keeps all-changes local and branch entries separate', () => { + const localEntry: GitStatusEntry = { + path: 'src/view.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' } + + expect(getCombinedDiffFileTreeSectionKey('all', localEntry)).toBe('unstaged:src/view.ts') + expect(getCombinedDiffFileTreeSectionKey('all', branchEntry)).toBe( + 'combined-branch:src/view.ts' + ) + }) + it('expands a collapsed target section and scrolls to its index', () => { const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' } const toggleSection = vi.fn() diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index 5a92bdae79e..ad461641e8c 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -61,14 +61,14 @@ function buildUncommittedRows( } function buildBranchRows( - mode: Extract, + mode: Extract, entries: readonly CombinedDiffFileTreeEntry[], collapsedDirectoryKeys: ReadonlySet ): CombinedDiffTreeNode[] { const branchEntries = entries.filter( (entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry) ) - const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit' + const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch' const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries)) return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] } @@ -149,12 +149,14 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => - mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [], + mode === 'all' || mode === 'uncommitted' + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) + : [], [collapsedDirectoryKeys, filteredEntries, mode] ) const branchRows = React.useMemo( () => - mode === 'branch' || mode === 'commit' + mode === 'all' || mode === 'branch' || mode === 'commit' ? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys) : [], [collapsedDirectoryKeys, filteredEntries, mode] @@ -280,27 +282,52 @@ export function CombinedDiffFileTree({ 'No files match the current filters.' )}
- ) : mode === 'uncommitted' ? ( - uncommittedGroups.map((group) => ( -
-
- {group.label} + ) : mode === 'all' || mode === 'uncommitted' ? ( + <> + {uncommittedGroups.map((group) => ( +
+
+ {group.label} +
+ {group.rows.map((node) => ( + + ))}
- {group.rows.map((node) => ( - - ))} -
- )) + ))} + {mode === 'all' && branchRows.length > 0 ? ( +
+
+ {translate( + 'auto.components.editor.CombinedDiffFileTree.39b6b9e4e4', + 'Committed on Branch' + )} +
+ {branchRows.map((node) => ( + + ))} +
+ ) : null} + ) : ( branchRows.map((node) => ( (() => { return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) }, [file.branchEntriesSnapshot, liveBranchEntries]) + const renderableBranchEntries = React.useMemo( + () => (branchCompare ? branchEntries : []), + [branchCompare, branchEntries] + ) const commitEntries = React.useMemo( () => file.commitEntriesSnapshot ?? [], [file.commitEntriesSnapshot] ) - const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries - const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted' + const allEntries = React.useMemo( + () => [...uncommittedEntries, ...renderableBranchEntries], + [renderableBranchEntries, uncommittedEntries] + ) + const entries = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries + const treeMode = isAllMode + ? 'all' + : isBranchMode + ? 'branch' + : isCommitMode + ? 'commit' + : 'uncommitted' const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({ mode: treeMode, @@ -477,7 +498,7 @@ export default function CombinedDiffViewer({ pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null setSections( entries.map((entry) => ({ - key: `${'area' in entry ? entry.area : (file.diffSource ?? 'compare')}:${entry.path}`, + key: getCombinedDiffFileTreeSectionKey(treeMode, entry), path: entry.path, status: entry.status, area: 'area' in entry ? entry.area : undefined, @@ -500,7 +521,7 @@ export default function CombinedDiffViewer({ loadSchedulerRef.current.reset() generationRef.current += 1 setGeneration((prev) => prev + 1) - }, [entries, entrySignature, file.diffSource, gitStatusEntries, viewStateKey]) + }, [entries, entrySignature, file.diffSource, gitStatusEntries, treeMode, viewStateKey]) const loadSectionNow = useCallback( async (index: number) => { @@ -510,11 +531,13 @@ export default function CombinedDiffViewer({ loadingIndicesRef.current.add(index) const gen = generationRef.current - const entries = isBranchMode - ? branchEntries - : isCommitMode - ? commitEntries - : uncommittedEntries + const entries = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries const entry = entries[index] if (!entry) { loadingIndicesRef.current.delete(index) @@ -527,7 +550,7 @@ export default function CombinedDiffViewer({ const connectionId = getConnectionId(file.worktreeId) ?? undefined const state = useAppStore.getState() const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if (isBranchMode && branchCompare) { + if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) { result = await withDiffSectionLoadTimeout( getRuntimeGitBranchDiff( { @@ -629,14 +652,16 @@ export default function CombinedDiffViewer({ branchCompare?.baseOid, branchCompare?.headOid, branchCompare?.mergeBase, - branchEntries, + allEntries, commitCompare?.commitOid, commitCompare?.parentOid, commitEntries, file.filePath, file.runtimeEnvironmentId, + isAllMode, isBranchMode, isCommitMode, + renderableBranchEntries, uncommittedEntries ] ) @@ -855,7 +880,7 @@ export default function CombinedDiffViewer({ }, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus]) useEffect(() => { - if (treeMode !== 'uncommitted') { + if (treeMode !== 'all' && treeMode !== 'uncommitted') { return } const handler = (event: Event): void => { @@ -927,7 +952,9 @@ export default function CombinedDiffViewer({ removed: section.removed } - if (isBranchMode && branchCompare) { + const isBranchEntry = section.area === undefined + + if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) { openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) return } @@ -952,6 +979,7 @@ export default function CombinedDiffViewer({ file.filePath, file.runtimeEnvironmentId, file.worktreeId, + isAllMode, isBranchMode, isCommitMode, openBranchDiff, @@ -1145,14 +1173,14 @@ export default function CombinedDiffViewer({ return } - if (file.combinedAlternate.source === 'combined-uncommitted') { + if (file.combinedAlternate.source === 'combined-all') { openAllDiffs(file.worktreeId, file.filePath) return } if (branchSummary && branchSummary.status === 'ready') { openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, { - source: 'combined-uncommitted' + source: 'combined-all' }) } }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) @@ -1447,7 +1475,7 @@ export default function CombinedDiffViewer({ {sections.length}{' '} {translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')} - {isBranchMode && branchCompare + {(isAllMode || isBranchMode) && branchCompare ? translate( 'auto.components.editor.CombinedDiffViewer.6094135eec', ' vs {{value0}}', @@ -1524,7 +1552,7 @@ export default function CombinedDiffViewer({ ) : translate( 'auto.components.editor.CombinedDiffViewer.982d14bfa5', - 'Open Uncommitted Diff' + 'Open All Changes' )} )} @@ -1601,7 +1629,7 @@ export default function CombinedDiffViewer({ toggleSection={toggleSection} openSection={openSection} openSectionTitle={ - isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' + isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' } setSectionHeights={setSectionHeights} setSections={setSections} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index b45d224e334..ed813e0ecf3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -199,7 +199,8 @@ export function EditorContent({ const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index 2aa4935564c..859cd92d882 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -35,7 +35,7 @@ describe('getCombinedUncommittedEntries', () => { ]) }) - it('excludes untracked entries when no area filter is set', () => { + it('includes every area when no area filter is set', () => { const liveEntries: GitStatusEntry[] = [ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, @@ -44,7 +44,8 @@ describe('getCombinedUncommittedEntries', () => { expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' } + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, + { path: 'src/untracked.ts', status: 'untracked', area: 'untracked' } ]) }) }) diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index c2ca3526d8c..e27e5037644 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -15,10 +15,7 @@ export function getCombinedUncommittedEntries( if (entry.conflictStatus === 'unresolved') { return false } - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) } diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts index 5f92d27f6d9..cbffbbc51e3 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -2,7 +2,7 @@ import { basename } from '@/lib/path' import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit' +export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' @@ -20,10 +20,10 @@ export function getCombinedDiffFileTreeSectionKey( mode: CombinedDiffFileTreeMode, entry: CombinedDiffFileTreeEntry ): string { - if (mode === 'uncommitted' && 'area' in entry) { + if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { return `${entry.area}:${entry.path}` } - return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}` + return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` } export function createCombinedDiffSectionIndexMap( diff --git a/src/renderer/src/components/editor/editor-header.test.ts b/src/renderer/src/components/editor/editor-header.test.ts index 7cb9e26deec..0939bc8dae0 100644 --- a/src/renderer/src/components/editor/editor-header.test.ts +++ b/src/renderer/src/components/editor/editor-header.test.ts @@ -98,7 +98,7 @@ describe('getEditorHeaderCopyState', () => { filePath: '/repo/worktree', relativePath: 'All Changes', mode: 'diff', - diffSource: 'combined-uncommitted' + diffSource: 'combined-all' }) ) ).toEqual({ diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index 9cfc39fe495..a3fbafbd6da 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -35,7 +35,8 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState const isCombinedDiff = file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || + (file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch' || file.diffSource === 'combined-commit') @@ -66,6 +67,7 @@ export function getEditorHeaderOpenFileState( const isSingleDiff = file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 50e55931db3..c39f38abe36 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -42,9 +42,12 @@ export function getEditorDisplayLabel( } const source = file.diffSource - if (source === 'combined-uncommitted') { + if (source === 'combined-all') { return 'All Changes' } + if (source === 'combined-uncommitted') { + return file.combinedAreaFilter ? getBaseLabel(file, variant) : 'Uncommitted Changes' + } if (source === 'combined-branch') { return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})` } diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.ts index ed33a5cbf31..4b58a471239 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.ts @@ -5,6 +5,7 @@ export function isReloadableSingleFileDiffTab(file: OpenFile): boolean { return ( file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 85463f36d64..be9738db6a4 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -40,12 +40,14 @@ export function getEditorPanelRenderModel({ const isSingleDiff = activeFile.mode === 'diff' && activeFile.diffSource !== undefined && + activeFile.diffSource !== 'combined-all' && activeFile.diffSource !== 'combined-uncommitted' && activeFile.diffSource !== 'combined-branch' && activeFile.diffSource !== 'combined-commit' const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') const resolvedLanguage = diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index df0762ff483..fc6ef9f5c1b 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -44,6 +44,7 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma } if ( target.mode === 'diff' && + target.diffSource !== 'combined-all' && target.diffSource !== 'combined-uncommitted' && target.diffSource !== 'combined-branch' && target.diffSource !== 'combined-commit' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ae48b785015..11712baf910 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10426,7 +10426,7 @@ "f786fd54e1": "Inline", "ea08dae15b": "Collapse All", "19c45cfdc0": "Expand All", - "982d14bfa5": "Open Uncommitted Diff", + "982d14bfa5": "Open All Changes", "3d909843bb": "Open Branch Diff", "8368d256ec": "combined-branch", "8f68ad9ca9": "Show {{value0}} AI {{value1}}", diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 6423b997134..315f4740310 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -11,7 +11,12 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { GitStatusEntry, Tab } from '../../../../shared/types' +import type { + GitBranchChangeEntry, + GitBranchCompareSummary, + GitStatusEntry, + Tab +} from '../../../../shared/types' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -2648,6 +2653,80 @@ describe('createEditorSlice combined diff exclusions', () => { }) ) }) + + it('includes untracked files in the all changes snapshot', () => { + const store = createEditorStore() + const stagedEntry: GitStatusEntry = { + path: 'src/staged.ts', + status: 'modified', + area: 'staged' + } + const untrackedEntry: GitStatusEntry = { + path: 'src/new.ts', + status: 'untracked', + area: 'untracked' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [stagedEntry, untrackedEntry] + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + uncommittedEntriesSnapshot: [stagedEntry, untrackedEntry] + }) + ) + }) + + it('opens all changes with uncommitted and committed branch snapshots', () => { + const store = createEditorStore() + const localEntry: GitStatusEntry = { + path: 'src/local.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { + path: 'src/committed.ts', + status: 'modified' + } + const branchSummary: GitBranchCompareSummary = { + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'HEAD', + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + changedFiles: 1, + status: 'ready' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [localEntry] + }) + store.setState({ + gitBranchCompareSummaryByWorktree: { 'wt-1': branchSummary }, + gitBranchChangesByWorktree: { 'wt-1': [branchEntry] } + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + diffSource: 'combined-all', + uncommittedEntriesSnapshot: [localEntry], + branchEntriesSnapshot: [branchEntry], + branchCompare: expect.objectContaining({ + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }) + }) + ) + }) }) describe('createEditorSlice remote branch actions', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fa2d9ea1d63..3780be8c6e3 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -94,6 +94,7 @@ export type DiffSource = | 'staged' | 'branch' | 'commit' + | 'combined-all' | 'combined-uncommitted' | 'combined-branch' | 'combined-commit' @@ -155,7 +156,7 @@ type CommitCompareLike = Pick< } type CombinedDiffAlternate = { - source: 'combined-uncommitted' | 'combined-branch' + source: 'combined-all' | 'combined-branch' branchCompare?: BranchCompareSnapshot } @@ -2657,13 +2658,22 @@ export const createEditorSlice: StateCreator = (s ] ?? 'All Changes') : 'All Changes' set((s) => { + const branchSummary = s.gitBranchCompareSummaryByWorktree[worktreeId] + const branchCompare = + !areaFilter && + branchSummary?.status === 'ready' && + branchSummary.baseOid && + branchSummary.headOid && + branchSummary.mergeBase + ? toBranchCompareSnapshot(branchSummary) + : undefined + const branchEntriesSnapshot = branchCompare + ? (s.gitBranchChangesByWorktree[worktreeId] ?? []) + : undefined const relevantEntries = entriesSnapshot ?? (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => { - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) const skippedConflicts = relevantEntries .filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind) @@ -2687,6 +2697,9 @@ export const createEditorSlice: StateCreator = (s f.id === id ? { ...f, + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, @@ -2710,7 +2723,9 @@ export const createEditorSlice: StateCreator = (s language: 'plaintext', isDirty: false, mode: 'diff', - diffSource: 'combined-uncommitted', + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, From fac7e0440cbebaab8b962641dfb0b7dc7e42faea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:07:09 -0700 Subject: [PATCH 35/57] Prevent sidebar crashes from missing worktree titles (#6127) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeCard.tsx | 8 ++- .../worktree-card-title-display.test.ts | 72 ++++++++++++++++++- .../sidebar/worktree-card-title-display.ts | 39 ++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index d41cf15e57a..d8f306c0508 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,7 +45,10 @@ import { import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' @@ -505,7 +508,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issueTitle: issueDisplay?.title, reviewTitle: prDisplay?.title }) - const visibleCardTitle = newCardStyle ? cardTitleDisplay : worktree.displayName + const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) + const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false const deleteModifierPressed = useWorkspaceDeleteModifierPressed() diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts index 5e56d7df4ea..bb5cf7625c4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' describe('worktree card title display', () => { it('keeps custom workspace titles', () => { @@ -10,6 +13,14 @@ describe('worktree card title display', () => { reviewTitle: 'Fix stale PR' }) ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' Custom workspace ', + branchName: 'feature/custom', + reviewTitle: 'Fix stale PR' + }) + ).toBe(' Custom workspace ') }) it('uses linked work titles instead of repeating the branch as the card title', () => { @@ -49,4 +60,63 @@ describe('worktree card title display', () => { }) ).toBe('test454545') }) + + it('uses linked work titles when the stored title is nullish and the branch is usable', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: undefined, + branchName: 'feature/local-branch', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Fix stale GH PR') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: 'feature/local-branch', + issueTitle: 'Fix stale issue' + }) + ).toBe('Fix stale issue') + }) + + it('treats blank stored titles as absent', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' ', + branchName: 'feature/local-branch' + }) + ).toBe('') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: '', + branchName: 'feature/local-branch', + linearIssueTitle: 'Fix stale Linear issue' + }) + ).toBe('Fix stale Linear issue') + }) + + it('skips linked-title replacement when the branch name is nullish or blank', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: 'Custom workspace', + branchName: undefined, + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: ' ', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('') + }) + + it('coerces legacy visible titles before downstream title operations', () => { + expect(coerceWorktreeCardVisibleTitle(undefined).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(null).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(' Custom workspace ')).toBe(' Custom workspace ') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.ts index 5da84c1f5f4..be0aa9200d7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.ts @@ -1,11 +1,16 @@ type WorktreeCardTitleDisplayInput = { - storedDisplayName: string - branchName: string + storedDisplayName: string | null | undefined + branchName: string | null | undefined linearIssueTitle?: string | null issueTitle?: string | null reviewTitle?: string | null } +function normalizeComparableTitle(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + function normalizeTitle(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { @@ -17,8 +22,17 @@ function normalizeTitle(value: string | null | undefined): string | null { return trimmed } -function isBranchTitle(displayName: string, branchName: string): boolean { - return displayName.trim() === branchName.trim() +function isBranchTitle( + normalizedDisplayName: string | null, + normalizedBranchName: string | null +): boolean { + return normalizedDisplayName !== null && normalizedDisplayName === normalizedBranchName +} + +export function coerceWorktreeCardVisibleTitle(value: string | null | undefined): string { + // Why: the legacy card path can bypass title selection but still feeds trim() + // and inline rename props, so nullish persisted titles stop at this boundary. + return typeof value === 'string' ? value : '' } export function getWorktreeCardTitleDisplay({ @@ -28,8 +42,19 @@ export function getWorktreeCardTitleDisplay({ issueTitle, reviewTitle }: WorktreeCardTitleDisplayInput): string { - if (!branchName || !isBranchTitle(storedDisplayName, branchName)) { - return storedDisplayName + const normalizedStoredDisplayName = normalizeComparableTitle(storedDisplayName) + const normalizedBranchName = normalizeComparableTitle(branchName) + const visibleStoredDisplayName = coerceWorktreeCardVisibleTitle(storedDisplayName) + + if (!normalizedBranchName) { + return normalizedStoredDisplayName ? visibleStoredDisplayName : '' + } + + if ( + normalizedStoredDisplayName && + !isBranchTitle(normalizedStoredDisplayName, normalizedBranchName) + ) { + return visibleStoredDisplayName } // Why: branch names are available in hover/details; the closed card title @@ -38,6 +63,6 @@ export function getWorktreeCardTitleDisplay({ normalizeTitle(linearIssueTitle) ?? normalizeTitle(issueTitle) ?? normalizeTitle(reviewTitle) ?? - storedDisplayName + (normalizedStoredDisplayName ? visibleStoredDisplayName : '') ) } From 8c4f57e8efeca186d7c8bc5f68cbce3157b84634 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:07:49 -0700 Subject: [PATCH 36/57] Reduce repeated git probes during worktree refresh (#6153) Co-authored-by: Orca --- src/main/github/client.test.ts | 102 +++++- src/main/github/client.ts | 73 ++++ src/main/ipc/worktree-change-invalidators.ts | 25 ++ src/main/ipc/worktree-remote.ts | 4 + src/main/ipc/worktrees.test.ts | 317 +++++++++++++++++- src/main/ipc/worktrees.ts | 92 ++++- .../attach-main-window-services.test.ts | 14 +- .../window/attach-main-window-services.ts | 9 +- 8 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/worktree-change-invalidators.ts diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 8493317a4c0..9e6e5eb92bf 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -110,7 +110,8 @@ import { updatePRTitle, _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, - _resetMergeQueueCacheForTests + _resetMergeQueueCacheForTests, + __resetTrackedUpstreamBranchCacheForTests } from './client' describe('checkOrcaStarred', () => { @@ -179,6 +180,7 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetTrackedUpstreamBranchCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => { }) }) + it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('coalesces concurrent missing tracked-upstream probes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockImplementation(async () => { + await Promise.resolve() + throw new Error("fatal: no upstream configured for branch 'no-pr-branch'") + }) + + await Promise.all([ + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch') + ]) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(trackedUpstreamCalls[1][1]).toEqual({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + }) + + it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { + vi.useFakeTimers() + try { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) + .mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root', 'feature') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('feature@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('uses the tracked upstream remote owner for fork branch lookup', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6bf0976bac3..158c28ef4d6 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = { branchName: string } +const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000 + +type TrackedUpstreamNullCacheEntry = { + expiresAt: number +} + +const trackedUpstreamNullCache = new Map() +const trackedUpstreamInFlight = new Map>() + +export function __resetTrackedUpstreamBranchCacheForTests(): void { + trackedUpstreamNullCache.clear() + trackedUpstreamInFlight.clear() +} + function parseTrackedUpstreamBranch( upstreamRef: string, branchName: string @@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch( branchName: string, connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} +): Promise { + // Why: branches without configured upstreams are stable misses during PR + // polling; cache only nulls so positive PR discovery stays fresh. + const cacheKey = getTrackedUpstreamBranchCacheKey( + repoPath, + branchName, + connectionId, + localGitOptions + ) + const now = Date.now() + const cachedNull = trackedUpstreamNullCache.get(cacheKey) + if (cachedNull && cachedNull.expiresAt > now) { + return null + } + if (cachedNull) { + trackedUpstreamNullCache.delete(cacheKey) + } + + const inFlight = trackedUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions) + trackedUpstreamInFlight.set(cacheKey, probe) + try { + const result = await probe + if (result) { + trackedUpstreamNullCache.delete(cacheKey) + } else { + trackedUpstreamNullCache.set(cacheKey, { + expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS + }) + } + return result + } finally { + if (trackedUpstreamInFlight.get(cacheKey) === probe) { + trackedUpstreamInFlight.delete(cacheKey) + } + } +} + +function getTrackedUpstreamBranchCacheKey( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): string { + const runtimeKey = connectionId + ? `ssh:${connectionId}` + : `local:${localGitOptions.wslDistro ?? 'host'}` + return [runtimeKey, repoPath, branchName].join('\0') +} + +async function probeTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] try { diff --git a/src/main/ipc/worktree-change-invalidators.ts b/src/main/ipc/worktree-change-invalidators.ts new file mode 100644 index 00000000000..a43657743b4 --- /dev/null +++ b/src/main/ipc/worktree-change-invalidators.ts @@ -0,0 +1,25 @@ +const worktreeChangeInvalidators = new Set<(repoId: string) => void>() + +export function registerWorktreeChangeInvalidator( + invalidator: (repoId: string) => void +): () => void { + worktreeChangeInvalidators.add(invalidator) + return () => { + worktreeChangeInvalidators.delete(invalidator) + } +} + +export function runWorktreeChangeInvalidators(repoId: string): void { + const invalidators = Array.from(worktreeChangeInvalidators) + for (const invalidator of invalidators) { + try { + invalidator(repoId) + } catch (error) { + console.warn('[worktrees] worktree change invalidator failed:', error) + } + } +} + +export function __resetWorktreeChangeInvalidatorsForTests(): void { + worktreeChangeInvalidators.clear() +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 50fb87e58fb..74dfb7db9f8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,6 +57,7 @@ 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' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( } export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { + // Why: invalidate detected-worktree caches before renderer observers react, + // so follow-up listDetected reads post-change state. + runWorktreeChangeInvalidators(repoId) if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3b7130d040d..0f043c7c6fe 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' const ORIGINAL_PLATFORM = process.platform @@ -226,9 +226,12 @@ vi.mock('./pty', () => ({ getLocalPtyProvider: getLocalPtyProviderMock })) -import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' +import { + __resetSshWorktreeCreateFetchCacheForTests, + notifyWorktreesChanged +} from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' type HandlerMap = Record unknown> @@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() + __resetDetectedWorktreeScanCacheForTests() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => { }) }) + it('reuses a recent authoritative detected worktree scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + + expect(first).toEqual(second) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('coalesces concurrent authoritative detected worktree scans', async () => { + listWorktreesMock.mockImplementation(async () => { + await Promise.resolve() + return [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + }) + + await Promise.all([ + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ]) + + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rechecks detected worktree metadata while reusing a cached raw scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + let currentMeta = makeWorktreeMeta({ isPinned: false }) + store.getWorktreeMeta.mockImplementation(() => currentMeta) + store.setWorktreeMeta.mockImplementation(() => currentMeta) + const first = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + currentMeta = makeWorktreeMeta({ isPinned: true }) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(first.worktrees[0].isPinned).toBe(false) + expect(second.worktrees[0].isPinned).toBe(true) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rescans detected worktrees after the scan cache TTL expires', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(5_001) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/new-worktree' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('starts the detected scan cache TTL after a slow scan completes', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + 6_000 + ) + }) + ) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(6_000) + await first + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo']) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('invalidates the detected scan cache before worktree change notifications', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees).toHaveLength(2) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('rescans detected worktrees after the local create flow notifies worktree changes', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + const detected = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/improve-dashboard' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(3) + }) + + it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/new-worktree': { + worktreeId: 'repo-1::/workspace/new-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await pendingList + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 564d369d914..f550d11079a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -77,6 +77,7 @@ import { cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' +import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' import { invalidateAuthorizedRootsCache, isENOENT, @@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +// Why: absorb renderer polling bursts while keeping external worktree-change +// lag bounded to one short refresh window. +const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +const detectedWorktreeScanCache = new Map() +const detectedWorktreeScanInFlight = new Map>() +const detectedWorktreeScanGenerations = new Map() + +function invalidateDetectedWorktreeScanCache(repoId: string): void { + detectedWorktreeScanCache.delete(repoId) + detectedWorktreeScanInFlight.delete(repoId) + detectedWorktreeScanGenerations.set( + repoId, + (detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1 + ) +} + +registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) + +export function __resetDetectedWorktreeScanCacheForTests(): void { + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() + detectedWorktreeScanGenerations.clear() +} + +async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)), + fresh: true + } + } + + const cached = detectedWorktreeScanCache.get(repo.id) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(repo.id) + if (inFlight) { + return { gitWorktrees: await inFlight, fresh: false } + } + + const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)) + const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0 + detectedWorktreeScanInFlight.set(repo.id, scan) + try { + const gitWorktrees = await scan + // Why: a create/remove notification can invalidate while the git scan is + // still running. Do not let that stale scan repopulate the cache afterward. + const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation + if (isCurrentGeneration) { + detectedWorktreeScanCache.set(repo.id, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: isCurrentGeneration } + } finally { + if (detectedWorktreeScanInFlight.get(repo.id) === scan) { + detectedWorktreeScanInFlight.delete(repo.id) + } + } +} function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { @@ -946,6 +1024,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees: GitWorktreeInfo[] + let freshScan = true if (isFolderRepo(repo)) { return { repoId: repo.id, @@ -966,13 +1045,14 @@ export function registerWorktreeHandlers( } gitWorktrees = await provider.listWorktrees(repo.path) } else { - gitWorktrees = await listRepoWorktrees( - repo, - getLocalProjectWorktreeGitOptions(store, repo) - ) + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return { repoId: repo.id, diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1091ef71417..f69ca694c4a 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,8 @@ const { registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, - browserManagerUnregisterAllMock + browserManagerUnregisterAllMock, + runWorktreeChangeInvalidatorsMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -33,7 +34,8 @@ const { registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), - browserManagerUnregisterAllMock: vi.fn() + browserManagerUnregisterAllMock: vi.fn(), + runWorktreeChangeInvalidatorsMock: vi.fn() })) vi.mock('electron', () => ({ @@ -64,6 +66,10 @@ vi.mock('../ipc/worktrees', () => ({ registerWorktreeHandlers: registerWorktreeHandlersMock })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ + runWorktreeChangeInvalidators: runWorktreeChangeInvalidatorsMock +})) + vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock @@ -592,5 +598,9 @@ describe('attachMainWindowServices', () => { } ] ]) + expect(runWorktreeChangeInvalidatorsMock).toHaveBeenCalledWith('repo-1') + expect(runWorktreeChangeInvalidatorsMock.mock.invocationCallOrder[0]).toBeLessThan( + sendMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ce720ca612c..eace514bcc9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -36,6 +36,7 @@ import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../share import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -216,8 +217,12 @@ function registerRuntimeWindowLifecycle( } } runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => - send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }), + worktreesChanged: (repoId, renamed) => { + // Why: clear detected-worktree scan caches before renderer listeners + // handle this event, preventing stale TTL reads after mutations. + runWorktreeChangeInvalidators(repoId) + send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) + }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), reposChanged: () => send('repos:changed'), From 03fd869b9232e302291a2a189dae2cddd5647a7a Mon Sep 17 00:00:00 2001 From: Salah Alkhwlani Date: Tue, 23 Jun 2026 21:18:17 +0300 Subject: [PATCH 37/57] feat(ai-vault): support OpenCode SQLite session storage (#5925) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../ai-vault/session-scanner-agent-parser.ts | 25 +- .../session-scanner-codex-workers.test.ts | 1 + .../session-scanner-opencode-sources.ts | 73 +++ ...canner-opencode-sqlite-coexistence.test.ts | 215 ++++++++ ...ssion-scanner-opencode-sqlite-discovery.ts | 242 +++++++++ ...sion-scanner-opencode-sqlite-paths.test.ts | 47 ++ .../session-scanner-opencode-sqlite-paths.ts | 56 ++ .../session-scanner-opencode-sqlite.test.ts | 499 ++++++++++++++++++ .../session-scanner-opencode-sqlite.ts | 263 +++++++++ .../session-scanner-source-discovery.ts | 27 +- src/main/ai-vault/session-scanner-types.ts | 3 + src/main/ai-vault/session-scanner.test.ts | 3 + src/main/ai-vault/session-scanner.ts | 9 + src/main/opencode-usage/scanner.ts | 13 +- src/main/opencode-usage/schema-helpers.ts | 31 ++ 15 files changed, 1473 insertions(+), 34 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sources.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite.ts create mode 100644 src/main/opencode-usage/schema-helpers.ts diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index f65bb8eec6a..385b2961984 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -7,6 +7,8 @@ import { 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' import { parseClaudeSessionFile, parseCodexSessionFile, @@ -20,6 +22,15 @@ import { } from './session-scanner-secondary-parsers' import type { SessionFileCandidate } from './session-scanner-types' +/** + * Parse a single agent session file into an `AiVaultSession`. Routes to the + * appropriate agent-specific parser based on `candidate.agent`. For OpenCode + * SQLite candidates (synthetic `db#id` paths), routes to + * `parseOpenCodeSqliteSession` instead of the legacy JSON parser. + * @param candidate - The session file candidate to parse. + * @param platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if parsing fails. + */ export async function parseAgentSessionFile( candidate: SessionFileCandidate, platform: NodeJS.Platform @@ -35,8 +46,20 @@ export async function parseAgentSessionFile( return parseCopilotSessionFile(candidate.file, platform) case 'cursor': return parseCursorSessionFile(candidate.file, platform) - case 'opencode': + case 'opencode': { + // Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic + // # candidate path. Legacy file-based sessions use + // real filesystem paths and fall through to the JSON parser. + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path) + if (sqliteCandidate) { + return parseOpenCodeSqliteSession({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + } return parseOpenCodeSessionFile(candidate.file, platform) + } case 'grok': return parseGrokSessionFile(candidate.file, platform) case 'hermes': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index b40b5f591ca..6f99151f059 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts new file mode 100644 index 00000000000..24069c7a0f2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -0,0 +1,73 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const OPENCODE_STORAGE_DIR = join( + process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), + 'storage' +) + +export function opencodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const storageDirs = opencodeStorageDirs(options, wslHomeDirs) + return storageDirs.map(async (storageDir, index) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + limitPerAgent: limit, + issues + }) + ) +} + +function opencodeStorageDirs( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[] +): string[] { + return [ + options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) + ] +} + +async function opencodeDbPathsForSource( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + storageDir: string, + sourceIndex: number +): Promise { + if (options.opencodeDbPaths) { + return sourceIndex === 0 ? options.opencodeDbPaths : [] + } + // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. + if (sourceIndex === 0 && options.opencodeStorageDir) { + return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + } + if (sourceIndex === 0) { + return listOpenCodeDatabases() + } + const wslHomeDir = wslHomeDirs[sourceIndex - 1] + return wslHomeDir + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + : [] +} + +async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { + try { + const entries = await readdir(dataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDir, entry.name)) + .sort() + } catch { + return [] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts new file mode 100644 index 00000000000..4ae4ea22284 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -0,0 +1,215 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import Database from '../sqlite/sync-database' + +let tempRoots: string[] = [] +let tempDbDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + for (const dir of tempDbDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempRoots = [] + tempDbDirs = [] +}) + +function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +function createTempOpenCodeDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-')) + tempDbDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER, + model TEXT, + agent TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) +} + +describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => { + it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const opencodeDataDir = join(root, 'custom-opencode') + const opencodeStorageDir = join(opencodeDataDir, 'storage') + await mkdir(opencodeStorageDir, { recursive: true }) + + const dbPath = join(opencodeDataDir, 'opencode.db') + const db = new Database(dbPath) + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode', + 'Custom SQLite session', '1.0.0', + 1777634010000, 1777634011000, NULL, 'build', 0, + 8, 13, 21, 34, 0)` + ).run() + db.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeStorageDir, + opencodeDbPaths: undefined, + platform: 'darwin', + limit: 50 + }) + + const session = result.sessions.find((s) => s.sessionId === 'custom-db-session') + expect(session).toBeDefined() + expect(session!.agent).toBe('opencode') + expect(session!.title).toBe('Custom SQLite session') + expect(session!.filePath).toBe(dbPath) + expect(session!.totalTokens).toBe(42) + }) + + it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + // Legacy file session under storage/session//.json + await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true }) + await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true }) + await writeFile( + join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'), + JSON.stringify({ + id: 'legacy-session', + directory: '/tmp/legacy', + title: 'Legacy file session', + time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 } + }) + ) + await writeFile( + join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'), + JSON.stringify({ + role: 'user', + summary: { title: 'Legacy file session' }, + time: { created: 1_777_634_000_000 }, + tokens: { input: 5, output: 2 } + }) + ) + + // SQLite session — same sessionId as the legacy file (dedup should keep SQLite) + const { db, path: dbPath } = createTempOpenCodeDb() + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0', + 1777634002000, 1777634003000, ?, 'build', 0, + 100, 40, 10, 5, 0)` + ).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' })) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } })) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ type: 'text', text: 'sqlite hello' })) + db.close() + + // A second SQLite-only session + const { db: db2, path: dbPath2 } = createTempOpenCodeDb() + applyOpenCodeSchema(db2) + db2 + .prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0', + 1777634004000, 1777634005000, NULL, 'build', 0, + 50, 20, 0, 0, 0)` + ) + .run() + db2.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeDbPaths: [dbPath, dbPath2], + platform: 'darwin', + limit: 50 + }) + + const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode') + const sessionIds = opencodeSessions.map((s) => s.sessionId).sort() + expect(sessionIds).toEqual(['legacy-session', 'sqlite-only']) + + // Why: dedup keeps the SQLite entry (newer time_updated, source of truth) + const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session') + expect(legacyEntry).toBeDefined() + expect(legacyEntry!.title).toBe('SQLite session') + expect(legacyEntry!.cwd).toBe('/tmp/sqlite') + expect(legacyEntry!.filePath).toBe(dbPath) + expect(legacyEntry!.totalTokens).toBe(150) + expect(legacyEntry!.resumeCommand).toBe( + "cd '/tmp/sqlite' && opencode --session 'legacy-session'" + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts new file mode 100644 index 00000000000..3c465007992 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts @@ -0,0 +1,242 @@ +import { basename, extname, join } from 'path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { + FileWithMtime, + SessionFileCandidate, + SessionFileDiscovery +} from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: keep the SQLite discovery + dedup layer separate from the parser so +// each file stays under the max-lines lint rule and the discovery layer can +// be tested in isolation. + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionListQuery(db: SyncDatabase): string { + const modelSelect = sessionColumnSelect(db, 'model') + const agentSelect = sessionColumnSelect(db, 'agent') + const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'] + const tokenSelects = tokenColumns + .map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`) + .join(', ') + const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0' + const parentIdPredicate = columnExists(db, 'session', 'parent_id') + ? 'AND s.parent_id IS NULL' + : '' + const archivedPredicate = columnExists(db, 'session', 'time_archived') + ? 'AND s.time_archived IS NULL' + : '' + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${modelSelect} AS model_json, ${agentSelect} AS agent, + ${tokenSelects}, ${costSelect} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + ORDER BY s.time_updated DESC + LIMIT ?` +} + +function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + return { + agent: 'opencode' as AiVaultAgent, + file: { + path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + codexHome: null + } +} + +function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { + const candidatesBySessionId = new Map() + for (const candidate of candidates) { + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + if (!parsed) { + continue + } + const previous = candidatesBySessionId.get(parsed.sessionId) + if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) { + candidatesBySessionId.set(parsed.sessionId, candidate) + } + } + return [...candidatesBySessionId.values()].sort((left, right) => { + return right.file.mtimeMs - left.file.mtimeMs + }) +} + +/** + * List OpenCode sessions from one or more SQLite databases as synthetic + * `SessionFileCandidate` entries. Each candidate's file path is a synthetic + * `#` string that the parser dispatcher routes to + * `parseOpenCodeSqliteSession`. Databases that lack the `session` table are + * silently skipped; errors are recorded as scan issues. + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limit - Maximum number of sessions to return per database. + * @param args.issues - Collected scan issues to append errors to. + * @returns Array of synthetic candidates sorted by `time_updated` DESC. + */ +export async function listOpenCodeSqliteSessions(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const candidates: SessionFileCandidate[] = [] + for (const dbPath of args.dbPaths) { + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + continue + } + const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + for (const row of rows) { + candidates.push(rowToCandidate(row, dbPath)) + } + } catch (err) { + args.issues.push({ + agent: 'opencode', + path: dbPath, + message: errorMessage(err) + }) + } finally { + db?.close() + } + } + return dedupeAndSortSqliteCandidates(candidates) +} + +// Why: extract the sessionId from a legacy file path like +// storage/session//.json. Falls back to the filename +// without extension when the opencode id format doesn't match a UUID. +function sessionIdFromLegacyFilePath(filePath: string): string { + return basename(filePath, extname(filePath)) +} + +/** + * Discover OpenCode sessions from both the legacy file layout and the SQLite + * DB, deduplicating at the file level before parsing. On mixed installs the + * same session may appear once via a stale legacy JSON file and once via the + * SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries + * whose sessionId matches a SQLite entry are dropped. Legacy installs without + * the `session` table fall through to the file scanner unchanged. + * @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`). + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limitPerAgent - Maximum number of candidates per source. + * @param args.issues - Collected scan issues to append errors to. + * @returns A `SessionFileDiscovery` with deduplicated file entries. + */ +export async function discoverOpenCodeSessions(args: { + storageDir: string + dbPaths: readonly string[] + limitPerAgent: number + issues: AiVaultScanIssue[] +}): Promise { + const [fileDiscovery, sqliteCandidates] = await Promise.all([ + discoverFiles({ + rootDir: join(args.storageDir, 'session'), + limit: args.limitPerAgent, + agent: 'opencode', + issues: args.issues, + extensions: ['.json'] + }), + listOpenCodeSqliteSessions({ + dbPaths: args.dbPaths, + limit: args.limitPerAgent, + issues: args.issues + }) + ]) + + const sqliteFiles = sqliteCandidates.map((c) => c.file) + // Why: on mixed installs the same OpenCode session may appear once via the + // SQLite DB and once via a stale legacy JSON file. SQLite is the source of + // truth on 1.17.x, so drop file-based duplicates when a SQLite entry with + // the same sessionId already exists. Deduping at the file level also avoids + // parsing the same session twice. + if (sqliteFiles.length === 0) { + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: fileDiscovery.files + } + } + const sqliteSessionIds = new Set() + for (const file of sqliteFiles) { + const parsed = splitOpenCodeSqliteCandidate(file.path) + if (parsed) { + sqliteSessionIds.add(parsed.sessionId) + } + } + const dedupedFileDiscovery: FileWithMtime[] = [] + for (const file of fileDiscovery.files) { + if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) { + dedupedFileDiscovery.push(file) + } + } + + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: [...dedupedFileDiscovery, ...sqliteFiles] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts new file mode 100644 index 00000000000..db9c60f0b70 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildOpenCodeSqliteCandidatePath, + looksLikeOpenCodeSqliteCandidate, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' + +describe('splitOpenCodeSqliteCandidate', () => { + it('splits a synthetic db#sessionId path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc') + expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' }) + }) + + it('splits a stable-db path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz') + expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' }) + }) + + it('rejects a path whose db basename is not opencode*.db', () => { + expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull() + expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull() + }) + + it('rejects a path without a separator', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull() + }) + + it('rejects an empty sessionId', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull() + }) +}) + +describe('looksLikeOpenCodeSqliteCandidate', () => { + it('returns true for a synthetic path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true) + }) + + it('returns false for a real filesystem path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false) + }) +}) + +describe('buildOpenCodeSqliteCandidatePath', () => { + it('joins dbPath and sessionId with #', () => { + expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts new file mode 100644 index 00000000000..fb1415af04e --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -0,0 +1,56 @@ +import { basename } from 'path' + +// Why: keep the synthetic candidate-path helpers separate from the SQLite +// discovery/parser so both the scanner and the agent-parser dispatcher can +// import them without pulling in the SyncDatabase dependency. + +const OPENCODE_SQLITE_PATH_SEPARATOR = '#' + +/** + * Build a synthetic candidate path that encodes the SQLite DB path and session ID + * as `#`. Used by the discovery layer so SQLite-backed + * sessions flow through the same FileWithMtime pipeline as file-backed ones. + * @param dbPath - Absolute path to the opencode.db file. + * @param sessionId - The OpenCode session ID (primary key in the session table). + * @returns The synthetic candidate path string. + */ +export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` +} + +/** + * Parse a synthetic candidate path back into its DB path and session ID parts. + * Validates that the DB basename matches `opencode*.db` so real filesystem paths + * that happen to contain `#` are never misrouted to the SQLite parser. + * @param candidatePath - The synthetic path to parse. + * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. + */ +export function splitOpenCodeSqliteCandidate( + candidatePath: string +): { dbPath: string; sessionId: string } | null { + const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { + return null + } + const dbPath = candidatePath.slice(0, separatorIndex) + const sessionId = candidatePath.slice(separatorIndex + 1) + if (!dbPath || !sessionId) { + return null + } + // Why: OpenCode DB files are named opencode*.db; reject anything else so we + // never misroute a real filesystem path that happens to contain '#'. + if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) { + return null + } + return { dbPath, sessionId } +} + +/** + * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite + * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null). + * @param candidatePath - The path to test. + * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise. + */ +export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { + return splitOpenCodeSqliteCandidate(candidatePath) !== null +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts new file mode 100644 index 00000000000..96ca6ea361a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -0,0 +1,499 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + workspace_id TEXT, + path TEXT, + agent TEXT, + model TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT, + icon_url_override TEXT + ); + `) +} + +function applyMinimalOpenCodeSchema(db: Database.Database): void { + db.exec(`CREATE TABLE session ( + id TEXT PRIMARY KEY, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + );`) +} + +function insertSession( + db: Database.Database, + args: { + id: string + title?: string + directory?: string + timeCreated: number + timeUpdated: number + parentId?: string | null + timeArchived?: number | null + model?: string | null + agent?: string | null + tokensInput?: number + tokensOutput?: number + tokensReasoning?: number + tokensCacheRead?: number + cost?: number + } +): void { + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, time_archived, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0', + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, 0)` + ).run( + args.id, + args.parentId ?? null, + `slug-${args.id}`, + args.directory ?? '/tmp/opencode', + args.title ?? 'OpenCode title', + args.timeCreated, + args.timeUpdated, + args.timeArchived ?? null, + args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }), + args.agent ?? 'build', + args.cost ?? 0, + args.tokensInput ?? 100, + args.tokensOutput ?? 40, + args.tokensReasoning ?? 10, + args.tokensCacheRead ?? 5 + ) +} + +function insertMessage( + db: Database.Database, + args: { + id: string + sessionId: string + role: 'user' | 'assistant' + timeCreated: number + summaryTitle?: string | null + summaryBody?: string | null + } +): void { + const data = JSON.stringify({ + role: args.role, + time: { created: args.timeCreated }, + agent: 'build', + summary: + args.summaryTitle || args.summaryBody + ? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null } + : undefined + }) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?)` + ).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data) +} + +function insertPart( + db: Database.Database, + args: { + id: string + messageId: string + sessionId: string + timeCreated: number + type?: 'text' | 'tool' | 'reasoning' + text?: string + } +): void { + const data = JSON.stringify({ + type: args.type ?? 'text', + text: args.text ?? 'hello world' + }) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data) +} +describe('listOpenCodeSqliteSessions', () => { + it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_old', + title: 'Old', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_new', + title: 'New', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(2) + expect(candidates[0].agent).toBe('opencode') + expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new')) + expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old')) + }) + + it('dedups matching session ids across databases and keeps the newest row', async () => { + const { db: oldDb, path: oldPath } = createTempDb() + applyOpenCodeSchema(oldDb) + insertSession(oldDb, { + id: 'ses_duplicate', + title: 'Old duplicate', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + oldDb.close() + + const { db: newDb, path: newPath } = createTempDb() + applyOpenCodeSchema(newDb) + insertSession(newDb, { + id: 'ses_duplicate', + title: 'New duplicate', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + newDb.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [oldPath, newPath], + limit: 10, + issues: [] + }) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate')) + }) + it('excludes archived and child sessions', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_normal', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_archived', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_002_000, + timeArchived: 1_777_634_002_500 + }) + insertSession(db, { + id: 'ses_child', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_003_000, + parentId: 'ses_normal' + }) + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates.map((c) => c.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, 'ses_normal') + ]) + }) + + it('returns [] when the session table is missing (legacy install)', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates).toEqual([]) + }) + + it('records an issue when the DB file does not exist', async () => { + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: ['/nonexistent/opencode.db'], + limit: 10, + issues + }) + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + expect(issues[0].agent).toBe('opencode') + expect(issues[0].path).toBe('/nonexistent/opencode.db') + }) + + it('lists sessions from a minimal readable session table', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal')) + }) +}) + +describe('parseOpenCodeSqliteSession', () => { + it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_1', + title: 'OpenCode title', + directory: '/tmp/opencode', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + tokensInput: 100, + tokensOutput: 40, + tokensReasoning: 10, + tokensCacheRead: 5, + cost: 0.01 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_1', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryTitle: 'OpenCode title' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_1', + timeCreated: 1_777_634_000_500, + text: 'Plan the work' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_1', + role: 'assistant', + timeCreated: 1_777_634_000_900 + }) + insertPart(db, { + id: 'prt_2', + messageId: 'msg_2', + sessionId: 'ses_1', + timeCreated: 1_777_634_001_000, + text: 'Done' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.agent).toBe('opencode') + expect(session!.sessionId).toBe('ses_1') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode title') + expect(session!.cwd).toBe('/tmp/opencode') + expect(session!.model).toBe('glm-5.2') + expect(session!.totalTokens).toBe(150) + expect(session!.messageCount).toBe(2) + expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString()) + expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString()) + expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'") + expect(session!.previewMessages).toHaveLength(2) + expect(session!.previewMessages[0].text).toBe('Plan the work') + expect(session!.previewMessages[0].role).toBe('user') + expect(session!.previewMessages[1].text).toBe('Done') + expect(session!.previewMessages[1].role).toBe('assistant') + }) + + it('falls back to summary.body for title when session.title is empty', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_2', + title: '', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_2', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryBody: 'fallback title from summary' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_2', + timeCreated: 1_777_634_000_500, + text: 'hello' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_2', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.title).toBe('fallback title from summary') + }) + + it('returns null when the session id is not found', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_real', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_missing', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('returns null when the DB has no session table', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('parses a minimal readable session table without optional columns or messages', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_minimal', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.sessionId).toBe('ses_minimal') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode ses_mini') + expect(session!.cwd).toBeNull() + expect(session!.model).toBeNull() + expect(session!.messageCount).toBe(0) + expect(session!.totalTokens).toBe(0) + expect(session!.previewMessages).toEqual([]) + }) + + it('extracts model from older modelID schema', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_3', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + model: JSON.stringify({ modelID: 'claude-sonnet-4-5' }) + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_3', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.model).toBe('claude-sonnet-4-5') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts new file mode 100644 index 00000000000..f3a6dbcd055 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -0,0 +1,263 @@ +import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import { normalizeTitleText } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: OpenCode 1.17.x migrated session storage from per-session JSON files +// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module +// parses individual sessions from the DB into AiVaultSession objects. The +// discovery layer (listing candidates) lives in +// session-scanner-opencode-sqlite-discovery.ts. + +const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +type PreviewRow = { + role: string | null + part_data: string + time_created: number + summary_title: string | null + summary_body: string | null +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + // Why: belt-and-suspenders guard so a bug in the SELECT list can never + // mutate the user's opencode.db. + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionQuery(db: SyncDatabase): string { + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${sessionColumnSelect(db, 'model')} AS model_json, + ${sessionColumnSelect(db, 'agent')} AS agent, + ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, + ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, + ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, + ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, + ${sessionNumberColumnSelect(db, 'cost')} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE s.id = ? + LIMIT 1` +} + +function extractModelId(modelJson: string | null): string | null { + if (!modelJson) { + return null + } + try { + const parsed = JSON.parse(modelJson) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + // Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}. + // Older schemas used {"modelID":"..."}; accept both. + return ( + (typeof record.id === 'string' && record.id.trim()) || + (typeof record.modelID === 'string' && record.modelID.trim()) || + null + ) + } catch { + return null + } +} + +function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] { + if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') { + return role + } + return 'unknown' +} + +function extractPartText(partData: string): string | null { + try { + const parsed = JSON.parse(partData) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + if (typeof record.text === 'string') { + return record.text + } + return null + } catch { + return null + } +} + +function buildPreviewQuery(db: SyncDatabase): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + return `SELECT json_extract(m.data, '$.role') AS role, + p.data AS part_data, + p.time_created, + json_extract(m.data, '$.summary.title') AS summary_title, + json_extract(m.data, '$.summary.body') AS summary_body + FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') IN ('user','assistant') + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created DESC + LIMIT ?` +} + +/** + * Parse a single OpenCode session from the SQLite database into an + * `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost) + * and up to 5 preview messages by joining the `message` and `part` tables. + * The database is opened read-only with `PRAGMA query_only = ON` as a + * belt-and-suspenders guard against mutations. + * @param args.dbPath - Absolute path to the opencode.db file. + * @param args.sessionId - The session ID (primary key in the `session` table). + * @param args.platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if the session does not exist + * or the database lacks the required schema. + */ +export async function parseOpenCodeSqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + const { dbPath, sessionId, platform } = args + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } + + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) + + const previewSql = buildPreviewQuery(db) + if (previewSql) { + const previewRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[] + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue + } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue + } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } + } + } + + return finalizeSession(accumulator, platform) + } finally { + db?.close() + } +} diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index e5b5f7f371b..b4e376e606d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' import { normalizePiSessionsDir } from './session-scanner-values' @@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) const GROK_SESSIONS_DIR = join( process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), 'sessions' @@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: { ...(options.additionalCodexSessionsDirs ?? []) ]) - return Promise.all([ + return Promise.all([ + // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a + // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) + // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. + ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), @@ -109,7 +110,6 @@ function standardDiscoveries( discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) ), ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...opencodeDiscoveries(options, wslHomeDirs, limit, issues), ...grokDiscoveries(options, wslHomeDirs, limit, issues), ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), @@ -139,21 +139,6 @@ function cursorDiscoveries( ) } -function opencodeDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs( - join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'), - wslHomeDirs, - ['.local', 'share', 'opencode', 'storage', 'session'] - ).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] }) - ) -} - function grokDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index e5330391f0e..fc7cf389b7d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,9 @@ export type AiVaultScanOptions = { copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string + // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB + // here so they don't depend on the real ~/.local/share/opencode. + opencodeDbPaths?: readonly string[] grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index ae0bc7ce106..d8afc36b7ee 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f07a769562d..484dbbc6339 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 +/** + * Scan all supported AI agent session stores and return a unified, sorted, + * deduplicated list of sessions for the AI Vault panel. Discovers sessions + * from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based + * stores (OpenCode 1.17.x). Results are sorted by session sort time DESC + * and truncated to `limit`. + * @param options - Optional scan configuration (limits, custom dirs, platform). + * @returns The list of sessions, scan issues, and a timestamp. + */ export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 83cc7f742d6..aaf998b3ca4 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' +import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { OpenCodeUsageAttributedEvent, @@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -function tableExists(db: Database.Database, tableName: string): boolean { - const row = db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(tableName) as { found?: number } | undefined - return row?.found === 1 -} - -function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] - return rows.some((row) => row.name === columnName) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/main/opencode-usage/schema-helpers.ts b/src/main/opencode-usage/schema-helpers.ts new file mode 100644 index 00000000000..d392eb58d7d --- /dev/null +++ b/src/main/opencode-usage/schema-helpers.ts @@ -0,0 +1,31 @@ +import type SyncDatabase from '../sqlite/sync-database' + +// Why: OpenCode's usage scanner and the AI Vault session scanner both need to +// probe the opencode.db schema shape across multiple DB generations. Centralizing +// the probes here avoids two private copies and keeps the contract testable. +type Database = SyncDatabase.Database + +/** + * Check whether a table exists in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table name to look up in sqlite_master. + * @returns `true` if the table exists, `false` otherwise. + */ +export function tableExists(db: Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +/** + * Check whether a column exists on a table in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table to inspect via PRAGMA table_info. + * @param columnName - The column name to find. + * @returns `true` if the column exists on the table, `false` otherwise. + */ +export function columnExists(db: Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} From 83f49818d31af46ba8375030b57d5bea405eb4cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:31:41 -0700 Subject: [PATCH 38/57] Detect remote agents from shell PATH (#6157) Co-authored-by: Orca --- src/main/ipc/agent-detection-shell-path.ts | 16 ++ src/main/ipc/preflight.test.ts | 59 ++++++ src/main/ipc/preflight.ts | 15 +- src/main/runtime/orca-runtime.test.ts | 28 +-- src/main/runtime/orca-runtime.ts | 5 +- .../runtime/rpc/methods/preflight.test.ts | 10 +- src/main/runtime/rpc/methods/preflight.ts | 4 +- src/relay/preflight-handler.test.ts | 199 +++++++++++++++++- src/relay/preflight-handler.ts | 176 ++++++++++++++-- 9 files changed, 458 insertions(+), 54 deletions(-) create mode 100644 src/main/ipc/agent-detection-shell-path.ts diff --git a/src/main/ipc/agent-detection-shell-path.ts b/src/main/ipc/agent-detection-shell-path.ts new file mode 100644 index 00000000000..42569755037 --- /dev/null +++ b/src/main/ipc/agent-detection-shell-path.ts @@ -0,0 +1,16 @@ +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' + +export async function hydrateShellPathForAgentDetection( + context?: PreflightRuntimeContext +): Promise { + if (getPreflightWslTarget(context)) { + return + } + // Why: remote runtime servers may inherit a sparse daemon/SSH PATH even + // though the user's shell can run the agents. + const hydration = await hydrateShellPath() + if (hydration.ok) { + mergePathSegments(hydration.segments) + } +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 94b969848b2..d3ffef854ef 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({ import { _resetPreflightCache, detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, registerPreflightHandlers, runPreflightCheck } from './preflight' @@ -105,6 +106,7 @@ describe('preflight', () => { handleMock.mockReset() execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() + hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() @@ -575,6 +577,63 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) }) + it('hydrates shell PATH before user-facing agent detection', async () => { + const originalPath = process.env.PATH + process.env.PATH = '/usr/bin' + hydrateShellPathMock.mockResolvedValueOnce({ + segments: ['/home/test/.local/bin'], + ok: true, + failureReason: 'none' + }) + mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => { + process.env.PATH = [...segments, '/usr/bin'].join(':') + return segments + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + try { + await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex']) + } finally { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } + expect(hydrateShellPathMock).toHaveBeenCalledWith() + expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin']) + }) + + it('does not run host shell hydration for WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect( + detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' }) + ).resolves.toEqual(['claude']) + expect(hydrateShellPathMock).not.toHaveBeenCalled() + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 8d65e0acc4b..fbe00db4605 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' +import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) } +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] @@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void { } ) - ipcMain.handle( - 'preflight:detectAgents', - async (_event, args?: PreflightRuntimeContext): Promise => { - return detectInstalledAgents(args) - } + ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentsWithShellPathHydration(args) ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 096c3ecf771..25dfa727a77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -164,7 +164,7 @@ const { addGitHubIssueCommentMock, listGitHubLabelsMock, listGitHubAssignableUsersMock, - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, listGitLabMergeRequestsMock, listGitLabWorkItemsMock, @@ -255,7 +255,7 @@ const { addGitHubIssueCommentMock: vi.fn(), listGitHubLabelsMock: vi.fn(), listGitHubAssignableUsersMock: vi.fn(), - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), @@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({ })) vi.mock('../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void { listGitHubLabelsMock.mockResolvedValue([]) listGitHubAssignableUsersMock.mockReset() listGitHubAssignableUsersMock.mockResolvedValue([]) - detectInstalledAgentsMock.mockReset() - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockReset() + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() detectRemoteAgentsMock.mockResolvedValue([]) listGitLabMergeRequestsMock.mockReset() @@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => { }) it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => { }) it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-fallback-draft', @@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => { }) it('lets explicit startup draft agents override the desktop default', async () => { - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} const runtimeStore = { ...store, @@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => { }) it('does not auto-launch an agent for startup drafts when the default is blank', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() @@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a0ca7efcadf..bd81e59fcc7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -172,7 +172,7 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' +import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -11436,9 +11436,10 @@ export class OrcaRuntimeService { if (!agent) { let detected: string[] = [] try { + // Why: startup-draft fallback can run from sparse runtime launch envs too. detected = repo.connectionId ? await detectRemoteAgents({ connectionId: repo.connectionId }) - : await detectInstalledAgents() + : await detectInstalledAgentsWithShellPathHydration() } catch { detected = [] } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 51a1231caa3..d28b97b8e53 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) vi.mock('../../../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock @@ -46,7 +46,7 @@ describe('preflight RPC methods', () => { }) it('detects agents and refreshes PATH on the server through runtime RPC', async () => { - detectInstalledAgentsMock.mockResolvedValueOnce(['codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({ agents: ['codex', 'claude'], addedPathSegments: ['/opt/bin'], @@ -60,7 +60,7 @@ describe('preflight RPC methods', () => { const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents')) const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents')) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled() expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 720a823cc22..4e0f06201e9 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, - detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../ipc/preflight' @@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ name: 'preflight.detectAgents', params: null, - handler: async () => detectInstalledAgents() + handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b2dfffe8ed2..6d2f9339b9e 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from 'vitest' -import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn() +})) + +vi.mock('child_process', () => { + const execFileWithPromisify = Object.assign(vi.fn(), { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock + }) + return { execFile: execFileWithPromisify } +}) + +import { + buildCommandLookupSpec, + buildCommandLookupSpecs, + hasAbsoluteCommandPath, + isCommandOnPathForRelay +} from './preflight-handler' + +function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { + return [ + mode, + [ + `if resolved=$(command -v ${command} 2>/dev/null); then`, + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'fi' + ].join('\n') + ] +} + +function fishLookupArgs(command: string): string[] { + return [ + '-ilc', + [ + `set -l resolved (command -v ${command} 2>/dev/null)`, + 'if test -n "$resolved"', + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'end' + ].join('\n') + ] +} + +beforeEach(() => { + execFileAsyncMock.mockReset() +}) describe('buildCommandLookupSpec', () => { it('uses where.exe on native Windows SSH hosts', () => { @@ -10,15 +54,160 @@ describe('buildCommandLookupSpec', () => { }) }) - it('passes the command as an argument to the POSIX login-shell probe', () => { - expect(buildCommandLookupSpec('codex', 'linux')).toEqual({ + it('falls back to sh for POSIX probes without a configured shell', () => { + expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: ['-lc', 'command -v "$1"', 'sh', 'codex'] + args: lookupArgs("'codex'") + }) + }) + + it('uses the configured remote shell for POSIX probes', () => { + expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ + file: '/bin/zsh', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('quotes command names in shell probes', () => { + expect( + buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') + ).toEqual({ + file: '/bin/bash', + args: lookupArgs("'agent'\\''cli'", '-ilc') + }) + }) +}) + +describe('buildCommandLookupSpecs', () => { + it('falls back to inherited PATH after a trusted configured POSIX shell', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ + { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows a custom shell path only when the account login shell matches', () => { + expect( + buildCommandLookupSpecs( + 'codex', + 'darwin', + { SHELL: '/opt/homebrew/bin/zsh' }, + '/opt/homebrew/bin/zsh' + ) + ).toEqual([ + { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows conservative system shell paths when account lookup is unavailable', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ + file: '/usr/bin/bash', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('uses fish syntax for trusted fish shells', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({ + file: '/usr/bin/fish', + args: fishLookupArgs("'codex'") + }) + }) + + it('ignores untrusted temp shell paths even when the basename is supported', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('ignores untrusted home-bin shell paths even when the basename is supported', () => { + expect( + buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') + ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + }) +}) + +describe('isCommandOnPathForRelay', () => { + it('falls back to inherited PATH when shell startup returns no absolute command path', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'welcome\ncodex is a function\n' }) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/zsh' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 1, + '/bin/zsh', + lookupArgs("'codex'", '-ilc'), + { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + } + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + }) + + it('falls back to inherited PATH when shell startup fails', async () => { + execFileAsyncMock + .mockRejectedValueOnce(new Error('startup failed')) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not execute an untrusted configured shell before inherited PATH lookup', async () => { + execFileAsyncMock.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/tmp/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/tmp/zsh' }), + timeout: 5000 }) }) }) describe('hasAbsoluteCommandPath', () => { + it('ignores banners and shell function output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe( + false + ) + }) + + it('ignores unmarked POSIX absolute paths from shell startup output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false) + }) + + it('recognizes a sentinel-marked command path amid shell startup and exit output', () => { + expect( + hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux') + ).toBe(true) + }) + it('recognizes Windows absolute command paths', () => { expect( hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index fb47248323d..a0382d77275 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { userInfo } from 'os' import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' @@ -12,6 +13,16 @@ type CommandLookupSpec = { windowsHide?: true } +type RelayCommandLookupOptions = { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + accountLoginShell?: string | null +} + +const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) +const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) +const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' + export class PreflightHandler { private dispatcher: RelayDispatcher @@ -43,35 +54,77 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } - // Why: SSH exec channels give the relay a minimal environment without - // .zprofile/.bash_profile sourced. Running `which` directly would miss - // agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login - // shell (`-lc`) ensures PATH matches what the user's PTY sessions see. - // Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there. + // Why: SSH exec channels give the relay a minimal environment without shell + // startup files sourced. Ask the user's configured shell so agent dirs added + // by zsh/bash/fish startup hooks match the remote terminal experience. + // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. private async isCommandOnPath(command: string): Promise { - try { - const spec = buildCommandLookupSpec(command, process.platform) - const { stdout } = await execFileAsync(spec.file, spec.args, { - encoding: 'utf-8', - env: buildRelayCommandEnv(), - timeout: 5000, - ...(spec.windowsHide ? { windowsHide: true } : {}) - }) - return hasAbsoluteCommandPath(stdout, process.platform) - } catch { - return false - } + return isCommandOnPathForRelay(command) } } export function buildCommandLookupSpec( command: string, - platform: NodeJS.Platform + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null ): CommandLookupSpec { + const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell) + return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh') +} + +export function buildCommandLookupSpecs( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null +): CommandLookupSpec[] { if (platform === 'win32') { - return { file: 'where.exe', args: [command], windowsHide: true } + return [{ file: 'where.exe', args: [command], windowsHide: true }] } - return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] } + const trustedShell = pickTrustedPosixShell( + env, + resolveAccountLoginShell(platform, accountLoginShell) + ) + const specs: CommandLookupSpec[] = [] + + if (trustedShell) { + specs.push(buildPosixCommandLookupSpec(command, trustedShell)) + } + + const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh') + if (!trustedShell || trustedShell !== inheritedPathSpec.file) { + specs.push(inheritedPathSpec) + } + + return specs +} + +export async function isCommandOnPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) + + for (const spec of specs) { + try { + const { stdout } = await execFileAsync(spec.file, spec.args, { + encoding: 'utf-8', + env: buildRelayCommandEnv(env, platform), + timeout: 5000, + ...(spec.windowsHide ? { windowsHide: true } : {}) + }) + if (hasAbsoluteCommandPath(stdout, platform)) { + return true + } + } catch { + // Try the inherited-PATH fallback before reporting the agent missing. + } + } + + return false } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { @@ -79,5 +132,86 @@ export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform return output .split(/\r?\n/) .map((line) => line.trim()) - .some((line) => pathOps.isAbsolute(line)) + .some((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) + }) +} + +function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { + const shellName = path.posix.basename(shell).toLowerCase() + if (shellName === 'fish') { + return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] } + } + return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] } +} + +function buildShCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'fi' + ].join('\n') +} + +function buildFishCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `set -l resolved (command -v ${quotedCommand} 2>/dev/null)`, + 'if test -n "$resolved"', + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'end' + ].join('\n') +} + +function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} + +function pickTrustedPosixShell( + env: NodeJS.ProcessEnv, + accountLoginShell: string | null +): string | null { + const shell = env.SHELL + if (!shell || !path.posix.isAbsolute(shell)) { + return null + } + const shellName = path.posix.basename(shell).toLowerCase() + if (!SUPPORTED_POSIX_SHELLS.has(shellName)) { + return null + } + if (accountLoginShell) { + return shell === accountLoginShell ? shell : null + } + return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null +} + +function getShellCommandMode(shell: string): '-lc' | '-ilc' { + const shellName = path.posix.basename(shell).toLowerCase() + // Why: bash/zsh/fish users commonly add package-manager bins from interactive + // startup files. POSIX sh/dash may not support interactive login flags. + return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc' +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` } From b8becaae39ad19fa77eaa21d6387b394b3591b55 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:08:19 -0700 Subject: [PATCH 39/57] Remove active-worktree gating from restored session visibility (#6185) Co-authored-by: Orca --- .../src/lib/worktree-runtime-owner.test.ts | 43 ++++ .../src/lib/worktree-runtime-owner.ts | 29 +++ .../src/runtime/web-session-tabs-sync.test.ts | 8 +- .../src/runtime/web-session-tabs-sync.ts | 216 +++++++++--------- .../slices/store-session-cascades.test.ts | 81 ++++++- src/renderer/src/store/slices/terminals.ts | 62 ++--- 6 files changed, 297 insertions(+), 142 deletions(-) diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 849cb4dfa34..35bb8eb522c 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -3,6 +3,7 @@ import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds, getSettingsForWorktreeRuntimeOwner, type WorktreeRuntimeOwnerState } from './worktree-runtime-owner' @@ -118,3 +119,45 @@ describe('getExplicitRuntimeEnvironmentIdForWorktree', () => { ).toBe('runtime:worktree-env') }) }) + +describe('getRuntimeSessionMirrorEnvironmentIds', () => { + it('includes focused runtime plus explicit repo, worktree, and folder owners', () => { + const multiRuntimeState: WorktreeRuntimeOwnerState = { + ...state, + worktreesByRepo: { + ...state.worktreesByRepo, + 'runtime-repo': [ + ...(state.worktreesByRepo?.['runtime-repo'] ?? []), + { + id: 'runtime-repo::wt-runtime-override', + repoId: 'runtime-repo', + hostId: 'runtime:worktree-env' + } + ] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(multiRuntimeState)).toEqual([ + 'focused-env', + 'folder-env', + 'owner-env', + 'worktree-env' + ]) + }) + + it('does not include local or SSH owners', () => { + const localOnlyState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'ssh-repo', connectionId: 'remote', executionHostId: 'ssh:remote' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-local', repoId: 'local-repo', hostId: 'local' }], + 'ssh-repo': [{ id: 'ssh-repo::wt-ssh', repoId: 'ssh-repo', hostId: 'ssh:remote' }] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e8c0e50f795..be54a7b9eb2 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -162,6 +162,35 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) } +export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwnerState): string[] { + const ids = new Set() + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + if (activeRuntimeEnvironmentId) { + ids.add(activeRuntimeEnvironmentId) + } + for (const repo of state.repos ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) + if (environmentId) { + ids.add(environmentId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + const environmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree.hostId) + if (environmentId) { + ids.add(environmentId) + } + } + } + for (const group of state.projectGroups ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(group.executionHostId) + if (environmentId) { + ids.add(environmentId) + } + } + return [...ids].sort() +} + export function getExecutionHostIdForWorktree( state: WorktreeRuntimeOwnerState, worktreeId: string | null | undefined diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 90293c7ec7f..687b18e24fa 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -306,19 +306,17 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(false) }) - it('only starts the all-session mirror for paired web clients', () => { + it('starts the all-session mirror for desktop and paired web clients', () => { expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: true + workspaceSessionReady: true }) ).toBe(true) expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: false + workspaceSessionReady: false }) ).toBe(false) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 43fc267001c..8ec5c326daf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -32,7 +32,10 @@ import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds +} from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, HOST_TERMINAL_SURFACE_SEPARATOR, @@ -255,10 +258,9 @@ export function shouldSyncRuntimeSessionTabs(args: { export function shouldSyncAllRuntimeSessionTabs(args: { activeRuntimeEnvironmentId: string | null | undefined workspaceSessionReady: boolean - isWebClient: boolean }): boolean { const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient) + return Boolean(environmentId && args.workspaceSessionReady) } export function resetWebSessionTabsSnapshotFreshnessForTests(): void { @@ -2328,134 +2330,142 @@ export function applyFreshWebSessionTabsSnapshots( export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const runtimeSessionMirrorEnvironmentKey = useAppStore((state) => + getRuntimeSessionMirrorEnvironmentIds(state).join('\u0000') ) const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true useEffect(() => { - const environmentId = activeRuntimeEnvironmentId?.trim() + const environmentIds = runtimeSessionMirrorEnvironmentKey + ? runtimeSessionMirrorEnvironmentKey.split('\u0000').filter((id) => id.trim()) + : [] // Why: startup hydration writes browser-local session state; applying the // host snapshot before that point gets clobbered and leaves the sidebar stale. - // Desktop clients should not mirror every remote session just because a - // remote is connected; project discovery runs through separate repo APIs. - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId, - workspaceSessionReady, - isWebClient - }) || - !environmentId - ) { + // Selectedness is not liveness: desktop and web clients both mirror the + // runtime's session bindings so background worktrees do not look asleep. + if (!workspaceSessionReady || environmentIds.length === 0) { return } let disposed = false - let unsubscribe: (() => void) | null = null + const unsubscribes: (() => void)[] = [] // Why: the streaming RPC emits an initial snapshots event, but startup can // render a paired web session before that event is applied. A one-shot // fetch makes initial parity deterministic; the stream remains the live // update path afterward. - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000 - }) - .then((response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) - ) - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - - void window.api.runtimeEnvironments - .subscribe( - { + for (const environmentId of environmentIds) { + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + void window.api.runtimeEnvironments + .call({ selector: environmentId, - method: 'session.tabs.subscribeAll', + method: 'session.tabs.listAll', params: {}, timeoutMs: 15_000 - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] global subscription failed:', - response.error.message - ) - return - } - const event = response.result as SessionTabsStreamEvent - if (event.type === 'snapshots') { - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) - ) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshot(state, event, environmentId) - ) - }, - onError: (error) => { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) + }) + .then((response: RuntimeRpcResponse) => { + if (disposed) { + return } - } - ) - .then((handle) => { - if (disposed) { - handle.unsubscribe() - return - } - unsubscribe = handle.unsubscribe - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) ) - } - }) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response: RuntimeRpcResponse) => { + if (disposed) { + return + } + if (response.ok === false) { + console.warn( + '[web-session-tabs-sync] global subscription failed:', + response.error.message + ) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type === 'snapshots') { + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) + ) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshot(state, event, environmentId) + ) + }, + onError: (error) => { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + ) + .then((handle) => { + if (disposed) { + handle.unsubscribe() + return + } + unsubscribes.push(handle.unsubscribe) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + } + }) + } return () => { disposed = true - unsubscribe?.() + for (const unsubscribe of unsubscribes) { + unsubscribe() + } // Why: environment ids can churn as paired runtimes reconnect or switch; // stale freshness/mapping entries should not live for the renderer lifetime. - clearWebSessionTabsTrackingForEnvironment(environmentId) + for (const environmentId of environmentIds) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } } - }, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady]) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) useEffect(() => { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index da3524afdcf..ed21ca8906c 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -101,6 +101,8 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test-helpers' +import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' // ─── Helpers ────────────────────────────────────────────────────────── @@ -1490,7 +1492,7 @@ describe('reconnectPersistedTerminals', () => { [wt2]: [makeTab({ id: 'tab2', worktreeId: wt2, ptyId: 'old-pty-2' })] }, terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() }, - activeWorktreeIdsOnShutdown: [wt1, wt2] + activeWorktreeIdsOnShutdown: [wt1] }) expect(store.getState().workspaceSessionReady).toBe(false) @@ -1508,9 +1510,22 @@ describe('reconnectPersistedTerminals', () => { expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - // Why: inactive worktrees keep a wake hint but must not advertise live PTYs - // until the user opens them and connectPanePty performs the actual reattach. - expect(s.ptyIdsByTabId.tab2).toEqual([]) + expect(s.ptyIdsByTabId.tab2).toEqual(['old-pty-2']) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1, wt2]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1801,6 +1816,64 @@ describe('reconnectPersistedTerminals', () => { expect(Object.values(bindings).sort()).toEqual(['daemon-session-A', 'daemon-session-B']) expect(s.workspaceSessionReady).toBe(true) }) + + it('advertises split-pane-only restored sessions for hide-sleeping visibility', async () => { + const store = createDaemonEnabledStore() + const wt1 = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: null })] + }, + terminalLayoutsByTabId: { + tab1: { + ...makeLayout(), + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:3' } + }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A', 'pane:3': 'daemon-session-B' } + } + }, + activeWorktreeIdsOnShutdown: [] + }) + + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + expect(s.ptyIdsByTabId.tab1?.sort()).toEqual(['daemon-session-A', 'daemon-session-B']) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1]) + }) }) describe('hydrateEditorSession', () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e235f65673f..7f62c0891ce 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,15 +2595,22 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - // Why: fall back to deriving the list from tabsByWorktree ptyIds when - // activeWorktreeIdsOnShutdown is absent (upgrade from older build). - // The raw tabs still carry ptyId values before clearTransientTerminalState - // nulls them, so we can infer which worktrees had active terminals. - const shutdownIds = - session.activeWorktreeIdsOnShutdown ?? - Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some((t) => t.ptyId)) - .map(([wId]) => wId) + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} + // Why: activeWorktreeIdsOnShutdown is only the eager-remount hint. Any + // tab with a persisted PTY or relay session can still reattach when + // opened, so it must also advertise liveness to Hide sleeping. + const tabHasRestorableSession = (tab: TerminalTab): boolean => { + const leafPtyIds = Object.values( + session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {} + ).filter(Boolean) + return Boolean(tab.ptyId || remoteSessionIds[tab.id] || leafPtyIds.length > 0) + } + const sessionBackedWorktreeIds = Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some(tabHasRestorableSession)) + .map(([wId]) => wId) + const shutdownIds = Array.from( + new Set([...(session.activeWorktreeIdsOnShutdown ?? []), ...sessionBackedWorktreeIds]) + ) const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) // Why: capture which specific tabs had live PTYs per worktree from the @@ -2613,12 +2620,11 @@ export const createTerminalSlice: StateCreator // Also include tabs whose relay session IDs were preserved in // remoteSessionIdsByTabId — those tabs were disconnected before shutdown // (ptyId was null) but the relay still has their PTY alive. - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} const pendingReconnectTabByWorktree: Record = {} for (const worktreeId of pendingReconnectWorktreeIds) { const rawTabs = session.tabsByWorktree[worktreeId] ?? [] const liveTabIds = rawTabs - .filter((t) => (t.ptyId || remoteSessionIds[t.id]) && validTabIds.has(t.id)) + .filter((t) => tabHasRestorableSession(t) && validTabIds.has(t.id)) .map((t) => t.id) if (liveTabIds.length > 0) { pendingReconnectTabByWorktree[worktreeId] = liveTabIds @@ -2794,8 +2800,7 @@ export const createTerminalSlice: StateCreator pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, terminalLayoutsByTabId, - tabsByWorktree, - activeWorktreeId + tabsByWorktree } = get() const ids = pendingReconnectWorktreeIds ?? [] @@ -2868,16 +2873,17 @@ export const createTerminalSlice: StateCreator console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) - if (tabLevelPtyId) { - const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId + if (tabLevelPtyId || hasLeafMappings) { set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) + if (tabLevelPtyId) { + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) + } // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2885,20 +2891,16 @@ export const createTerminalSlice: StateCreator // appear as orphans until the terminal pane mounts. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : [tabLevelPtyId] + : [tabLevelPtyId!] return { tabsByWorktree: next, - ...(shouldAdvertiseLivePtys - ? { - // Why: inactive worktrees only need wake hints. Publishing - // their PTYs as live at startup starts global session/status - // machinery before the user opens that workspace. - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: allPtyIds - } - } - : {}) + // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. + // Restored daemon sessions are still running even before their + // pane remounts, so background workspaces must advertise them. + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: allPtyIds + } } }) } From dd421a054bf7c725fbcc7c150f2b6a2688d47f1b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:19:15 -0700 Subject: [PATCH 40/57] Fix Codex session history titles from injected context (#6151) Co-authored-by: Orca --- .../ai-vault/session-scanner-agent-parser.ts | 7 +- .../session-scanner-codex-parser.test.ts | 88 ++++++ .../ai-vault/session-scanner-codex-parser.ts | 273 ++++++++++++++++++ .../session-scanner-primary-parsers.ts | 139 +-------- .../session-scanner-text-normalization.ts | 2 +- src/main/ai-vault/session-scanner.test.ts | 13 +- 6 files changed, 376 insertions(+), 146 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-codex-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-codex-parser.ts diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 385b2961984..adfb2aa86db 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -9,11 +9,8 @@ import { import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' -import { - parseClaudeSessionFile, - parseCodexSessionFile, - parseGeminiSessionFile -} from './session-scanner-primary-parsers' +import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers' +import { parseCodexSessionFile } from './session-scanner-codex-parser' import { parseCopilotSessionFile, parseCursorSessionFile, diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts new file mode 100644 index 00000000000..19581fc8761 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexSessionFile } from './session-scanner-codex-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('parseCodexSessionFile', () => { + it('does not double-count usage when token count formats switch', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'token-format-switch', cwd: '/repo/app' } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Measure Codex token totals' }] + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 70, + cached_input_tokens: 20, + output_tokens: 30, + total_tokens: 100 + } + } + } + }, + { + timestamp: '2026-06-18T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 90, + cached_input_tokens: 25, + output_tokens: 60, + total_tokens: 150 + } + } + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session?.totalTokens).toBe(150) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts new file mode 100644 index 00000000000..5435b2eaf1c --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -0,0 +1,273 @@ +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types' +import { + asRecord, + extractContentText, + extractGitBranch, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage +} from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +type CodexSessionIndexTitleCacheEntry = { + signature: string + titles: Map +} + +const codexSessionIndexTitleCache = new Map>() + +export async function parseCodexSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + codexHome: string | null = null +): Promise { + 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) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes internal worker/sub-agent transcripts into the same + // history tree; AI Vault should show user-started sessions only. + return null + } + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const indexedTitle = + extractCodexSessionMetadataTitle(payload) ?? + (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + if (indexedTitle) { + accumulator.title = indexedTitle + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + continue + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + continue + } + + if (!payload) { + continue + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + continue + } + + if (record.type !== 'event_msg') { + continue + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + continue + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + continue + } + + if (payload.type !== 'token_count') { + continue + } + + const info = asRecord(payload.info) + if (!info) { + continue + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, previousTotals) + previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + } + + return finalizeSession(accumulator, platform, { codexHome }) +} + +function addCodexUsage( + base: CodexUsageSnapshot, + increment: CodexUsageSnapshot +): CodexUsageSnapshot { + return { + inputTokens: base.inputTokens + increment.inputTokens, + cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, + outputTokens: base.outputTokens + increment.outputTokens, + reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, + totalTokens: base.totalTokens + increment.totalTokens + } +} + +function extractCodexThreadSource(payload: Record): string | null { + return extractString(payload.thread_source) ?? extractString(payload.threadSource) +} + +function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractCodexThreadSource(payload) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + + const source = asRecord(payload.source) + return Boolean(asRecord(source?.subagent)) +} + +function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} + +async function readCodexSessionIndexTitle( + sessionFilePath: string, + codexHome: string | null, + sessionId: string +): Promise { + const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) + if (!resolvedCodexHome) { + return null + } + const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) + return titleBySessionId.get(sessionId) ?? null +} + +function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { + let currentDir = dirname(sessionFilePath) + while (currentDir && dirname(currentDir) !== currentDir) { + if (basename(currentDir) === 'sessions') { + return dirname(currentDir) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCodexSessionIndexTitles(codexHome: string): Promise> { + const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) + let signature: string + try { + const indexStat = await stat(indexPath) + signature = `${indexStat.size}:${indexStat.mtimeMs}` + } catch { + return new Map() + } + + const cached = codexSessionIndexTitleCache.get(codexHome) + if (cached) { + const entry = await cached + if (entry.signature === signature) { + return entry.titles + } + } + + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ + signature, + titles + })) + codexSessionIndexTitleCache.set(codexHome, pending) + return (await pending).titles +} + +async function readCodexSessionIndexTitlesFromDisk( + indexPath: string +): Promise> { + const titleBySessionId = new Map() + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + 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 creates the index opportunistically; older homes may only have raw transcripts. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index fced9ab1f6a..dc062f5ab3e 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'fs' import { readFile } from 'fs/promises' import { createInterface } from 'readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, createAccumulator, @@ -16,14 +16,10 @@ import { asRecord, claudeUsageTotal, extractContentText, - extractGitBranch, extractMessageText, - extractModel, extractString, - normalizeCodexUsage, normalizeTitleText, parseJsonObject, - subtractCodexUsage, tokenTotal } from './session-scanner-values' @@ -99,139 +95,6 @@ export async function parseClaudeSessionFile( return finalizeSession(accumulator, platform) } -export async function parseCodexSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform, - codexHome: string | null = null -): Promise { - 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) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - return null - } - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - continue - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - continue - } - - if (!payload) { - continue - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - continue - } - - if (record.type !== 'event_msg') { - continue - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - continue - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - continue - } - - if (payload.type !== 'token_count') { - continue - } - - const info = asRecord(payload.info) - if (!info) { - continue - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage - if (totalUsage) { - previousTotals = totalUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - } - - return finalizeSession(accumulator, platform, { codexHome }) -} - -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - export async function parseGeminiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 8bd58ad6407..88eae73eb2e 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024 const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512 const FIELD_BLANK_SCAN_LIMIT = 1024 -const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for' +const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions' const XML_INSTRUCTIONS_PREFIX = '' const HIDDEN_TEXT_BLOCKS = [ diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d8afc36b7ee..a4c5cf6540f 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -113,7 +113,7 @@ describe('scanAiVaultSessions', () => { type: 'message', role: 'user', content: [ - { type: 'text', text: '# AGENTS.md instructions for /repo/app ' } + { type: 'text', text: '# AGENTS.md instructions\n\nrepo policy' } ] } }), @@ -165,6 +165,15 @@ describe('scanAiVaultSessions', () => { }) ].join('\n') ) + await writeFile( + join(root, 'session_index.jsonl'), + jsonLines([ + { + id: '019f0000-1111-7222-8333-444444444444', + thread_name: 'Indexed Codex resume picker title' + } + ]) + ) const result = await scanAiVaultSessions({ ...roots, @@ -174,7 +183,7 @@ describe('scanAiVaultSessions', () => { expect(result.issues).toEqual([]) expect(result.sessions).toHaveLength(2) expect(result.sessions.map((session) => session.title).sort()).toEqual([ - 'Fix the resume picker filters', + 'Indexed Codex resume picker title', 'Vault polish pass' ]) From ab04b1b99b21e8dc287890e1bfc0854f5c2078d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:20:18 +0000 Subject: [PATCH 41/57] release: v1.4.94-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad72559af63..55ea1c9f61d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.0", + "version": "1.4.94-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0797966d392627c220b27a0822e00f076c809eb7 Mon Sep 17 00:00:00 2001 From: AJ <60591772+devajmeireles@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:50:00 -0300 Subject: [PATCH 42/57] Preserve unsaved diff-note draft on outside click (#6188) * Preserve unsaved diff-note draft on outside click Clicking outside the diff-note popover while it held unsaved text discarded the draft. Dismiss-on-outside-click now only fires when the draft is empty or whitespace-only; with content the popover stays open so an accidental click can't lose an in-progress note. Escape and the Cancel button still dismiss explicitly. Mirror the draft into a ref so the document mousedown listener (empty dependency array) reads the freshest body, matching the existing onCancelRef/topRef pattern in the component. * Preserve large diff-note drafts on outside click Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../diff-comments/DiffCommentPopover.tsx | 15 +++ ...iff-comment-popover-outside-click.test.tsx | 125 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx index efe3ec78b42..51b376ade15 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -34,6 +34,10 @@ type Props = { onSubmit: (body: string) => Promise } +function hasDraftText(body: string): boolean { + return /\S/u.test(body) +} + export function DiffCommentPopover({ lineNumber, startLine, @@ -48,6 +52,12 @@ export function DiffCommentPopover({ onSubmit }: Props): React.JSX.Element { const [body, setBody] = useState('') + // Why: mirror the draft into a ref so the document mousedown listener (empty + // dep array, see below) can read the freshest body without re-registering on + // every keystroke. Used to keep the popover open on outside-click when the + // user has typed something, so an accidental click never discards a draft. + const bodyRef = useRef(body) + bodyRef.current = body // Why: `submitting` prevents duplicate note rows when the user // double-clicks the Add note button or hits Enter twice before the // IPC round-trip resolves. Iteration 1 made submission async and keeps the @@ -144,6 +154,11 @@ export function DiffCommentPopover({ if (popoverRef.current.contains(ev.target as Node)) { return } + // Why: outside-click is a soft dismiss, so preserve any non-whitespace + // draft even when submit's bounded scanner would reject it as too large. + if (hasDraftText(bodyRef.current)) { + return + } // Why: read the latest onCancel from the ref rather than closing over it // so the listener does not need to be re-registered on every parent // render (see onCancelRef comment above). diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx b/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx new file mode 100644 index 00000000000..a74e737fbe4 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx @@ -0,0 +1,125 @@ +// @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 { COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES } from '@/lib/comment-body-submit-state' +import { DiffCommentPopover } from './DiffCommentPopover' + +const roots: Root[] = [] + +async function renderPopover(args: { + onCancel: () => void + onSubmit?: (body: string) => Promise +}): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render( + {})} + /> + ) + }) + + return container +} + +// Why: the textarea is a controlled React input, so writing `.value` directly is +// invisible to React. Drive the change through the native value setter + an +// 'input' event the way React's synthetic onChange listens for it. +function typeInDraft(container: HTMLElement, value: string): void { + const textarea = container.querySelector('textarea') + if (!textarea) { + throw new Error('textarea not found') + } + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(textarea, value) + textarea.dispatchEvent(new Event('input', { bubbles: true })) +} + +async function clickOutside(): Promise { + await act(async () => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + }) +} + +describe('DiffCommentPopover outside-click draft preservation', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + vi.clearAllMocks() + }) + + it('keeps the popover open on outside-click when the draft has content', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, 'unsaved note') + }) + await clickOutside() + + expect(onCancel).not.toHaveBeenCalled() + }) + + it('dismisses on outside-click when the draft is empty', async () => { + const onCancel = vi.fn() + await renderPopover({ onCancel }) + + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('dismisses on outside-click when the draft is only whitespace', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, ' ') + }) + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('keeps the popover open when non-whitespace text follows large leading whitespace', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, `${' '.repeat(COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES + 1)}note`) + }) + await clickOutside() + + expect(onCancel).not.toHaveBeenCalled() + }) + + it('still dismisses after the user clears a draft back to empty', async () => { + const onCancel = vi.fn() + const container = await renderPopover({ onCancel }) + + await act(async () => { + typeInDraft(container, 'typed then deleted') + }) + await act(async () => { + typeInDraft(container, '') + }) + await clickOutside() + + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) From ffa80eb5bb1f868cd93ce9a57f103f7ee527c82c Mon Sep 17 00:00:00 2001 From: Ricardo Sawir <37329575+sawirricardo@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:00:09 +0700 Subject: [PATCH 43/57] Fix Windows directory cwd prefixes (#6116) --- src/main/providers/windows-shell-args.test.ts | 13 +++++++++++++ src/main/providers/windows-shell-args.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index c9f6d1a470a..e7e8770a89c 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -214,6 +214,19 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\project') }) + it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected sh cmd must not break out of the surrounding single quotes diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 826b672d271..0e3d181fdd4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -136,6 +136,7 @@ export function resolveWindowsShellLaunchArgs( ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() const nativeCwd = normalizeMsysDrivePath(cwd) + const isMsysDriveCwd = nativeCwd !== cwd if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -183,7 +184,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, From b4d2e9810d9b1a6c722513e74595889a2ae317e2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:12:47 -0700 Subject: [PATCH 44/57] Restore pre-culprit workspace liveness boundary (#6196) Co-authored-by: Orca --- .../slices/store-session-cascades.test.ts | 17 +++++---- src/renderer/src/store/slices/terminals.ts | 37 +++++++------------ 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index ed21ca8906c..d2f93e73ab4 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1498,7 +1498,7 @@ describe('reconnectPersistedTerminals', () => { expect(store.getState().workspaceSessionReady).toBe(false) expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() expect(store.getState().tabsByWorktree[wt2][0].ptyId).toBeNull() - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1, wt2]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) await store.getState().reconnectPersistedTerminals() @@ -1508,9 +1508,9 @@ describe('reconnectPersistedTerminals', () => { // records daemon session IDs as tab-level ptyIds so connectPanePty // can pass them as sessionId to the daemon's createOrAttach. expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') - expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') + expect(s.tabsByWorktree[wt2][0].ptyId).toBeNull() expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - expect(s.ptyIdsByTabId.tab2).toEqual(['old-pty-2']) + expect(s.ptyIdsByTabId.tab2).toEqual([]) expect( computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { filterRepoIds: [], @@ -1525,7 +1525,7 @@ describe('reconnectPersistedTerminals', () => { defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {} }) - ).toEqual([wt1, wt2]) + ).toEqual([wt1]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1817,7 +1817,7 @@ describe('reconnectPersistedTerminals', () => { expect(s.workspaceSessionReady).toBe(true) }) - it('advertises split-pane-only restored sessions for hide-sleeping visibility', async () => { + it('does not advertise split-pane-only wake hints as hide-sleeping activity', async () => { const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' @@ -1852,12 +1852,13 @@ describe('reconnectPersistedTerminals', () => { activeWorktreeIdsOnShutdown: [] }) - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([]) await store.getState().reconnectPersistedTerminals() const s = store.getState() - expect(s.ptyIdsByTabId.tab1?.sort()).toEqual(['daemon-session-A', 'daemon-session-B']) + expect(s.terminalLayoutsByTabId.tab1?.ptyIdsByLeafId).toBeDefined() + expect(s.ptyIdsByTabId.tab1).toEqual([]) expect( computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { filterRepoIds: [], @@ -1872,7 +1873,7 @@ describe('reconnectPersistedTerminals', () => { defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {} }) - ).toEqual([wt1]) + ).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7f62c0891ce..b99b248a503 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,22 +2595,14 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} - // Why: activeWorktreeIdsOnShutdown is only the eager-remount hint. Any - // tab with a persisted PTY or relay session can still reattach when - // opened, so it must also advertise liveness to Hide sleeping. - const tabHasRestorableSession = (tab: TerminalTab): boolean => { - const leafPtyIds = Object.values( - session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {} - ).filter(Boolean) - return Boolean(tab.ptyId || remoteSessionIds[tab.id] || leafPtyIds.length > 0) - } - const sessionBackedWorktreeIds = Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some(tabHasRestorableSession)) - .map(([wId]) => wId) - const shutdownIds = Array.from( - new Set([...(session.activeWorktreeIdsOnShutdown ?? []), ...sessionBackedWorktreeIds]) - ) + // Why: match the pre-idle-runtime-optimization startup contract. + // activeWorktreeIdsOnShutdown is authoritative when present; persisted + // tab/layout PTY IDs are wake hints, not a broader active-workspace list. + const shutdownIds = + session.activeWorktreeIdsOnShutdown ?? + Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some((t) => t.ptyId)) + .map(([wId]) => wId) const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) // Why: capture which specific tabs had live PTYs per worktree from the @@ -2620,11 +2612,12 @@ export const createTerminalSlice: StateCreator // Also include tabs whose relay session IDs were preserved in // remoteSessionIdsByTabId — those tabs were disconnected before shutdown // (ptyId was null) but the relay still has their PTY alive. + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} const pendingReconnectTabByWorktree: Record = {} for (const worktreeId of pendingReconnectWorktreeIds) { const rawTabs = session.tabsByWorktree[worktreeId] ?? [] const liveTabIds = rawTabs - .filter((t) => tabHasRestorableSession(t) && validTabIds.has(t.id)) + .filter((t) => (t.ptyId || remoteSessionIds[t.id]) && validTabIds.has(t.id)) .map((t) => t.id) if (liveTabIds.length > 0) { pendingReconnectTabByWorktree[worktreeId] = liveTabIds @@ -2873,17 +2866,12 @@ export const createTerminalSlice: StateCreator console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) - if (tabLevelPtyId || hasLeafMappings) { + if (tabLevelPtyId) { set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - if (tabLevelPtyId) { - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) - } // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2892,6 +2880,9 @@ export const createTerminalSlice: StateCreator const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) : [tabLevelPtyId!] + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) return { tabsByWorktree: next, // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. From 02e5464d2a78419170f928829402b6ba7d76cf9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:13:45 +0000 Subject: [PATCH 45/57] release: v1.4.94-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55ea1c9f61d..1960a32566d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.1", + "version": "1.4.94-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From eb650d6cb8409416e685bf793661ac75aa5f0673 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:22:51 -0700 Subject: [PATCH 46/57] Make mobile pairing QR codes larger (#6192) Co-authored-by: Orca --- src/renderer/src/assets/mobile-page.css | 14 ++++++++++++-- src/renderer/src/components/mobile/MobileHero.tsx | 4 ++-- 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 ++- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 24ab4c3425b..df32f64ccae 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -557,6 +557,15 @@ height: 116px; } +.mobile-page-root .mp-qr-large { + padding: 10px; +} + +.mobile-page-root .mp-qr-large img { + width: 164px; + height: 164px; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -704,8 +713,9 @@ } .mobile-page-root .mp-network-select { - min-width: 220px; - max-width: 280px; + flex: 1 1 180px; + min-width: 0; + max-width: 240px; } .mobile-page-root .mp-network-refresh { diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 43409635549..1d7ac73c013 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -121,7 +121,7 @@ export function HeroFlow({
Date: Tue, 23 Jun 2026 13:37:17 -0700 Subject: [PATCH 47/57] feat: floating workspace shortcuts and tab shortcut affordances (#6077) * feat: add floating workspace shortcuts * test: shim localStorage in happy-dom tests * refactor: tighten floating shortcut tests and docs - Drop unrelated localStorage test shim (local Node 25 only; CI runs Node 24) - Add why-comments for minify/minimize macOS-only and unbound defaults - Assert minify bounds via getMinifiedFloatingTerminalBounds() not magic numbers - Cover out-of-range tab index and no-active-tab rename edge cases * test: cover custom floating shortcut bindings on Linux/Windows Proves user-assigned overrides for floatingWorkspace.maximize/minify fire on platforms where the default bindings are intentionally empty. * feat: show keybinding hints in floating panel control tooltips Maximize/restore and minimize tooltips now surface their live shortcut as "Label (shortcut)", matching the repo convention (e.g. ReviewPRViewAnimatedVisual). Degrades to a bare label when the action is unbound (Win/Linux defaults, and minimize on every platform). * feat: rework floating panel resize shortcut to Cmd+Opt+Shift+A - Move maximize/restore default from Cmd+Opt+Up to Cmd+Opt+Shift+A so it shares the Cmd+Opt+A toggle anchor and stays one-handed. - Open-into-maximized: pressing the chord while the panel is closed opens it straight into the maximized state (one-shot intent bridges the closed->mounted gap), regardless of last saved size. While open it remains a maximize<->restore toggle returning to saved dimensions. - Drop the minify action and its Cmd+Opt+Down default entirely; it was introduced earlier in this PR and is not in main, so this trims unshipped surface rather than removing an existing feature. Maximize tooltip now surfaces the new chord. Adds panel tests for the open-maximized intent and updates keybinding default tests. * feat: surface tab shortcut affordances * refactor: address 5.5 Pro review on tab shortcut affordances - Add Darwin Option-composed (Cmd+Opt+Shift+A) maximize capture/round-trip regression - Replace 'Unassigned' string-sentinel checks with useOptionalShortcutLabel (string | null) - Add TTL guard + unit tests so a stale open-maximized intent cannot leak into a later open * feat: show default agent shortcut in new tab menu * fix: address PR review on tab tooltip a11y, i18n, and test guards * Fix floating workspace shortcut edge cases Co-authored-by: Orca --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/renderer/src/App.tsx | 24 +- .../FloatingTerminalPanel.test.tsx | 465 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 206 ++++++-- .../FloatingTerminalWindowControls.tsx | 38 +- .../src/components/tab-bar/EditorFileTab.tsx | 29 +- .../tab-bar/EditorFileTabCloseButton.tsx | 56 +++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 52 +- .../tab-bar/EditorFileTabContextMenu.tsx | 11 +- .../tab-bar/QuickLaunchButton.test.ts | 155 +++++- .../components/tab-bar/QuickLaunchButton.tsx | 11 +- .../SortableTab.rename-shortcut.test.tsx | 10 +- .../src/components/tab-bar/SortableTab.tsx | 84 ++-- .../tab-bar/SortableTabContextMenu.test.tsx | 3 +- .../tab-bar/SortableTabContextMenu.tsx | 6 +- .../tab-bar/TabBar.context-menu.test.ts | 2 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../tab-bar/tab-title-tooltip.test.tsx | 14 + src/renderer/src/hooks/useShortcutLabel.ts | 20 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/lib/floating-terminal.test.ts | 45 ++ src/renderer/src/lib/floating-terminal.ts | 27 + src/shared/keybindings.test.ts | 102 ++++ src/shared/keybindings.ts | 53 ++ 27 files changed, 1313 insertions(+), 141 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx create mode 100644 src/renderer/src/lib/floating-terminal.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aacb0a5c9e5..3bcbf8887c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -1395,6 +1398,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1409,6 +1413,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1426,6 +1431,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1521,6 +1527,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 8306c7b9102..c571d145808 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,7 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { KeybindingOverrides } from '../../../../shared/keybindings' +import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -14,6 +14,10 @@ import { getMaximizedFloatingTerminalBounds, type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' type EffectCallback = () => void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -57,6 +62,7 @@ type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -65,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f91eded8216..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -58,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -94,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -376,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -963,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 950378ae1d1..514402a0f36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -16,6 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = { const controlButtonClassName = 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' +// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of +// surfacing the live keybinding in a tooltip as "Label (shortcut)", while +// degrading to a bare label when the action is unbound (default on Win/Linux, +// and for minimize on every platform). +function withShortcutHint(label: string, shortcutLabel: string | null): string { + return shortcutLabel ? `${label} (${shortcutLabel})` : label +} + export function FloatingTerminalWindowControls({ maximized, onToggleMaximized, @@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 6694b357ddb..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,28 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !isPinned && onClose()} disabled={isPinned}> {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} + {closeShortcut ? {closeShortcut} : null} {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('
', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b47ec987e66..b22e1912d9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !isPinned && onClose(tab.id)} disabled={isPinned}> {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} + {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} @@ -178,6 +181,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} + {renameShortcut ? {renameShortcut} : null}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0455c4fb64b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a699f1d5812..86a665eafb7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2624,6 +2625,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac7092448ff..f4592d35d23 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2624,6 +2625,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2ceb16b354d..205a953ec87 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7a22cac315f..60f7ac0ebbb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2624,6 +2625,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d2d12868b6..0c5d2d3c9fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..83db7e37efd 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,65 @@ describe('keybindings', () => { ) }) + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +609,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..57a8b775267 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -50,6 +50,8 @@ export type KeybindingActionId = | 'sidebar.ports.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In', From 30a1a52459bcfe7ed0acb4e4e6d80ee0eba23629 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:51:54 +0000 Subject: [PATCH 48/57] release: v1.4.94 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1960a32566d..6b08ed1e086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94-rc.2", + "version": "1.4.94", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0880021665be55ccd66c2ea6c9346e25f4e3666c Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:26:01 +0200 Subject: [PATCH 49/57] feat(shortcuts): add assignable shortcut to toggle sleeping workspaces Adds an unbound sidebar.sleepingWorkspaces.toggle keybinding action, wires it to the global shortcut dispatcher, and shows assigned shortcut labels in the workspace-board filter menu. Also resolves the PR against current main and removes an unnecessary exhaustive-switch fallback that blocked current lint. --- src/renderer/src/App.tsx | 15 ++++++++ .../src/components/sidebar/SidebarFilter.tsx | 33 +++++++++++------ .../task-page-github-work-item-status.ts | 3 -- src/shared/keybindings.test.ts | 36 +++++++++++++++++++ src/shared/keybindings.ts | 21 +++++++++++ 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3bcbf8887c2..1071ff9ae8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1604,6 +1604,21 @@ function App(): React.JSX.Element { return } + // Toggle the "show sleeping workspaces" sidebar filter without opening the + // filters menu (issue #5209). When revealing them, open the left sidebar + // so the now-visible sleeping worktrees are actually reachable. + if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) { + input.preventDefault() + notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle') + const store = useAppStore.getState() + const nextShowSleeping = !store.showSleepingWorkspaces + store.setShowSleepingWorkspaces(nextShowSleeping) + if (nextShowSleeping) { + store.setSidebarOpen(true) + } + return + } + // Why: rename the active terminal tab. Cmd+R is free in the app/terminal // focus zone because the browser pane owns its own Cmd+R reload and that // focus never reaches this renderer-window handler. Only terminal tabs diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 7d21c357afd..9927e4e9db2 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -13,10 +13,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' @@ -37,6 +39,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ }: SidebarFilterProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + // Surface the user-assigned shortcut here so the filter menu doubles as its + // discovery point ('Unassigned' until they bind one in Settings → Shortcuts). + const sleepingShortcut = useShortcutLabel('sidebar.sleepingWorkspaces.toggle') const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) @@ -183,6 +188,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ label={translate('auto.components.sidebar.SidebarFilter.638a2d221d', 'Hide sleeping')} checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} + shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> } @@ -333,12 +339,14 @@ function FilterToggleRow({ icon, label, checked, - onChange + onChange, + shortcutLabel }: { icon: React.ReactNode label: string checked: boolean onChange: (next: boolean) => void + shortcutLabel?: string }) { return ( ) diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index a8028e6a43b..ab9d0361032 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -65,8 +65,5 @@ export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): str return 'text-purple-600 dark:text-purple-300' case 'closed': return 'text-rose-600 dark:text-rose-300' - default: - // Fallback for any unexpected state - return 'text-muted-foreground' } } diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 83db7e37efd..e69917db03e 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -456,6 +456,42 @@ describe('keybindings', () => { ) }) + it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 's', + code: 'KeyS', + control: true, + meta: false, + alt: true, + shift: false + } + + // Ships unbound on every platform (issue #5209): assign-it-yourself. + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'darwin')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'linux')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'win32')).toEqual( + [] + ) + expect(keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux')).toBe( + false + ) + expect( + keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux', { + 'sidebar.sleepingWorkspaces.toggle': ['Mod+Alt+S'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.sleepingWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Sleeping Workspaces') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['sleeping', 'workspaces', 'filter']) + ) + }) + it('defines floating workspace panel action metadata', () => { const actionIds = [ 'floatingWorkspace.maximize' as KeybindingActionId, diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 57a8b775267..6d4c3e64fb7 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -48,6 +48,7 @@ export type KeybindingActionId = | 'sidebar.sourceControl.toggle' | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' @@ -393,6 +394,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ win32: [] } }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound — issue #5209 asks to "assign a shortcut", so we avoid + // claiming a cross-platform chord and let users bind it in Settings. + defaultBindings: platformBindings([]) + }, { id: 'sidebar.focusWorktreeList', title: 'Focus worktree list', From cf39bd7abe0c19a98903caf1684bdc8fd3c4af04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:55:59 -0700 Subject: [PATCH 50/57] Find projects and repo groups from Cmd+J (#6193) Co-authored-by: Orca --- .../src/components/WorktreeJumpPalette.tsx | 223 ++++++++++- .../cmd-j/palette-project-results.ts | 262 +++++++++++++ .../components/cmd-j/palette-results.test.ts | 191 ++++++++++ .../src/components/sidebar/WorktreeList.tsx | 352 +++++++++++++++++- .../sidebar/visible-worktrees.test.ts | 11 + .../components/sidebar/visible-worktrees.ts | 8 +- .../sidebar/worktree-list-groups.ts | 35 +- .../task-page-github-work-item-status.ts | 1 - src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- .../lib/scroll-to-current-workspace-status.ts | 17 +- src/renderer/src/store/slices/ui.ts | 25 ++ 15 files changed, 1156 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-project-results.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 55adcae851f..f43cafa131d 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -2,7 +2,16 @@ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { FileText, Globe, Plus, Server, ServerOff, Smartphone, SquareTerminal } from 'lucide-react' +import { + FileText, + FolderTree, + Globe, + Plus, + Server, + ServerOff, + Smartphone, + SquareTerminal +} from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' import { @@ -77,6 +86,12 @@ import { type CmdJActionResult, type CmdJSettingsResult } from '@/components/cmd-j/palette-results' +import { buildImportedWorktreesCardCandidates } from '@/components/sidebar/imported-worktrees-card-candidates' +import { + hasCmdJProjectSearchCandidates, + searchCmdJProjectResults, + type CmdJProjectSearchResult +} from '@/components/cmd-j/palette-project-results' import { buildCmdJQuickActionContext, captureCmdJActiveGroupSnapshot, @@ -139,6 +154,12 @@ type QuickActionPaletteItem = { result: CmdJActionResult } +type ProjectTargetPaletteItem = { + id: string + type: 'project-target' + result: CmdJProjectSearchResult +} + type SectionHeader = { id: string type: 'section-header' @@ -160,6 +181,7 @@ type CreateWorktreePaletteItem = { // Keep future quick actions curated; route one-time setup flows through Settings. type PaletteItem = | WorktreePaletteItem + | ProjectTargetPaletteItem | SettingsPaletteItem | QuickActionPaletteItem | BrowserPaletteItem @@ -305,9 +327,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) + const projectGroups = useAppStore((s) => s.projectGroups) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) // Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a // working agent in a non-focused pane still surface as 'working' in the @@ -740,6 +768,68 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [settingsSections] ) const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), []) + // Why: Cmd+J should only offer project jumps the sidebar can actually reveal; + // archived-only repos are intentionally left out of this navigation surface. + const renderableProjectRepoIds = useMemo(() => { + const ids = new Set() + for (const worktree of allWorktrees) { + if (!worktree.isArchived) { + ids.add(worktree.repoId) + } + } + for (const repo of repos) { + if ((worktreesByRepo[repo.id]?.length ?? 0) === 0) { + ids.add(repo.id) + } + } + for (const repoId of buildImportedWorktreesCardCandidates({ + repos, + detectedWorktreesByRepo + }).keys()) { + ids.add(repoId) + } + for (const creation of Object.values(pendingWorktreeCreations)) { + ids.add(creation.request.repoId) + } + return ids + }, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo]) + const hasAnyProjectSearchCandidates = useMemo( + () => + hasCmdJProjectSearchCandidates({ + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds: renderableProjectRepoIds + }), + [projectGroups, projectHostSetups, projects, renderableProjectRepoIds, repos] + ) + const projectTargetItems = useMemo( + () => + hasQuery + ? searchCmdJProjectResults({ + query: deferredQuery, + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds: renderableProjectRepoIds + }).map((result) => ({ + id: result.id, + type: 'project-target' as const, + result + })) + : [], + [ + deferredQuery, + hasQuery, + projectGroups, + projectHostSetups, + projects, + renderableProjectRepoIds, + repos + ] + ) const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => { const state = useAppStore.getState() @@ -830,6 +920,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // viewport naturally. const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap) + const visibleProjectTargetItems = hasQuery ? projectTargetItems : [] const visibleMiddleItems = hasQuery ? middleItems : [] const visibleOpenTabItems = hasQuery ? openTabItems @@ -838,15 +929,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return { visibleWorktreeItems, + visibleProjectTargetItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } - }, [worktreeItems, middleItems, openTabItems, hasQuery]) + }, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery]) const selectableItems = useMemo( () => [ ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, ...paletteSections.visibleMiddleItems, ...paletteSections.visibleOpenTabItems ], @@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const listEntries = useMemo(() => { const entries: PaletteListEntry[] = [] - const { visibleWorktreeItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } = - paletteSections + const { + visibleWorktreeItems, + visibleProjectTargetItems, + visibleMiddleItems, + visibleOpenTabItems, + showWorktreeHint + } = paletteSections const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0) const populatedSectionCount = [ visibleWorkspaceItemCount, + visibleProjectTargetItems.length, visibleMiddleItems.length, visibleOpenTabItems.length ].filter((count) => count > 0).length @@ -883,6 +982,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const showOpenTabsHeader = hasQuery ? visibleOpenTabItems.length > 0 && populatedSectionCount > 1 : visibleOpenTabItems.length > 0 + const showProjectTargetHeader = + hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1 const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1 if (visibleWorkspaceItemCount > 0) { @@ -899,11 +1000,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }) } appendPaletteListEntries(entries, visibleWorktreeItems) - if (showCreateAction) { - // Why: the typed create affordance is workspace-scoped, so keep it - // directly under workspace matches instead of after actions/tabs. - entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) - } if (showWorktreeHint) { entries.push({ id: '__hint_worktree_cap__', @@ -916,6 +1012,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }) } } + if (visibleProjectTargetItems.length > 0) { + if (showProjectTargetHeader) { + entries.push({ + id: '__header_projects_groups__', + type: 'section-header', + label: translate( + 'auto.components.WorktreeJumpPalette.projectsGroupsHeader', + 'Projects & Groups' + ) + }) + } + appendPaletteListEntries(entries, visibleProjectTargetItems) + } + if (showCreateAction) { + // Why: project/group jump targets are navigation results; keep them + // directly after worktree matches before the creation fallback. + entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) + } if (visibleMiddleItems.length > 0) { if (showMiddleHeader) { entries.push({ @@ -1260,10 +1374,41 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [buildQuickActionContext, closeModal, recordFeatureInteraction] ) + const handleSelectProjectTarget = useCallback( + (result: CmdJProjectSearchResult) => { + skipRestoreFocusRef.current = true + // Why: selecting a project or repo group is a sidebar navigation action; + // it should reveal the grouping row without activating an arbitrary workspace. + revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true }) + recordFeatureInteraction('cmd-j') + closeModal() + setSelectedItemId('') + if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) { + requestBrowserFocus({ + pageId: previousBrowserPageIdRef.current, + target: previousBrowserFocusTargetRef.current + }) + return + } + if (previousWorktreeIdRef.current) { + focusFallbackSurface() + } + }, + [ + closeModal, + focusFallbackSurface, + recordFeatureInteraction, + requestBrowserFocus, + revealSidebarRow + ] + ) + const handleSelectItem = useCallback( (item: PaletteItem) => { if (item.type === 'worktree') { handleSelectWorktree(item.worktree.id) + } else if (item.type === 'project-target') { + handleSelectProjectTarget(item.result) } else if (item.type === 'browser-page') { handleSelectBrowserPage(item.result) } else if (item.type === 'simulator-tab') { @@ -1278,6 +1423,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }, [ handleSelectBrowserPage, + handleSelectProjectTarget, handleSelectQuickAction, handleSelectSettings, handleSelectSimulatorTab, @@ -1495,7 +1641,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const resultCount = selectableItems.length const emptyState = (() => { - if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyOpenTabs) && hasQuery) { + if ( + (hasAnySearchableWorktrees || + hasAnyProjectSearchCandidates || + hasAnyMiddleResults || + hasAnyOpenTabs) && + hasQuery + ) { return { title: translate( 'auto.components.WorktreeJumpPalette.dbd9d87eec', @@ -1503,7 +1655,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ), subtitle: translate( 'auto.components.WorktreeJumpPalette.c4afa68159', - 'Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.' + 'Try a worktree, project, setting, action, tab title, agent prompt, URL, PR, or port.' ) } } @@ -1698,7 +1850,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} )} - + {entry.match.displayNameRange ? ( handleSelectItem(entry)} + className={cn( + 'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]', + 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' + )} + > +
+
+
+
+
+
+ + {result.title} + + + {badgeLabel} + +
+
+ {isProject ? ( +
+ + + + {result.repo.displayName} + +
+ ) : null} +
+
+ + ) + } + if (entry.type === 'settings' || entry.type === 'quick-action') { const result = entry.result const Icon = result.icon diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts new file mode 100644 index 00000000000..e4b26441bde --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -0,0 +1,262 @@ +import { isCmdJPaletteQueryTooLarge } from './palette-results' +import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { + getProjectGroupHeaderKey, + getProjectHeaderRevealTarget, + type ProjectGroupingModel +} from '../sidebar/worktree-list-groups' + +export type CmdJProjectGroupResult = { + id: string + kind: 'project-group' + title: string + description: string + rowKey: string + order: number + keywords: string[] +} + +export type CmdJProjectResult = { + id: string + kind: 'project' + title: string + description: string + rowKey: string + repo: Repo + order: number + keywords: string[] +} + +export type CmdJProjectSearchResult = CmdJProjectGroupResult | CmdJProjectResult + +type RankedProjectResult = { + result: CmdJProjectSearchResult + rule: number + score: number +} + +const PROJECT_GROUP_ALIASES = ['group', 'repo group'] +const PROJECT_ALIASES = ['project', 'repo'] + +function normalizeQuery(value: string): string { + let normalized = '' + let pendingWhitespace = false + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (isCmdJPaletteWhitespace(code)) { + pendingWhitespace = normalized.length > 0 + continue + } + if (pendingWhitespace) { + normalized += ' ' + pendingWhitespace = false + } + normalized += value.charAt(index).toLowerCase() + } + return normalized +} + +function isCmdJPaletteWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +function uniqueNormalized(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeQuery).filter(Boolean))] +} + +function tokenize(value: string): string[] { + return normalizeQuery(value) + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function tokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenize) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + for (const queryToken of tokenize(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + } + return score +} + +function buildCmdJProjectSearchCandidates({ + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds +}: { + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + renderableRepoIds?: ReadonlySet +}): CmdJProjectSearchResult[] { + const projectGrouping: ProjectGroupingModel = { projects, projectHostSetups } + const repoMap = new Map(repos.map((repo) => [repo.id, repo])) + const candidates: CmdJProjectSearchResult[] = [] + + projectGroups.forEach((group, order) => { + candidates.push({ + id: `project-group:${group.id}`, + kind: 'project-group', + title: group.name, + description: translate( + 'auto.components.cmd.j.palette.project.results.repoGroup', + 'Repo group' + ), + rowKey: getProjectGroupHeaderKey(group.id), + order, + keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES]) + }) + }) + + const seenRowKeys = new Set() + repos.forEach((repo, repoIndex) => { + if (renderableRepoIds && !renderableRepoIds.has(repo.id)) { + return + } + const target = getProjectHeaderRevealTarget(repo.id, repoMap, projectGrouping) + if (!target.repo || seenRowKeys.has(target.key)) { + return + } + seenRowKeys.add(target.key) + candidates.push({ + id: `project:${target.key}`, + kind: 'project', + title: target.label, + description: translate('auto.components.cmd.j.palette.project.results.project', 'Project'), + rowKey: target.key, + repo: target.repo, + order: projectGroups.length + repoIndex, + keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES]) + }) + }) + + return candidates +} + +export function hasCmdJProjectSearchCandidates({ + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds +}: { + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + renderableRepoIds?: ReadonlySet +}): boolean { + return ( + buildCmdJProjectSearchCandidates({ + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds + }).length > 0 + ) +} + +function projectRankingForCandidate( + query: string, + candidate: CmdJProjectSearchResult +): RankedProjectResult | null { + const title = normalizeQuery(candidate.title) + if (query === title) { + return { result: candidate, rule: 1, score: 0 } + } + if (title.startsWith(query)) { + return { result: candidate, rule: 2, score: 0 } + } + const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES + if (aliasKeywords.map(normalizeQuery).includes(query)) { + return { result: candidate, rule: 3, score: 0 } + } + if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { + return { result: candidate, rule: 4, score: 0 } + } + const score = tokenScore(query, [candidate.title, ...candidate.keywords]) + return score > 0 ? { result: candidate, rule: 5, score } : null +} + +function compareProjectRanked(a: RankedProjectResult, b: RankedProjectResult): number { + if (a.rule !== b.rule) { + return a.rule - b.rule + } + if (a.score !== b.score) { + return b.score - a.score + } + if (a.result.order !== b.result.order) { + return a.result.order - b.result.order + } + return a.result.id.localeCompare(b.result.id) +} + +export function searchCmdJProjectResults({ + query, + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds +}: { + query: string + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + renderableRepoIds?: ReadonlySet +}): CmdJProjectSearchResult[] { + // Why: oversized pasted input should not force the palette to scan project, + // repo, or group names that may include private workspace details. + if (isCmdJPaletteQueryTooLarge(query)) { + return [] + } + const normalizedQuery = normalizeQuery(query) + // Why: project/group rows sit after worktree matches, so one-character + // searches would add broad noisy navigation targets before intent is clear. + if (normalizedQuery.length < 2) { + return [] + } + return buildCmdJProjectSearchCandidates({ + projectGroups, + repos, + projects, + projectHostSetups, + renderableRepoIds + }) + .map((candidate) => projectRankingForCandidate(normalizedQuery, candidate)) + .filter((entry): entry is RankedProjectResult => entry !== null) + .sort(compareProjectRanked) + .map((entry) => entry.result) +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index d6e0257640e..1d034b1a507 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Globe, Settings } from 'lucide-react' import type { CmdJQuickAction } from './quick-actions' @@ -10,7 +11,9 @@ import { type CmdJActionResult, type CmdJSettingsResult } from './palette-results' +import { hasCmdJProjectSearchCandidates, searchCmdJProjectResults } from './palette-project-results' import type { SettingsNavSection } from '@/lib/settings-navigation-types' +import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' }) const available: CmdJQuickAction['isAvailable'] = () => ({ available: true }) @@ -259,3 +262,191 @@ describe('Cmd+J palette middle-band ranking', () => { ).toEqual([]) }) }) + +function repo(id: string, displayName: string, projectGroupId?: string | null): Repo { + return { + id, + path: path.join('/repos', displayName), + displayName, + badgeColor: '#999999', + addedAt: 1, + projectGroupId + } as Repo +} + +function project(id: string, displayName: string): Project { + return { + id, + displayName, + badgeColor: '#999999', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + } +} + +function setup(id: string, projectId: string, hostId: string, repoId: string): ProjectHostSetup { + return { + id, + projectId, + hostId: hostId as ProjectHostSetup['hostId'], + repoId, + path: path.join('/repos', repoId), + displayName: repoId, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } +} + +function projectGroup(id: string, name: string, parentGroupId: string | null = null): ProjectGroup { + return { + id, + name, + parentPath: null, + parentGroupId, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +describe('Cmd+J project and repo-group search', () => { + it('finds a Project Group by name', () => { + const [result] = searchCmdJProjectResults({ + query: 'infra', + projectGroups: [projectGroup('group-1', 'Infrastructure')], + repos: [], + projects: [], + projectHostSetups: [] + }) + + expect(result).toMatchObject({ + kind: 'project-group', + title: 'Infrastructure', + description: 'Repo group', + rowKey: 'project-group:group-1' + }) + }) + + it('finds a Project by project name and a repo-backed fallback by repo name', () => { + const projectResults = searchCmdJProjectResults({ + query: 'api', + projectGroups: [], + repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')], + projects: [project('project-1', 'API Service')], + projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')] + }) + const fallbackResults = searchCmdJProjectResults({ + query: 'source', + projectGroups: [], + repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')], + projects: [project('project-1', 'API Service')], + projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')] + }) + + expect(projectResults.map((result) => [result.title, result.rowKey])).toEqual([ + ['API Service', 'project:project-1'] + ]) + expect(fallbackResults.map((result) => [result.title, result.rowKey])).toEqual([ + ['source-folder', 'repo:repo-2'] + ]) + }) + + it('uses setup-specific project header keys for multi-setup projects on one host', () => { + const results = searchCmdJProjectResults({ + query: 'platform', + projectGroups: [], + repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')], + projects: [project('project-1', 'Platform')], + projectHostSetups: [ + setup('setup-1', 'project-1', 'local', 'repo-1'), + setup('setup-2', 'project-1', 'local', 'repo-2') + ] + }) + + expect(results.map((result) => result.rowKey)).toEqual([ + 'project:project-1::setup:repo-1', + 'project:project-1::setup:repo-2' + ]) + }) + + it('suppresses raw Project records without renderable repo header targets', () => { + const results = searchCmdJProjectResults({ + query: 'orphan', + projectGroups: [], + repos: [], + projects: [project('project-1', 'Orphan Project')], + projectHostSetups: [] + }) + + expect(results).toEqual([]) + }) + + it('suppresses repo-backed projects when the sidebar cannot render their header row', () => { + const results = searchCmdJProjectResults({ + query: 'archived', + projectGroups: [], + repos: [repo('repo-1', 'archived-service')], + projects: [project('project-1', 'Archived Service')], + projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')], + renderableRepoIds: new Set() + }) + + expect(results).toEqual([]) + }) + + it('reports searchable project candidates even when a query has no match', () => { + expect( + hasCmdJProjectSearchCandidates({ + projectGroups: [projectGroup('group-1', 'Infrastructure')], + repos: [], + projects: [], + projectHostSetups: [] + }) + ).toBe(true) + expect( + searchCmdJProjectResults({ + query: 'zzzz', + projectGroups: [projectGroup('group-1', 'Infrastructure')], + repos: [], + projects: [], + projectHostSetups: [] + }) + ).toEqual([]) + }) + + it('rejects oversized project queries before reading names', () => { + const oversizedQuery = 'secret-palette-query'.repeat(CMD_J_PALETTE_QUERY_MAX_BYTES) + const throwingGroup = { + get id() { + throw new Error('oversized palette queries must not scan project groups') + }, + get name() { + throw new Error('oversized palette queries must not scan project groups') + } + } as unknown as ProjectGroup + const throwingRepo = { + get id() { + throw new Error('oversized palette queries must not scan repos') + }, + get displayName() { + throw new Error('oversized palette queries must not scan repos') + } + } as unknown as Repo + + expect( + searchCmdJProjectResults({ + query: oversizedQuery, + projectGroups: [throwingGroup], + repos: [throwingRepo], + projects: [], + projectHostSetups: [] + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a46ed4c2d47..1236d6dad5c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -78,6 +78,7 @@ import { ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, + getProjectGroupHeaderKey, getGroupKeysForWorktree, getLineageGroupKey } from './worktree-list-groups' @@ -198,7 +199,7 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' @@ -439,6 +440,130 @@ function revealMountedWorktreeElement( return revealElementInScrollContainer(container, element, behavior) ? element : null } +function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior) ? element : null +} + +function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + return null +} + +function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + function getWorktreeVisibilityMenuLabel(repo: Repo): string { const visibility = effectiveExternalWorktreeVisibility( repo, @@ -471,7 +596,9 @@ type VirtualizedWorktreeViewportProps = { handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void agentSendTargetWorktreeId: string | null worktrees: Worktree[] folderWorkspaces: readonly FolderWorkspace[] @@ -1097,7 +1224,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleCreateFolderWorkspace, activeModal, pendingRevealWorktree, + pendingRevealSidebarRow, clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, agentSendTargetWorktreeId, worktrees, folderWorkspaces, @@ -1143,9 +1272,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState( - null - ) + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const worktreeDragSessionRef = useRef(null) @@ -1156,6 +1283,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) const worktreeNativeLatestPointRef = useRef(null) const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) const pendingRevealFrameIdsRef = useRef>(new Set()) const revealHighlightFrameIdRef = useRef(null) const revealHighlightTimeoutRef = useRef(null) @@ -1184,19 +1312,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp revealHighlightTimeoutRef.current = null } }, []) - const flashRevealedWorktree = useCallback( - (worktreeId: string) => { + const flashRevealedRow = useCallback( + (rowKey: string) => { clearRevealHighlightTimeout() clearRevealHighlightFrame() // Why: remove before add restarts the CSS glow when the user repeatedly // asks to reveal the same active workspace. - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { revealHighlightFrameIdRef.current = null - setHighlightedRevealWorktreeId(worktreeId) + setHighlightedRevealRowKey(rowKey) revealHighlightTimeoutRef.current = window.setTimeout(() => { revealHighlightTimeoutRef.current = null - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) }, 1500) }) }, @@ -1867,7 +1995,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp : null if (revealedOption) { if (pendingRevealWorktree.highlight) { - flashRevealedWorktree(pendingRevealWorktree.worktreeId) + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } } if (pendingRevealWorktree.beginRename) { setRenamingWorktreeId({ @@ -1930,12 +2062,135 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp settings, projectGroups, pendingRevealRetryTick, - flashRevealedWorktree, + flashRevealedRow, setRenamingWorktreeId, schedulePendingRevealFrame, cancelPendingRevealFrames ]) + React.useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap, + projectGroups, + projectGrouping + })) { + if (collapsedGroups.has(groupKey)) { + toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = () => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= 8) { + schedulePendingRevealFrame(() => { + if (!cancelled) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const retryExactRevealOnNextFrame = () => { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + } + + const container = scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { + align: 'auto', + behavior: 'auto' + }) + retryExactRevealOnNextFrame() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + }, [ + pendingRevealSidebarRow, + repoMap, + projectGroups, + projectGrouping, + collapsedGroups, + groupBy, + toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames + ]) + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) const renderRowKeySignature = useMemo( @@ -3742,6 +3997,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } >
s.activeWorktreeId) const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) + const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) @@ -4679,9 +4938,12 @@ const WorktreeList = React.memo(function WorktreeList({ const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) + const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) // Why: agent-send eligibility only matters while the picker is open. When it // is closed, avoid subscribing WorktreeList to wake-time terminal layout churn. @@ -5341,6 +5603,23 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceHostScope ] ) + const renderedSidebarRowKeys = useMemo(() => { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } + } + return keys + }, [sectionRows]) // Why: status headers change during wake (inactive -> active). Key only on // the grouping mode so row identity survives those ordinary status moves. const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' @@ -5878,14 +6157,16 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope }), [ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope ] ) const hasFilters = sidebarHasActiveFilters(filterState) @@ -5923,12 +6204,48 @@ const WorktreeList = React.memo(function WorktreeList({ filterState ]) + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + const handleRevealCurrentWorkspaceRequest = useCallback( (event: Event) => { const detail = event instanceof CustomEvent ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } if (!activeWorktreeId) { return } @@ -5948,13 +6265,14 @@ const WorktreeList = React.memo(function WorktreeList({ revealWorktreeInSidebar(activeWorktreeId, { behavior: 'smooth', highlight: true, - beginRename: detail?.beginRename === true + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ activeWorktreeId, clearFilters, folderWorkspaces, + revealSidebarRow, renderedWorktreeIds, revealWorktreeInSidebar, worktreeMap @@ -6085,7 +6403,9 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} + pendingRevealSidebarRow={pendingRevealSidebarRow} clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} + clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} worktrees={worktrees} folderWorkspaces={folderWorkspaces} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 81a78bec138..25165df25b8 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -98,6 +98,7 @@ function filterState(overrides: Partial = {}): FilterState { filterRepoIds: [], hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + workspaceHostScope: 'all', ...overrides } } @@ -617,6 +618,16 @@ describe('computeClearFilterActions', () => { expect(actions.resetFilterRepoIds).toBe(true) }) + it('flags legacy single-host scope for reset even without visible host ids', () => { + expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: true + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 785fc2a39fa..434df0ee0de 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -36,6 +36,7 @@ export type SidebarFilterState = { hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + workspaceHostScope?: ExecutionHostScope } /** @@ -53,7 +54,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || state.hideAutomationGeneratedWorkspaces || - state.visibleWorkspaceHostIds != null + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) } @@ -83,7 +85,9 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null + resetVisibleWorkspaceHostIds: + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 2b5a89f18e2..91f9aa5c8b3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -145,14 +145,24 @@ type ProjectGroupingIndex = { multiSetupProjectHostKeys: Set } +const projectGroupingIndexCache = new WeakMap() + function projectHostKey(projectId: string, hostId: string): string { return `${projectId}::${hostId}` } function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { - const projects = model?.projects ?? [] - const projectHostSetups = model?.projectHostSetups ?? [] + if (!model) { + return null + } + const cached = projectGroupingIndexCache.get(model) + if (cached !== undefined) { + return cached + } + const projects = model.projects ?? [] + const projectHostSetups = model.projectHostSetups ?? [] if (projects.length === 0 || projectHostSetups.length === 0) { + projectGroupingIndexCache.set(model, null) return null } const setupCountByProjectHost = new Map() @@ -166,18 +176,27 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin multiSetupProjectHostKeys.add(key) } } - return { + const index = { projectById: new Map(projects.map((project) => [project.id, project])), setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), multiSetupProjectHostKeys } + projectGroupingIndexCache.set(model, index) + return index +} + +export type ProjectHeaderRevealTarget = { + key: string + label: string + repo?: Repo + projectId?: string } function getProjectGroupingForRepo( repoId: string, repoMap: Map, projectIndex: ProjectGroupingIndex | null -): { key: string; label: string; repo?: Repo; projectId?: string } { +): ProjectHeaderRevealTarget { const repo = repoMap.get(repoId) const setup = projectIndex?.setupByRepoId.get(repoId) const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined @@ -207,6 +226,14 @@ function getProjectGroupingForRepo( } } +export function getProjectHeaderRevealTarget( + repoId: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { group.repoIds.add(repoId) } diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index ab9d0361032..b200abb2328 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -55,7 +55,6 @@ export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): str return 'text-muted-foreground' } - // Exhaustive switch ensures TypeScript will error if new states are added switch (item.state) { case 'draft': return 'text-muted-foreground' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 86a665eafb7..885882707ec 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1703,7 +1703,10 @@ "settingsBadge": "Settings", "actionBadge": "Action", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "Tab no longer exists" + "workspaceTabMissing": "Tab no longer exists", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3965,7 +3968,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "failedNestWorkspace": "Failed to nest workspace" + "failedNestWorkspace": "Failed to nest workspace", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -11060,6 +11064,14 @@ "newQuickCommand": "new quick command" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4592d35d23..5cfaa362c6b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1703,7 +1703,10 @@ "settingsBadge": "Ajustes", "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "La pestaña ya no existe" + "workspaceTabMissing": "La pestaña ya no existe", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3951,7 +3954,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "No se pudo anidar el espacio de trabajo" + "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -11060,6 +11064,14 @@ "newQuickCommand": "nuevo comando rápido" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 205a953ec87..14cbf557eaf 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1703,7 +1703,10 @@ "settingsBadge": "設定", "actionBadge": "操作", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "タブはもう存在しません" + "workspaceTabMissing": "タブはもう存在しません", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "ワークスペースをネストできませんでした" + "failedNestWorkspace": "ワークスペースをネストできませんでした", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -11060,6 +11064,14 @@ "newQuickCommand": "新規クイックコマンド" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 60f7ac0ebbb..4478b7fa714 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1703,7 +1703,10 @@ "settingsBadge": "설정", "actionBadge": "작업", "paletteHostBadge": "호스트: {{value0}}", - "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다" + "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "업데이트 필요", "hostAuthNeeded": "인증 필요", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다" + "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -11060,6 +11064,14 @@ "newQuickCommand": "새로운 빠른 명령" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0c5d2d3c9fd..0e6c0214a88 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1703,7 +1703,10 @@ "settingsBadge": "设置", "actionBadge": "操作", "paletteHostBadge": "主机:{{value0}}", - "workspaceTabMissing": "标签页不再存在" + "workspaceTabMissing": "标签页不再存在", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "无法嵌套工作区" + "failedNestWorkspace": "无法嵌套工作区", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -11060,6 +11064,14 @@ "newQuickCommand": "新的快速命令" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/lib/scroll-to-current-workspace-status.ts b/src/renderer/src/lib/scroll-to-current-workspace-status.ts index fbca39dc2a1..6f077da3b06 100644 --- a/src/renderer/src/lib/scroll-to-current-workspace-status.ts +++ b/src/renderer/src/lib/scroll-to-current-workspace-status.ts @@ -1,9 +1,15 @@ export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT = 'orca-scroll-to-current-workspace-reveal-request' -export type ScrollToCurrentWorkspaceRevealRequestDetail = { - beginRename?: boolean -} +export type ScrollToCurrentWorkspaceRevealRequestDetail = + | { + target?: { type: 'active-workspace' } + beginRename?: boolean + } + | { + target: { type: 'sidebar-row'; rowKey: string } + highlight?: boolean + } function dispatchScrollToCurrentWorkspaceReveal( detail?: ScrollToCurrentWorkspaceRevealRequestDetail @@ -21,5 +27,8 @@ export function requestScrollToCurrentWorkspaceReveal(): void { } export function requestScrollToCurrentWorkspaceRevealAndRename(): void { - dispatchScrollToCurrentWorkspaceReveal({ beginRename: true }) + dispatchScrollToCurrentWorkspaceReveal({ + target: { type: 'active-workspace' }, + beginRename: true + }) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ceb59dd752a..5e0c78c2945 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -117,6 +117,12 @@ export type PendingSidebarWorktreeReveal = { beginRename?: boolean } +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + export type AgentSendPopoverTargetMode = { id: string instanceId: string @@ -847,6 +853,7 @@ export type UISlice = { petSize: number setPetSize: (size: number) => void pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null revealWorktreeInSidebar: ( worktreeId: string, options?: { @@ -855,7 +862,15 @@ export type UISlice = { beginRename?: boolean } ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void // Why: lets the SourceControl sidebar request that the diff editor scroll // to a specific note. Cleared by the diff decorator after it reveals the // line, so the same id can be requested again later without the surface @@ -2145,6 +2160,7 @@ export const createUISlice: StateCreator = (set, get) }), pendingRevealWorktree: null, + pendingRevealSidebarRow: null, revealWorktreeInSidebar: (worktreeId, options) => set({ pendingRevealWorktree: { @@ -2154,7 +2170,16 @@ export const createUISlice: StateCreator = (set, get) ...(options?.beginRename ? { beginRename: true } : {}) } }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), persistedUIReady: false, From a2fd8c11c272dac0a36a672d6ad4b3da82a609cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:06:38 -0700 Subject: [PATCH 51/57] Reduce source-control background load during refreshes (#6189) Co-authored-by: Orca --- .../status-upstream-negative-cache.test.ts | 329 ++++++++++++++++++ .../git/status-upstream-probe-churn.test.ts | 39 ++- src/main/git/status.ts | 122 ++++++- src/main/github/pr-refresh-coordinator.ts | 39 +++ .../pr-refresh-validation-backoff.test.ts | 73 ++++ .../github/pr-refresh-validation-backoff.ts | 107 ++++++ src/main/ipc/filesystem.test.ts | 30 ++ src/main/ipc/filesystem.ts | 14 +- src/main/ipc/github.test.ts | 152 ++++++++ src/main/ipc/github.ts | 144 +++++--- src/main/providers/ssh-git-provider.test.ts | 16 + src/main/providers/ssh-git-provider.ts | 10 +- src/main/providers/types.ts | 7 +- src/main/runtime/orca-runtime-git.ts | 3 +- src/main/runtime/rpc/methods/git-params.ts | 3 +- src/main/runtime/rpc/methods/git.test.ts | 26 ++ src/main/runtime/rpc/methods/git.ts | 19 +- src/preload/api-types.ts | 4 +- src/preload/index.ts | 1 + src/relay/git-handler-status-ops.test.ts | 29 ++ src/relay/git-handler-status-ops.ts | 5 +- ...git-status-upstream-negative-cache.test.ts | 297 ++++++++++++++++ .../git-status-upstream-negative-cache.ts | 104 +++++- .../right-sidebar/git-status-refresh.test.ts | 156 ++++++++- .../right-sidebar/git-status-refresh.ts | 214 +++++++++--- .../right-sidebar/useGitStatusPolling.test.ts | 6 +- .../src/runtime/runtime-git-client.test.ts | 56 +++ .../src/runtime/runtime-git-client.ts | 14 +- src/renderer/src/store/slices/editor.ts | 9 +- src/renderer/src/store/slices/github.test.ts | 69 ++++ src/renderer/src/store/slices/github.ts | 2 +- src/shared/types.ts | 5 + 32 files changed, 1961 insertions(+), 143 deletions(-) create mode 100644 src/main/git/status-upstream-negative-cache.test.ts create mode 100644 src/main/github/pr-refresh-validation-backoff.test.ts create mode 100644 src/main/github/pr-refresh-validation-backoff.ts create mode 100644 src/relay/git-status-upstream-negative-cache.test.ts diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..0dcc4f4bcc6 --- /dev/null +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitStreamStdout: async ( + args: string[], + options: { onStdout: (chunk: string) => boolean | void } + ) => { + const { stdout } = await gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + }, + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ + ...env, + GIT_OPTIONAL_LOCKS: '0' + }) +})) + +vi.mock('fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock +})) + +import { + clearEffectiveUpstreamNegativeStatusCache, + clearEffectiveUpstreamStatusCacheForTests, + getEffectiveUpstreamStatusCacheCountForTests, + getEffectiveUpstreamStatusGenerationCountForTests, + getStatus +} from './status' + +describe('local upstream negative cache', () => { + beforeEach(() => { + clearEffectiveUpstreamStatusCacheForTests() + existsSyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + readFileMock.mockReset() + existsSyncMock.mockReturnValue(false) + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + }) + + it('bypasses a cached negative result for strict status reads', async () => { + let originBranchExists = false + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const first = await getStatus('/repo') + originBranchExists = true + const automatic = await getStatus('/repo') + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic.upstreamStatus).toEqual(first.upstreamStatus) + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(nextAutomatic.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds effective-upstream negative entries', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo') + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${currentBranch}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${currentBranch}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index b59f88dee8a..ddf216b3f9d 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn" @@ -51,6 +51,10 @@ describe('getStatus missing-upstream polling churn', () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n') }) + afterEach(() => { + vi.useRealTimers() + }) + it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { @@ -87,6 +91,39 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) + it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'Initi-Project\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error("fatal: no upstream configured for branch 'Initi-Project'") + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await getStatus('/repo') + vi.setSystemTime(31_000) + await getStatus('/repo') + + const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { + const args = getGitArgs(call) + return args[0] === 'rev-parse' && args.includes('HEAD@{u}') + }) + + expect(upstreamProbeCalls).toHaveLength(1) + }) + it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4097adaf1e6..8876fb18f53 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -52,7 +52,8 @@ import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 -const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 30_000 +const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number @@ -61,10 +62,22 @@ type EffectiveUpstreamStatusCacheEntry = { const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() +const effectiveUpstreamStatusWriteGeneration = new Map() export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() + retiredEffectiveUpstreamStatusInFlight.clear() + effectiveUpstreamStatusWriteGeneration.clear() +} + +export function getEffectiveUpstreamStatusCacheCountForTests(): number { + return effectiveUpstreamStatusCache.size +} + +export function getEffectiveUpstreamStatusGenerationCountForTests(): number { + return effectiveUpstreamStatusWriteGeneration.size } export type GetStatusOptions = GitRuntimeOptions & { @@ -74,6 +87,7 @@ export type GetStatusOptions = GitRuntimeOptions & { * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. */ limit?: number + bypassEffectiveUpstreamNegativeCache?: boolean } /** @@ -168,7 +182,8 @@ export async function getStatus( cacheKey, worktreePath, branchName, - options + options, + options.bypassEffectiveUpstreamNegativeCache === true ) } catch { // Why: git status polling should not fail just because the richer @@ -282,6 +297,64 @@ function getEffectiveUpstreamStatusCacheKey( return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } +export function clearEffectiveUpstreamNegativeStatusCache(identity: { + worktreePath: string + branchName: string + upstreamName?: string + options?: GitRuntimeOptions +}): void { + const cacheKey = getEffectiveUpstreamStatusCacheKey( + identity.worktreePath, + identity.branchName, + identity.upstreamName, + identity.options + ) + retireEffectiveUpstreamStatusProbe(cacheKey) + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusInFlight.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set( + cacheKey, + (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { + const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { + retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() + } + }) + .catch(() => undefined) +} + +function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { + return ( + effectiveUpstreamStatusInFlight.has(cacheKey) || + retiredEffectiveUpstreamStatusInFlight.has(cacheKey) + ) +} + +function trimEffectiveUpstreamStatusGeneration(): void { + for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { + if ( + effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES + ) { + break + } + if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { + continue + } + effectiveUpstreamStatusWriteGeneration.delete(cacheKey) + } +} + function readCachedEffectiveUpstreamStatus( cacheKey: string, now: number @@ -301,12 +374,18 @@ function rememberEffectiveUpstreamStatus( cacheKey: string, status: GitUpstreamStatus, now: number, - probedSameNameOriginRef: boolean + probedSameNameOriginRef: boolean, + writeGeneration: number ): void { // Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll // rather than keeping a stale positive target after branch config changes. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) + trimEffectiveUpstreamStatusGeneration() + return + } + if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { return } if (!probedSameNameOriginRef) { @@ -318,41 +397,58 @@ function rememberEffectiveUpstreamStatus( status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS }) + while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = effectiveUpstreamStatusCache.keys().next() + if (oldest.done) { + break + } + effectiveUpstreamStatusCache.delete(oldest.value) + effectiveUpstreamStatusWriteGeneration.delete(oldest.value) + } + trimEffectiveUpstreamStatusGeneration() } async function readOrProbeEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, branchName: string, - options: GitRuntimeOptions = {} + options: GitRuntimeOptions = {}, + bypassCache = false ): Promise { - const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) - if (cached) { - return cached - } + if (!bypassCache) { + const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) + if (cached) { + return cached + } - const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) - if (inFlight) { - return inFlight + const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } } // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. + const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, Date.now(), - result.probedSameNameOriginRef + result.probedSameNameOriginRef, + writeGeneration ) return result.status }) - effectiveUpstreamStatusInFlight.set(cacheKey, probe) + if (!bypassCache) { + effectiveUpstreamStatusInFlight.set(cacheKey, probe) + } try { return await probe } finally { if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { effectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() } } } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8af4ee10b4b..7dbb30f30d6 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' type QueueEntry = { key: string @@ -64,6 +65,7 @@ const BACKGROUND_BUDGET_MAX = 20 const POST_PUSH_DELAY_MS = 2_500 const BACKOFF_BASE_MS = 60_000 const BACKOFF_MAX_MS = 15 * 60_000 +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 let sequence = 0 let draining = false @@ -74,6 +76,12 @@ const errorBackoff = new Map() let lastBackgroundStartAt = 0 const visibleByWindow = new Map }>() let outcomeObserver: PRRefreshOutcomeObserver | null = null +const diagnosticsCounters = { + enqueued: 0, + coalesced: 0, + skipped: 0, + backgroundPauses: 0 +} export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { outcomeObserver = observer @@ -94,6 +102,27 @@ function removeInvisibleVisibleRefreshes(): void { } } +function recordPRRefreshQueueDiagnostic( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: diagnosticsCounters.enqueued, + coalesced: diagnosticsCounters.coalesced, + skipped: diagnosticsCounters.skipped, + backgroundPauses: diagnosticsCounters.backgroundPauses + } + }) +} + export function clearVisiblePRRefreshWindow(windowId: number): void { if (!visibleByWindow.delete(windowId)) { return @@ -480,6 +509,8 @@ async function drainQueue(): Promise { const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0 if (budgetDelay > 0) { + diagnosticsCounters.backgroundPauses += 1 + recordPRRefreshQueueDiagnostic('background-pause', next.reason) scheduleDrain(budgetDelay) return } @@ -488,6 +519,8 @@ async function drainQueue(): Promise { const aliases = Array.from(next.aliases.values()) const skippedReason = validateCandidate(next.candidate) if (skippedReason) { + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) continue } @@ -578,6 +611,8 @@ export function enqueuePRRefresh( const skippedReason = validateCandidate(candidate) if (skippedReason) { removeQueuedAliasForInvalidCandidate(key, alias) + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) broadcast({ aliases: [alias], reason, @@ -592,6 +627,8 @@ export function enqueuePRRefresh( const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { existing.aliases.set(alias.cacheKey, alias) + diagnosticsCounters.coalesced += 1 + recordPRRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = priority > existing.priority || isManual(reason) || @@ -604,6 +641,8 @@ export function enqueuePRRefresh( existing.windowId = windowId ?? existing.windowId } } else { + diagnosticsCounters.enqueued += 1 + recordPRRefreshQueueDiagnostic('enqueued', reason) queue.set(key, { key, candidate, diff --git a/src/main/github/pr-refresh-validation-backoff.test.ts b/src/main/github/pr-refresh-validation-backoff.test.ts new file mode 100644 index 00000000000..fd4211783ec --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({ + recordCoalescedCrashBreadcrumbMock: vi.fn() +})) + +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock +})) + +import { + clearPRRefreshValidationBackoffForTests, + getPRRefreshValidationBackoffCountForTests, + notePRRefreshValidationDenial +} from './pr-refresh-validation-backoff' + +describe('PR refresh validation backoff', () => { + beforeEach(() => { + clearPRRefreshValidationBackoffForTests() + recordCoalescedCrashBreadcrumbMock.mockReset() + }) + + it('backs off repeated automatic validation denials until the TTL expires', () => { + const identity = { + repoId: 'repo-1', + repoPath: '/workspace/missing', + reason: 'unknown-repo' as const + } + + expect(notePRRefreshValidationDenial(identity, 0)).toBe('validation-denied') + expect(notePRRefreshValidationDenial(identity, 60_000)).toBe('validation-backoff') + expect(notePRRefreshValidationDenial(identity, 5 * 60_000 + 1)).toBe('validation-denied') + }) + + it('bounds validation backoff entries', () => { + for (let index = 0; index < 257; index += 1) { + notePRRefreshValidationDenial( + { + repoId: `repo-${index}`, + repoPath: `/workspace/missing-${index}`, + reason: 'unknown-repo' + }, + index + ) + } + + expect(getPRRefreshValidationBackoffCountForTests()).toBe(256) + }) + + it('records path-safe diagnostic breadcrumbs', () => { + notePRRefreshValidationDenial( + { + repoId: 'repo-1', + repoPath: '/Users/alice/private/project', + reason: 'repo-path-mismatch' + }, + 0 + ) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'pr_refresh_validation_skip', + data: expect.objectContaining({ + reason: 'repo-path-mismatch', + result: 'recorded', + token: expect.any(String) + }) + }) + ) + const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0] + expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project') + }) +}) diff --git a/src/main/github/pr-refresh-validation-backoff.ts b/src/main/github/pr-refresh-validation-backoff.ts new file mode 100644 index 00000000000..ab03a064d84 --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.ts @@ -0,0 +1,107 @@ +import { createHash } from 'crypto' +import { resolve } from 'path' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000 +const MAX_VALIDATION_BACKOFF_ENTRIES = 256 +const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export type PRRefreshValidationDenialReason = + | 'unknown-repo' + | 'repo-path-mismatch' + | 'host-mismatch' + +type ValidationBackoffIdentity = { + repoId?: string | null + repoPath: string + reason: PRRefreshValidationDenialReason +} + +type ValidationBackoffEntry = { + expiresAt: number +} + +type ValidationBackoffCounters = { + recorded: number + skipped: number + expired: number +} + +const validationBackoff = new Map() +const counters: ValidationBackoffCounters = { + recorded: 0, + skipped: 0, + expired: 0 +} + +function validationIdentityKey(identity: ValidationBackoffIdentity): string { + return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0') +} + +function validationIdentityToken(key: string): string { + return createHash('sha256').update(key).digest('hex').slice(0, 12) +} + +function evictOldestValidationBackoffEntries(): void { + while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) { + const oldest = validationBackoff.keys().next() + if (oldest.done) { + break + } + validationBackoff.delete(oldest.value) + } +} + +function recordValidationBreadcrumb( + reason: PRRefreshValidationDenialReason, + result: 'recorded' | 'backoff', + token: string +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_validation_skip', + coalesceKey: `pr-refresh-validation:${reason}:${token}`, + minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS, + data: { + reason, + result, + token, + recorded: counters.recorded, + skipped: counters.skipped, + expired: counters.expired + } + }) +} + +export function notePRRefreshValidationDenial( + identity: ValidationBackoffIdentity, + nowMs = Date.now() +): 'validation-denied' | 'validation-backoff' { + const key = validationIdentityKey(identity) + const existing = validationBackoff.get(key) + const token = validationIdentityToken(key) + if (existing && existing.expiresAt > nowMs) { + counters.skipped += 1 + recordValidationBreadcrumb(identity.reason, 'backoff', token) + return 'validation-backoff' + } + if (existing) { + counters.expired += 1 + validationBackoff.delete(key) + } + counters.recorded += 1 + validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS }) + evictOldestValidationBackoffEntries() + recordValidationBreadcrumb(identity.reason, 'recorded', token) + return 'validation-denied' +} + +export function clearPRRefreshValidationBackoffForTests(): void { + validationBackoff.clear() + counters.recorded = 0 + counters.skipped = 0 + counters.expired = 0 +} + +export function getPRRefreshValidationBackoffCountForTests(): number { + return validationBackoff.size +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index f8670c4ebd3..dd7ec9c2697 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -885,6 +885,36 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) }) + it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + bypassEffectiveUpstreamNegativeCache: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + bypassEffectiveUpstreamNegativeCache: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 6f803ca737c..021ee8f4f1d 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -829,9 +829,19 @@ export function registerFilesystemHandlers( 'git:status', async ( _event, - args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean } + args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + } ): Promise => { - const options = { includeIgnored: args.includeIgnored ?? false } + const options = { + includeIgnored: args.includeIgnored ?? false, + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 687c0f8d0a7..d889120497c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ })) import { registerGitHubHandlers } from './github' +import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' type HandlerMap = Record unknown> @@ -237,6 +238,7 @@ describe('registerGitHubHandlers', () => { refreshPRNowMock.mockReset() reportVisiblePRRefreshCandidatesMock.mockReset() setPRRefreshOutcomeObserverMock.mockReset() + clearPRRefreshValidationBackoffForTests() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -293,6 +295,156 @@ describe('registerGitHubHandlers', () => { expect(getIssueMock).not.toHaveBeenCalled() }) + it('returns typed automatic PR refresh validation skips without enqueueing', async () => { + registerGitHubHandlers(store as never, stats as never) + const candidate = { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' as const + } + + const first = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + const second = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + + expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' }) + expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' }) + expect(first).not.toBe(false) + expect(second).not.toBe(false) + expect(enqueuePRRefreshMock).not.toHaveBeenCalled() + }) + + it('uses registered repo routing fields for automatic PR refresh candidates', async () => { + repos = [ + { + id: 'repo-ssh', + path: '/workspace/remote-repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real' + } + ] + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:enqueuePRRefresh'](null, { + candidate: { + cacheKey: 'remote::feature/test', + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + branch: 'feature/test', + repoKind: 'git', + connectionId: 'ssh-stale', + executionHostId: 'runtime:stale', + connectionState: 'disconnected', + localGitOptions: { wslDistro: 'Stale' } + }, + reason: 'active', + priority: 80 + }) + + const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0] + expect(candidate).toEqual( + expect.objectContaining({ + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real', + connectionState: 'connected' + }) + ) + expect(candidate).not.toHaveProperty('localGitOptions') + }) + + it('keeps manual PR refresh validation strict', async () => { + registerGitHubHandlers(store as never, stats as never) + + await expect( + handlers['gh:refreshPRNow'](null, { + candidate: { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' + } + }) + ).rejects.toThrow('Access denied: unknown repository path') + + expect(refreshPRNowMock).not.toHaveBeenCalled() + }) + + it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 7, once: vi.fn() } }, + { + generation: 1, + candidates: [ + { + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1', + branch: 'feature/test', + repoKind: 'git' + }, + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1' + }) + ], + 1, + 7 + ) + }) + + it('clears a sender visible PR refresh set when all current candidates are invalid', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 8, once: vi.fn() } }, + { + generation: 2, + candidates: [ + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8) + }) + it('rejects GitHub source context from a different host', async () => { registerGitHubHandlers(store as never, stats as never) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index f4c4b1a6401..87b873e73af 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -11,6 +11,7 @@ import type { GitHubOwnerRepo, GitHubPullRequestStateUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, PRRefreshOutcome } from '../../shared/types' @@ -62,6 +63,10 @@ import { import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' @@ -140,27 +145,53 @@ type RepoScopedArgs = { sourceContext?: TaskSourceContext | null } -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | RepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { const repoPath = typeof args === 'string' ? args : args.repoPath const repoId = typeof args === 'string' ? undefined : args.repoId const resolvedRepoPath = resolve(repoPath) - const repo = store - .getRepos() - .find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) + const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) if (!repo) { - throw new Error('Access denied: unknown repository path') + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } } if (repoId && resolve(repo.path) !== resolvedRepoPath) { - throw new Error('Access denied: repository path does not match repo id') + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } } if ( typeof args !== 'string' && args.sourceContext?.provider === 'github' && args.sourceContext.hostId !== getRepoExecutionHostId(repo) ) { - throw new Error('Access denied: GitHub source host does not match repository host') + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } } - return repo + return { kind: 'ok', repo } +} + +function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo } function repoConnectionId(repo: Repo): string | null { @@ -172,6 +203,50 @@ function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: strin return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } +function applyRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = localGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: repoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { + kind: 'skipped' + result: Extract + } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } +} + export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { @@ -246,16 +321,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:refreshPRNow', async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const outcome = await refreshPRNow({ - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }) + const repo = assertRegisteredRepo(args.candidate, store) + const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) recordPRIfNeeded(repo, outcome) return outcome } @@ -270,22 +337,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi reason: GitHubPRRefreshReason priority?: number } - ) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - enqueuePRRefresh( - { - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }, - args.reason, - args.priority ?? 0 - ) - return true + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0) + return { kind: 'queued' } } ) @@ -300,18 +358,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi clearVisiblePRRefreshWindow(senderId) }) } - const candidates = args.candidates.map((candidate) => { - const repo = assertRegisteredRepo(candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return { - ...candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : candidate.connectionState + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) } - }) + } reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) return true } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 2ebcf99053c..a6fd5911fa2 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -69,6 +69,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown' } + mux.request.mockResolvedValue(statusResult) + + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }) + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', { + worktreePath: '/home/user/repo', + bypassEffectiveUpstreamNegativeCache: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/repo' + }) + }) + it('checkIgnoredPaths sends git.checkIgnored request', async () => { mux.request.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index b9495dd7e7c..9c93c46e6d5 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -3,7 +3,7 @@ indirection — every method is a 1:1 forwarder to a relay RPC plus a small amount of param plumbing. */ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IGitProvider } from './types' +import type { GitProviderStatusOptions, IGitProvider } from './types' import type { GitStatusResult, GitDiffResult, @@ -82,12 +82,16 @@ export class SshGitProvider implements IGitProvider { async getStatus( worktreePath: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} return (await this.mux.request('git.status', { worktreePath, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs })) as GitStatusResult } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1a11df9e1af..c8a090e59fa 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -165,8 +165,13 @@ export type IFilesystemProvider = { // ─── Git Provider ─────────────────────────────────────────────────── +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean +} + export type IGitProvider = { - getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index c2cd775ac0e..e39ef000eea 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -23,6 +23,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' +import type { GitProviderStatusOptions } from '../providers/types' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' import { abortMerge, @@ -164,7 +165,7 @@ export class RuntimeGitCommands { async getRuntimeGitStatus( worktreeSelector: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index ca80c969709..c94c3349c6c 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -8,7 +8,8 @@ export const WorktreeSelector = z.object({ }) export const GitStatusParams = WorktreeSelector.extend({ - includeIgnored: z.boolean().optional() + includeIgnored: z.boolean().optional(), + bypassEffectiveUpstreamNegativeCache: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index b5937d8fc61..c6307c0aa63 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -55,6 +55,32 @@ describe('git RPC methods', () => { }) }) + it('forwards upstream-negative-cache bypass for status requests', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.status', { + worktree: 'id:wt-1', + bypassEffectiveUpstreamNegativeCache: true + }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + bypassEffectiveUpstreamNegativeCache: true + }) + expect(response).toMatchObject({ + ok: true, + result: { entries: [] } + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 06cc3205452..964f7d09ce3 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -87,10 +87,23 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => - params.includeIgnored === undefined + handler: async (params, { runtime }) => { + const options = + params.includeIgnored === undefined && + params.bypassEffectiveUpstreamNegativeCache === undefined + ? undefined + : { + ...(params.includeIgnored === undefined + ? {} + : { includeIgnored: params.includeIgnored }), + ...(params.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } + return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) - : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) + : runtime.getRuntimeGitStatus(params.worktree, options) + } }), defineMethod({ name: 'git.checkIgnored', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e57c86aa041..55c32a9a5d9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -115,6 +115,7 @@ import type { FloatingTerminalCwdRequest, GitHubIssueUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, GitHubPRRefreshReason, GetRateLimitResult, @@ -1156,7 +1157,7 @@ export type PreloadApi = { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }) => Promise + }) => Promise reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number @@ -2107,6 +2108,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }) => Promise checkIgnored: (args: { worktreePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7852bd91f31..7936edb853b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2536,6 +2536,7 @@ const api = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }): Promise => ipcRenderer.invoke('git:status', args), checkIgnored: (args: { worktreePath: string diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index d55e86d7584..5c409f4c943 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -30,6 +30,7 @@ describe('getStatusOp', () => { }) afterEach(async () => { + vi.useRealTimers() clearNoEffectiveUpstreamStatusCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -109,6 +110,34 @@ describe('getStatusOp', () => { ).toHaveLength(1) }) + it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + vi.setSystemTime(31_000) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + }) + it('coalesces concurrent no-effective-upstream probes', async () => { const git = vi.fn(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 8619e6fd1e9..03fe7c35b69 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -136,7 +136,10 @@ export async function getStatusOp( try { upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, - (args) => git(args, worktreePath) + (args) => git(args, worktreePath), + { + bypassCache: params.bypassEffectiveUpstreamNegativeCache === true + } ) } catch { // Why: status polling should keep returning working-tree entries even diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..e232f791265 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNoEffectiveUpstreamStatusCache, + clearNoEffectiveUpstreamStatusCacheEntry, + getNoEffectiveUpstreamStatusCacheCountForTests, + getNoEffectiveUpstreamStatusGenerationCountForTests, + readOrProbeNoEffectiveUpstreamStatus +} from './git-status-upstream-negative-cache' + +describe('relay upstream negative cache', () => { + beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + afterEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + it('bypasses cached no-effective-upstream status when requested', async () => { + let originBranchExists = false + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + originBranchExists = true + const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + + expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic).toEqual(first) + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic).toEqual(strict) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic).toEqual(strict) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearNoEffectiveUpstreamStatusCacheEntry(identity) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(nextAutomatic).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds no-effective-upstream entries', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index ec1764891c8..a9dbec3175c 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -2,7 +2,8 @@ import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' import type { GitCommandRunner } from '../shared/git-effective-upstream' import type { GitUpstreamStatus } from '../shared/types' -const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 30_000 +const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 +const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 type NoEffectiveUpstreamCacheIdentity = { worktreePath: string @@ -17,6 +18,8 @@ type NoEffectiveUpstreamCacheEntry = { const noEffectiveUpstreamByIdentity = new Map() const noEffectiveUpstreamInFlight = new Map>() +const retiredNoEffectiveUpstreamInFlight = new Map>() +const noEffectiveUpstreamWriteGeneration = new Map() function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') @@ -37,16 +40,40 @@ function readCachedNoEffectiveUpstreamStatus( return entry.status } +function hasPendingNoEffectiveUpstreamProbe(cacheKey: string): boolean { + return ( + noEffectiveUpstreamInFlight.has(cacheKey) || retiredNoEffectiveUpstreamInFlight.has(cacheKey) + ) +} + +function trimNoEffectiveUpstreamWriteGeneration(): void { + for (const cacheKey of noEffectiveUpstreamWriteGeneration.keys()) { + if (noEffectiveUpstreamWriteGeneration.size <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { + break + } + if (hasPendingNoEffectiveUpstreamProbe(cacheKey)) { + continue + } + noEffectiveUpstreamWriteGeneration.delete(cacheKey) + } +} + function cacheNoEffectiveUpstreamStatus( cacheKey: string, status: GitUpstreamStatus, probedSameNameOriginRef: boolean, + writeGeneration: number, nowMs = Date.now() ): void { // Why: hasConfiguredPushTarget controls publish behavior; keep that signal // fresh rather than serving a stale positive from status polling. if (status.hasUpstream || status.hasConfiguredPushTarget) { noEffectiveUpstreamByIdentity.delete(cacheKey) + noEffectiveUpstreamWriteGeneration.set(cacheKey, writeGeneration + 1) + trimNoEffectiveUpstreamWriteGeneration() + return + } + if ((noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { return } // Why: only cache negatives after probing origin/; other resolution @@ -58,39 +85,55 @@ function cacheNoEffectiveUpstreamStatus( status, expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS }) + while (noEffectiveUpstreamByIdentity.size > MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { + const oldest = noEffectiveUpstreamByIdentity.keys().next() + if (oldest.done) { + break + } + noEffectiveUpstreamByIdentity.delete(oldest.value) + noEffectiveUpstreamWriteGeneration.delete(oldest.value) + } + trimNoEffectiveUpstreamWriteGeneration() } export async function readOrProbeNoEffectiveUpstreamStatus( identity: NoEffectiveUpstreamCacheIdentity, - runGit: GitCommandRunner + runGit: GitCommandRunner, + options: { bypassCache?: boolean } = {} ): Promise { const cacheKey = noEffectiveUpstreamCacheKey(identity) - const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) - if (cachedStatus) { - return cachedStatus - } + if (options.bypassCache !== true) { + const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) + if (cachedStatus) { + return cachedStatus + } - const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) - if (inFlight) { - return inFlight + const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } } let probedSameNameOriginRef = false + const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0 const probe = getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { probedSameNameOriginRef = true } return runGit(args) }).then((status) => { - cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef) + cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration) return status }) - noEffectiveUpstreamInFlight.set(cacheKey, probe) + if (options.bypassCache !== true) { + noEffectiveUpstreamInFlight.set(cacheKey, probe) + } try { return await probe } finally { if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { noEffectiveUpstreamInFlight.delete(cacheKey) + trimNoEffectiveUpstreamWriteGeneration() } } } @@ -98,4 +141,43 @@ export async function readOrProbeNoEffectiveUpstreamStatus( export function clearNoEffectiveUpstreamStatusCache(): void { noEffectiveUpstreamByIdentity.clear() noEffectiveUpstreamInFlight.clear() + retiredNoEffectiveUpstreamInFlight.clear() + noEffectiveUpstreamWriteGeneration.clear() +} + +export function clearNoEffectiveUpstreamStatusCacheEntry( + identity: NoEffectiveUpstreamCacheIdentity +): void { + const cacheKey = noEffectiveUpstreamCacheKey(identity) + retireNoEffectiveUpstreamProbe(cacheKey) + noEffectiveUpstreamByIdentity.delete(cacheKey) + noEffectiveUpstreamInFlight.delete(cacheKey) + noEffectiveUpstreamWriteGeneration.set( + cacheKey, + (noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireNoEffectiveUpstreamProbe(cacheKey: string): void { + const retiredProbe = noEffectiveUpstreamInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredNoEffectiveUpstreamInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredNoEffectiveUpstreamInFlight.get(cacheKey) === retiredProbe) { + retiredNoEffectiveUpstreamInFlight.delete(cacheKey) + trimNoEffectiveUpstreamWriteGeneration() + } + }) + .catch(() => undefined) +} + +export function getNoEffectiveUpstreamStatusCacheCountForTests(): number { + return noEffectiveUpstreamByIdentity.size +} + +export function getNoEffectiveUpstreamStatusGenerationCountForTests(): number { + return noEffectiveUpstreamWriteGeneration.size } diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 1613f3c8b54..5fe28efa4be 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -1,19 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult } from '../../../../shared/types' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' function makeDeps(): GitStatusRefreshDeps { return { setGitStatus: vi.fn(), updateWorktreeGitIdentity: vi.fn(), setUpstreamStatus: vi.fn(), - fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined) + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) } } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + describe('refreshGitStatusForWorktree', () => { beforeEach(() => { vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() }) it('stores status, branch identity, and upstream data from git status', async () => { @@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) }) + it('bypasses automatic no-upstream backoff only for strict refreshes', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-normal', + worktreePath: '/repo', + deps + }) + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + it('does not let an older automatic upstream result overwrite a strict result', async () => { + const automaticStatus = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(automaticStatus.promise) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + automaticStatus.resolve(staleAutomaticStatus) + await automatic + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus) + expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) + }) + + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { + const automaticFetch = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 } + const gitStatus = vi + .fn() + .mockResolvedValueOnce({ + entries: [], + conflictOperation: 'unknown' + } satisfies GitStatusResult) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise) + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + automaticFetch.resolve(staleAutomaticUpstream) + await automatic + + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith( + 'wt-fetch-race', + strictStatus.upstreamStatus + ) + }) + it('clears stale branch identity when git status reports detached HEAD', async () => { const status: GitStatusResult = { entries: [], diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 89d4ec4f662..4c5b8fb7ace 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = { worktreePath: string, connectionId?: string, pushTarget?: GitPushTarget, - options?: { runtimeTargetSettings?: Pick | null } - ) => Promise + options?: { + runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean + } + ) => Promise +} + +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +function beginAutomaticUpstreamRefresh(worktreeId: string): number { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 +} + +function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { + return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration +} + +async function fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration +}: { + settings?: Pick | null + worktreeId: string + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + deps: GitStatusRefreshDeps + startGeneration: number +}): Promise { + const upstreamStatus = await deps.fetchUpstreamStatus( + worktreeId, + worktreePath, + connectionId, + pushTarget, + { + runtimeTargetSettings: settings, + applyUpstreamStatus: false + } + ) + if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + deps.setUpstreamStatus(worktreeId, upstreamStatus) + } +} + +function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() } export async function refreshGitStatusForWorktree({ @@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({ pushTarget?: GitPushTarget deps: GitStatusRefreshDeps }): Promise { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + try { + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, + worktreePath, + connectionId + })) as GitStatusResult - deps.setGitStatus(worktreeId, status) - // Why: branch switches can happen inside a terminal. `git status --branch` - // gives us the new identity without a separate worktree-list poll. - deps.updateWorktreeGitIdentity(worktreeId, { - head: status.head, - // Why: detached HEAD reports a head oid and no branch. Pass null as an - // explicit clear signal so stale branch names don't linger in the UI. - branch: status.branch ?? (status.head ? null : undefined) - }) - if (pushTarget) { - // Why: porcelain status reports Git's configured upstream. Source Control - // actions for PR-created worktrees must instead reconcile with Orca's - // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: settings + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + return + } + + deps.setGitStatus(worktreeId, status) + // Why: branch switches can happen inside a terminal. `git status --branch` + // gives us the new identity without a separate worktree-list poll. + deps.updateWorktreeGitIdentity(worktreeId, { + head: status.head, + // Why: detached HEAD reports a head oid and no branch. Pass null as an + // explicit clear signal so stale branch names don't linger in the UI. + branch: status.branch ?? (status.head ? null : undefined) }) - return - } - if (status.upstreamStatus) { - if ( - status.upstreamStatus.ahead > 0 && - status.upstreamStatus.behind > 0 && - status.upstreamStatus.behindCommitsArePatchEquivalent === undefined - ) { - // Why: porcelain status has counts but cannot tell stale post-rebase - // upstream commits from real remote work. Writing it first makes the - // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings + if (pushTarget) { + // Why: porcelain status reports Git's configured upstream. Source Control + // actions for PR-created worktrees must instead reconcile with Orca's + // explicit publish target. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) - return + if (status.upstreamStatus) { + if ( + status.upstreamStatus.ahead > 0 && + status.upstreamStatus.behind > 0 && + status.upstreamStatus.behindCommitsArePatchEquivalent === undefined + ) { + // Why: porcelain status has counts but cannot tell stale post-rebase + // upstream commits from real remote work. Writing it first makes the + // primary action flicker between Sync and Force Push on every poll. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + deps, + startGeneration: upstreamStartGeneration + }) + return + } + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + return + } + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration + }) + } finally { + finishAutomaticUpstreamRefresh(worktreeId) } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings - }) } export async function refreshGitStatusForWorktreeStrict({ @@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({ fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus'] } }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + beginStrictUpstreamRefresh(worktreeId) + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + { + // Why: strict refreshes are user-triggered reconciliation and must not reuse + // automatic polling's no-upstream backoff window. + bypassEffectiveUpstreamNegativeCache: true + } + )) as GitStatusResult deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 9f045022168..c534a7859f7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => { undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) @@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => { undefined, pushTarget, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8dabf867757..d4f1f57abc4 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -132,6 +132,37 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: false } + ) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -262,6 +293,31 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', bypassEffectiveUpstreamNegativeCache: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index f7c9c02f06d..b0795718ac2 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -121,21 +121,29 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean } + options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ worktreePath: context.worktreePath, connectionId: context.connectionId, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs }) } return callRuntimeRpc( target, 'git.status', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs }, + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs + }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 3780be8c6e3..213f575e25e 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -295,6 +295,7 @@ type EditorOpenTargetOptions = { type GitRuntimeOperationOptions = { runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean } export type PendingEditorReveal = { @@ -596,7 +597,7 @@ export type EditorSlice = { connectionId?: string, pushTarget?: GitPushTarget, options?: GitRuntimeOperationOptions - ) => Promise + ) => Promise pushBranch: ( worktreeId: string, worktreePath: string, @@ -3540,7 +3541,10 @@ export const createEditorSlice: StateCreator = (s }, pushTarget ) - get().setUpstreamStatus(worktreeId, status) + if (options?.applyUpstreamStatus !== false) { + get().setUpstreamStatus(worktreeId, status) + } + return status } catch (error) { // Why: on error we leave the prior status in place rather than writing a // synthetic {hasUpstream:false} — that would flash 'Publish Branch' on a @@ -3549,6 +3553,7 @@ export const createEditorSlice: StateCreator = (s // genuinely newly unpublished, the polling effect will eventually correct // the status on success. console.error('fetchUpstreamStatus failed', error) + return null } }, pushBranch: async ( diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 372ddfd9bb4..823ed97e0ec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2967,6 +2967,75 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { }) }) + it('does not direct-fetch when enqueue returns an automatic validation skip', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ + kind: 'skipped', + skippedReason: 'validation-denied' + }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() + }) + + it('direct-fetches when enqueue returns an explicit fallback result', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'fallback' }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 1 }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1) + }) + it('enqueues active PR refresh with a GitHub hosted-review fallback number', () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index cfd032f969d..495eaef72b7 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -3441,7 +3441,7 @@ export const createGitHubSlice: StateCreator = (s if (enqueue) { void enqueue({ candidate, reason, priority }) .then((queued) => { - if (queued === false) { + if (queued === false || queued.kind === 'fallback') { return get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, diff --git a/src/shared/types.ts b/src/shared/types.ts index 20db64abc6c..c56b9af4acc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1125,6 +1125,11 @@ export type PRRefreshOutcome = export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + export type GitHubPRRefreshAlias = { cacheKey: string repoId?: string From ce403c09d5b21a40c696eb695af41098b9afaca2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:18:46 -0700 Subject: [PATCH 52/57] Add GitHub task close reason parity (#6141) Co-authored-by: Orca --- docs/github-tasks-close-reason-parity.md | 137 +++ src/main/github/issues.test.ts | 48 + .../github/project-view/mutations.test.ts | 121 +++ src/main/github/project-view/mutations.ts | 69 +- .../src/components/GitHubItemDialog.tsx | 992 ++++++++++++------ .../src/components/PullRequestPage.tsx | 587 +++++++---- src/renderer/src/components/TaskPage.tsx | 505 +++++++-- .../task-page-github-status-actions.test.ts | 88 ++ .../task-page-github-status-actions.ts | 102 ++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- 14 files changed, 2141 insertions(+), 633 deletions(-) create mode 100644 docs/github-tasks-close-reason-parity.md create mode 100644 src/main/github/project-view/mutations.test.ts create mode 100644 src/renderer/src/components/task-page-github-status-actions.test.ts create mode 100644 src/renderer/src/components/task-page-github-status-actions.ts diff --git a/docs/github-tasks-close-reason-parity.md b/docs/github-tasks-close-reason-parity.md new file mode 100644 index 00000000000..dea16edfcec --- /dev/null +++ b/docs/github-tasks-close-reason-parity.md @@ -0,0 +1,137 @@ +# GitHub Tasks Close Reason Parity + +## Problem + +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action. +- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI. +- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not. + +## Goal + +Bring the GitHub Tasks list status menu closer to GitHub: + +1. Closed issue UI must stop using destructive red styling. +2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu. +3. Duplicate closes must require a target issue number and pass it as `duplicateOf`. +4. Reopening still works from the same status menu. +5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row. + +## Non-goals + +- No provider-generic changes for GitLab, Linear, or Jira. +- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback. +- No schema changes or cache migration. +- No change to PR status behavior. +- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows. + +## Design + +1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons. +2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`. +3. Route issue-state mutations by issue identity: + - If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo. + - Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`. +4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback. +5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it. +6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers. + +## API Notes + +- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass: + - `--reason completed` for `stateReason: 'completed'` + - `--reason "not planned"` for `stateReason: 'not_planned'` + - `--duplicate-of ` for `stateReason: 'duplicate'` with `duplicateOf` +- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body. +- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded. +- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`. + +## Data Flow + +- User opens a GitHub issue status pill. +- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`. +- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`. +- Cell optimistically patches local state and store row state. +- Mutation goes through local IPC or runtime RPC with the same repo/source context. +- Failure reverts the local/store state and shows the existing error toast. +- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch. + +## Edge Cases + +- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter. +- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository. +- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does. +- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice. +- SSH/runtime target: payload must remain serializable and use the existing runtime method. +- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows. +- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree. +- PR rows and rows without a repo continue to render a non-editable status pill. +- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number. + +## Test Plan + +- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets. +- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets. +- Existing unit: optimistic status draft reconciliation remains unchanged. +- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of `, and `gh issue reopen`. +- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields. +- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable. +- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely. +- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red. +- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data. +- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row. +- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data. + +## UI Quality Bar + +- Popover should feel like an action menu, not an error/destructive confirmation. +- Closed issue pill must be visually distinct from open but not red/destructive. +- Duplicate picker must fit in the popover at desktop and narrow widths without clipping. +- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens. + +## Review Screenshots + +1. GitHub Tasks issue row with status popover open showing all close reasons. +2. Duplicate close second-step picker with search and issue candidates. +3. Closed issue row/pill showing non-red completion styling. +4. GitHub issue detail sidebar status popover showing all close reasons. +5. GitHub issue detail duplicate close second-step picker. +6. Adjacent smoke: GitHub Tasks header/search area still renders normally. + +## Rollout + +1. Add status menu model and tests. +2. Update `GHStatusCell` UI and mutation payload. +3. Run focused tests, typecheck, and lint. +4. Run UI quality review. +5. Validate in Electron and capture screenshots. + +## Lightweight Eng Review + +- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work. +- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo. +- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`. +- Failure modes covered: + - Invalid duplicate targets stay local and do not mutate. + - Failed mutations revert optimistic state using the existing request id guard. + - Runtime/SSH remains on the existing serializable RPC method. + - Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`. + - Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success. +- Test coverage required: + - Unit tests for close update payloads and duplicate validation. + - Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation. + - Renderer routing tests for slug-addressed versus repo-path-addressed issue rows. + - Cache invalidation/refetch coverage for status-filtered Tasks lists. + - Existing status draft tests for optimistic reconciliation. + - Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke. +- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC. +- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap. +- Required review screenshots: + 1. Status popover with close reasons. + 2. Duplicate second-step picker with search and issue candidates. + 3. Non-red closed status pill. + 4. Tasks header/search smoke. +- Residual risks: + - Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues. + - Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs. diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 1c8e5c5596f..f68726b395a 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -251,4 +251,52 @@ describe('issue source operations', () => { { cwd: '/repo-root' } ) }) + + it('closes issues with completed, not planned, and duplicate reasons', async () => { + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 926, { + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 99 + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], + { cwd: '/repo-root' } + ) + }) + + it('reopens issues through gh issue reopen', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({ + ok: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + }) }) diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts new file mode 100644 index 00000000000..9d8cabca7f8 --- /dev/null +++ b/src/main/github/project-view/mutations.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ + acquireMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + releaseMock: vi.fn(), + runRestMock: vi.fn() +})) + +vi.mock('./internals', () => ({ + acquire: acquireMock, + release: releaseMock, + extractExecError: (err: unknown) => ({ + stderr: err instanceof Error ? err.message : String(err), + stdout: '' + }), + ghExecFileAsync: ghExecFileAsyncMock, + rateLimitGuard: () => ({ blocked: false }), + noteRateLimitSpend: vi.fn(), + classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), + rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), + runGraphql: vi.fn(), + runRest: runRestMock, + validateSlugArgs: (owner: string, repo: string) => + owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, + assertPositiveInt: (value: number, name: string) => + Number.isInteger(value) && value > 0 + ? { ok: true, value } + : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } +})) + +import { updateIssueBySlug } from './mutations' + +describe('updateIssueBySlug', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + releaseMock.mockReset() + runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) + }) + + it('closes slug-addressed issues with completed and not planned reasons via gh issue close', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'completed' } + }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 13, + updates: { state: 'closed', stateReason: 'not_planned' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate', duplicateOf: 99 } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('rejects duplicate closes without a target before spawning gh', async () => { + const result = await updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate' } + }) + + expect(result).toMatchObject({ + ok: false, + error: { type: 'validation_error' } + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('reopens slug-addressed issues via gh issue reopen', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'open' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '12', '--repo', 'acme/widgets'], + { encoding: 'utf-8' } + ) + }) +}) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index cd72c9e8ce1..e2da3dae068 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -183,17 +183,63 @@ export async function updateIssueBySlug( removeAssignees } = args.updates - // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target is only valid when closing as duplicate.' + } + } + } + if (state === 'closed' && stateReason === 'duplicate' && duplicateOf === undefined) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target issue number is required.' + } + } + } + if (duplicateOf !== undefined) { + const duplicate = assertPositiveInt(duplicateOf, 'duplicateOf') + if (!duplicate.ok) { + return { ok: false, error: duplicate.error } + } + } + + // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. + // State uses gh issue close/reopen so duplicate closes can record a target. // Labels/assignees go through their dedicated endpoints. const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + if (state !== undefined) { + const stateArgs = + state === 'closed' + ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] + : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] + if (state === 'closed') { + if (stateReason === 'completed') { + stateArgs.push('--reason', 'completed') + } else if (stateReason === 'not_planned') { + stateArgs.push('--reason', 'not planned') + } else if (stateReason === 'duplicate') { + stateArgs.push('--duplicate-of', String(duplicateOf)) + } + } + await acquire() + try { + await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + } catch (err) { + const { stderr, stdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, stdout) } + } finally { + release() + } + } + // 1) PATCH body - if ( - title !== undefined || - body !== undefined || - state !== undefined || - stateReason !== undefined - ) { + if (title !== undefined || body !== undefined) { const patchArgs: string[] = ['-X', 'PATCH', base] if (title !== undefined) { patchArgs.push('--raw-field', `title=${title}`) @@ -201,15 +247,6 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - if (state !== undefined) { - patchArgs.push('--raw-field', `state=${state}`) - } - if (stateReason !== undefined) { - patchArgs.push('--raw-field', `state_reason=${stateReason}`) - } - if (duplicateOf !== undefined) { - patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`) - } const r = await runRest(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c35ddee07af..a417419855e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -17,10 +17,12 @@ import { ArrowDown, ArrowRight, ArrowUp, + Ban, Braces, Check, ChevronDown, ChevronLeft, + ChevronRight, CircleDashed, CircleDot, Copy, @@ -38,10 +40,10 @@ import { Pencil, Plus, RefreshCw, + Search, Send, Settings, UndoDot, - Users, Wrench, X } from 'lucide-react' @@ -59,7 +61,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -159,6 +161,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -193,6 +196,13 @@ import { import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -354,7 +364,9 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + // Why: closed issues can mean completed/resolved; keep them neutral instead + // of using destructive red, which is reserved for PR closed-without-merge. + return 'border-ring/50 bg-primary/10 text-foreground' } return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } @@ -386,25 +398,15 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - if (avatarUrl) { - return ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover" + /> ) } @@ -447,6 +449,239 @@ function buildRequestedReviewUsers( return Array.from(byLogin.values()) } +function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + sourceContext, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + sourceContext?: TaskSourceContext | null + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => item.assignees ?? [] + ) + const [assigneesSource, setAssigneesSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + assignees: item.assignees + })) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const { isPending, run } = useImmediateMutation() + + // Why: PR assignees can change through background refetches; sync them + // before paint so the right rail never shows a stale reviewer/assignee split. + if ( + assigneesSource.itemId !== item.id || + assigneesSource.repoId !== item.repoId || + assigneesSource.assignees !== item.assignees + ) { + setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) + setLocalAssignees(item.assignees ?? []) + } + + const patchProjectRowIfNeeded = useCallback( + (assignees: string[]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) + }, + [patchProjectRowContent, projectOrigin] + ) + const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) + const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null + const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assigneeLogins, + sourceSettings + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath + const canEditAssignees = Boolean(projectOrigin || repoPath) + const assigneesByLogin = useMemo( + () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), + [repoAssignees.data] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + const lowerLogin = login.toLowerCase() + const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) + const prevAssignees = localAssignees + const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } + const nextAssignees = isAssigned + ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) + : [...prevAssignees, candidate] + const nextLogins = nextAssignees.map((user) => user.login) + const prevLogins = prevAssignees.map((user) => user.login) + + run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(prevLogins) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + }, + [ + assigneesByLogin, + item.id, + item.number, + item.repoId, + localAssignees, + onMutated, + patchProjectRowIfNeeded, + patchWorkItem, + projectOrigin, + repoPath, + run, + sourceContext + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} + function PRReviewersPanel({ item, loading, @@ -462,8 +697,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -663,26 +896,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -856,9 +1069,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -936,165 +1146,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} +
+ )} + ) } @@ -2794,7 +2979,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ (null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) ) @@ -4723,6 +4940,33 @@ function GHEditSection({ const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = useMemo(() => { + if (projectOrigin) { + return `${projectOrigin.owner}/${projectOrigin.repo}` + } + const parsed = parseOwnerRepoFromItemUrl(item.url) + return parsed + ? `${parsed.owner}/${parsed.repo}` + : translate('auto.components.TaskPage.repository', 'Repository') + }, [item.url, projectOrigin]) const handleOpenOrUseWorkspace = useCallback((): void => { if (onOpenOrUse) { onOpenOrUse(item) @@ -4742,7 +4986,7 @@ function GHEditSection({ }, [assigneesItemKey, assignees]) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { if (newState === localState) { return } @@ -4755,7 +4999,10 @@ function GHEditSection({ sourceContext, projectOrigin, number: item.number, - updates: { state: newState } + updates: + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } }), onOptimistic: () => { onStateChange(newState) @@ -4792,6 +5039,42 @@ function GHEditSection({ ] ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setStatusPopoverOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { + setStatusPopoverOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + const handleLabelToggle = useCallback( (label: string) => { const isAdding = !localLabels.includes(label) @@ -4947,6 +5230,196 @@ function GHEditSection({ ] ) + const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { + const isSidebar = variant === 'sidebar' + return ( + + + + + + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )} +
+
+ ) + } + if (item.type === 'pr') { return null } @@ -4971,51 +5444,7 @@ function GHEditSection({
{translate('auto.components.GitHubItemDialog.00ccdf9b5a', 'Status')}
- - - - - - - - - + {renderIssueStatusPopover('sidebar')} {/* Assignees */} @@ -5264,44 +5693,7 @@ function GHEditSection({ return (
{/* State */} - - - - - - - - - + {renderIssueStatusPopover('pill')} {/* Labels */} @@ -5571,29 +5963,37 @@ function GHCommentComposer({ const canSubmitComment = hasBoundedCommentBodyText(body) return ( -
+
void handleSubmit()} /> - + + + + + + {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 849fad04317..c42fdff9119 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -40,7 +40,6 @@ import { RefreshCw, Send, UndoDot, - Users, Wrench, X } from 'lucide-react' @@ -58,7 +57,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -155,6 +154,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' import { getCommentBodySubmitState, @@ -448,25 +448,15 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - if (avatarUrl) { - return ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover" + /> ) } @@ -509,6 +499,222 @@ function buildRequestedReviewUsers( return Array.from(byLogin.values()) } +function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: PullRequestPageProjectOrigin | undefined + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => item.assignees ?? [] + ) + const [assigneesSource, setAssigneesSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + assignees: item.assignees + })) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const { isPending, run } = useImmediateMutation() + + // Why: PR assignees can change through background refetches; sync them + // before paint so the right rail never shows a stale reviewer/assignee split. + if ( + assigneesSource.itemId !== item.id || + assigneesSource.repoId !== item.repoId || + assigneesSource.assignees !== item.assignees + ) { + setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) + setLocalAssignees(item.assignees ?? []) + } + + const patchProjectRowIfNeeded = useCallback( + (assignees: string[]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) + }, + [patchProjectRowContent, projectOrigin] + ) + const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) + const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null + const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assigneeLogins, + repoOwnerSettings + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings) + const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath + const canEditAssignees = Boolean(projectOrigin || repoPath) + const assigneesByLogin = useMemo( + () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), + [repoAssignees.data] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + const lowerLogin = login.toLowerCase() + const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) + const prevAssignees = localAssignees + const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } + const nextAssignees = isAssigned + ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) + : [...prevAssignees, candidate] + const nextLogins = nextAssignees.map((user) => user.login) + const prevLogins = prevAssignees.map((user) => user.login) + + run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: item.repoId, + repoPath, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId) + patchProjectRowIfNeeded(prevLogins) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + }, + [ + assigneesByLogin, + item.id, + item.number, + item.repoId, + localAssignees, + onMutated, + patchProjectRowIfNeeded, + patchWorkItem, + projectOrigin, + repoPath, + run + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.PullRequestPage.8ff5ae8866', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} + function PRReviewersPanel({ item, loading, @@ -522,8 +728,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -713,26 +917,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -898,9 +1082,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -978,162 +1159,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} +
+ )} + ) } @@ -2968,7 +3127,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ +
- + + + + + + {translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e9495d4c30f..fc56f6692c1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9,6 +9,7 @@ import { AlertCircle, ArrowDownUp, ArrowRight, + Ban, Check, CheckCircle2, ChevronDown, @@ -16,6 +17,7 @@ import { ChevronRight, CircleDot, Clock3, + Copy, EllipsisVertical, ExternalLink, Eye, @@ -106,6 +108,7 @@ import type { import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, + getGitHubPRReviewerRows, getGitHubPRReviewLabel, normalizeGitHubReviewerLogins, parseGitHubReviewerInputLogins, @@ -216,6 +219,13 @@ import { getTaskPageGitHubPRIconTone, isTaskPageGitHubDraftPR } from '@/components/task-page-github-work-item-status' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' import { createTaskPageJiraLoadFailureState, type TaskPageJiraLoadError @@ -231,6 +241,7 @@ import type { GitHubOwnerRepo, GitHubAssignableUser, GitHubPRMergeMethod, + GitHubIssueUpdate, GitHubWorkItem, GitLabTodo, GitLabWorkItem, @@ -1021,6 +1032,34 @@ function GHStatusCell({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const [statusStateDraft, setStatusStateDraft] = useState(() => + createTaskPageGitHubStatusStateDraft(item) + ) + const [open, setOpen] = useState(false) + const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) + const [duplicateSearch, setDuplicateSearch] = useState('') + const [duplicateError, setDuplicateError] = useState(null) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) ) @@ -1034,11 +1073,29 @@ function GHStatusCell({ : repoOwnerSettings, [repoOwnerSettings, sourceContext] ) - const [statusStateDraft, setStatusStateDraft] = useState(() => - createTaskPageGitHubStatusStateDraft(item) - ) - const [open, setOpen] = useState(false) const reqRef = useRef(0) + const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = parsedIssueLink?.slug + ? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}` + : (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository')) const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item) if (resolvedStatusStateDraft !== statusStateDraft) { @@ -1057,38 +1114,71 @@ function GHStatusCell({ ) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { - if (newState === localState || !repo || item.type !== 'issue') { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if (newState === localState || item.type !== 'issue') { + return + } + const parsedOwnerRepo = parsedIssueLink?.slug + if (!repo && !parsedOwnerRepo) { return } reqRef.current += 1 const reqId = reqRef.current + const updates: GitHubIssueUpdate = + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } updateLocalState(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) const target = getActiveRuntimeTarget(sourceSettings) - const runtimeRepoId = - sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id - const updatePromise = - target.kind === 'environment' - ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + const updatePromise = parsedOwnerRepo + ? target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( target, - 'github.updateIssue', - { repo: runtimeRepoId, number: item.number, updates: { state: newState } }, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + number: item.number, + updates + }, { timeoutMs: 30_000 } ) - : window.api.gh.updateIssue({ - repoPath: repo.path, - repoId: repo.id, - sourceContext, + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, number: item.number, - updates: { state: newState } + updates }) + : (() => { + if (!repo) { + throw new Error('No GitHub repository context available for this issue.') + } + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + target, + 'github.updateIssue', + { repo: runtimeRepoId, number: item.number, updates }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + number: item.number, + updates + }) + })() updatePromise .then((result) => { if (reqId !== reqRef.current) { return } - const typed = result as { ok?: boolean; error?: string } + const typed = result as { ok?: boolean; error?: string | { message?: string } } if (typed && typed.ok === false) { updateLocalState(newState === 'closed' ? 'open' : 'closed') patchWorkItem( @@ -1098,11 +1188,16 @@ function GHStatusCell({ { sourceContext } ) toast.error( - typed.error ?? - translate('auto.components.TaskPage.1c893195ac', 'Failed to update state') + typeof typed.error === 'string' + ? typed.error + : (typed.error?.message ?? + translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) ) return } + if (repo) { + useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path) + } useAppStore.getState().recordFeatureInteraction('github-tasks') }) .catch(() => { @@ -1121,61 +1216,236 @@ function GHStatusCell({ toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) }, - [item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState] + [ + item, + localState, + parsedIssueLink, + patchWorkItem, + repo, + sourceContext, + sourceSettings, + updateLocalState + ] ) - if (item.type !== 'issue' || !repo) { + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handlePopoverOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + + if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) { return } return ( - + - e.stopPropagation()}> - - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )}
) @@ -1195,47 +1465,27 @@ function formatPRDelta(item: GitHubWorkItem): string | null { return parts.length > 0 ? parts.join(' ') : null } -function getReviewTone(item: GitHubWorkItem): string { - if (item.reviewDecision === 'APPROVED') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' - } - if (item.reviewDecision === 'CHANGES_REQUESTED') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' - } - if (item.reviewRequests && item.reviewRequests.length > 0) { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' - } - return 'border-border/60 bg-background/70 text-muted-foreground' -} - function ReviewChipAvatar({ reviewer }: { reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { - if (reviewer?.avatarUrl) { + if (reviewer?.login) { + // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's + // public avatar endpoint keeps the list visual aligned with assignee cells. + const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` return ( ) } - if (reviewer?.login) { - return ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - ) - } - return + return } function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element { @@ -1731,16 +1981,16 @@ function getChecksLabel(item: GitHubWorkItem): string { return `${summary.passed}/${summary.total} passed` } -function getChecksTone(item: GitHubWorkItem): string { +function getChecksPillTone(item: GitHubWorkItem): string { const state = item.checksSummary?.state if (state === 'success') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200' } if (state === 'failure') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + return 'border-rose-500/40 bg-rose-500/10 text-rose-200' } if (state === 'pending') { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + return 'border-amber-500/40 bg-amber-500/10 text-amber-200' } return 'border-border/60 bg-background/70 text-muted-foreground' } @@ -1809,6 +2059,8 @@ function PRReviewCell({ const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ itemId: item.id, repoId: item.repoId, @@ -1831,6 +2083,7 @@ function PRReviewCell({ [repoOwnerSettings, sourceContext] ) const reviewerInputRef = useRef(null) + const reviewerTriggerRef = useRef(null) const reviewerInputFocusFrameRef = useRef(null) const cancelReviewerInputFocusFrame = useCallback((): void => { @@ -1989,6 +2242,8 @@ function PRReviewCell({ const itemWithLocalReviewRequests = { ...item, reviewRequests: localReviewRequests } const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests) + const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests) + const extraReviewerCount = Math.max(0, reviewerRows.length - 1) const hasReviewerMetadata = item.reviewDecision !== undefined || localReviewRequests.length > 0 || @@ -2123,6 +2378,16 @@ function PRReviewCell({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + const rect = reviewerTriggerRef.current?.getBoundingClientRect() + const gap = 8 + const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0 + const availableAbove = rect ? rect.top - gap : 0 + const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360))) + } setOpen(nextOpen) if (nextOpen) { cancelReviewerInputFocusFrame() @@ -2191,20 +2456,44 @@ function PRReviewCell({ event.stopPropagation()} onOpenAutoFocus={(event) => { event.preventDefault() @@ -2256,7 +2545,7 @@ function PRReviewCell({ }} />
-
+
{reviewerMetadata.loading ? (
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')} @@ -2372,7 +2661,7 @@ function PRChecksCell({ }} className={cn( 'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110', - getChecksTone(item) + getChecksPillTone(item) )} > @@ -2551,7 +2840,7 @@ function PRMergeCell({ )} > {merging ? ( - + ) : ( )} @@ -5290,6 +5579,7 @@ export default function TaskPage(): React.JSX.Element { ) const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) + const appliedTaskQuery = useMemo(() => parseTaskQuery(appliedTaskSearch), [appliedTaskSearch]) const selectedGitHubRepoExternalLink = useMemo(() => { if (selectedRepos.length !== 1) { return null @@ -6062,11 +6352,18 @@ export default function TaskPage(): React.JSX.Element { setTaskRefreshNonce((current) => current + 1) }, [activeGithubTaskKind, setTaskResumeState, taskSearchInput]) - const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { - const next = event.target.value - setTaskSearchInput(next) - setActiveTaskPreset(null) - }, []) + const handleTaskSearchChange = useCallback( + (event: React.ChangeEvent): void => { + const next = event.target.value + const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind) + setTaskSearchInput(next) + setActiveTaskPreset(null) + // Why: the visible rows are keyed by appliedTaskSearch, not the draft + // input. Hide stale rows as soon as the draft would change the query. + setTasksFiltering(scoped !== appliedTaskSearch) + }, + [activeGithubTaskKind, appliedTaskSearch] + ) const handleSetDefaultTaskPreset = useCallback( (presetId: TaskViewPresetId): void => { @@ -7884,7 +8181,7 @@ export default function TaskPage(): React.JSX.Element {
{ + it('builds completed and not planned close updates', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({ + state: 'closed', + stateReason: 'completed' + }) + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({ + state: 'closed', + stateReason: 'not_planned' + }) + }) + + it('builds duplicate close updates with a target issue number', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({ + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 42 + }) + }) + + it('validates duplicate targets before dispatch', () => { + expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({ + ok: true, + duplicateOf: 34 + }) + }) + + it('maps duplicate validation failures to localized messages', () => { + const t = (_key: string, fallback: string): string => fallback + const validation = validateTaskPageGitHubDuplicateTarget('same', 12) + + expect(validation.ok).toBe(false) + if (!validation.ok) { + expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe( + 'Use a whole issue number.' + ) + } + }) + + it('filters duplicate candidates to other matching issues', () => { + const items = [ + buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }), + buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }), + buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }), + buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' }) + ] + + expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([ + 34, 78 + ]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number) + ).toEqual([34, 78]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number) + ).toEqual([34]) + }) +}) + +function buildWorkItem(overrides: Partial): GitHubWorkItem { + return { + id: `item-${overrides.number ?? 1}`, + type: 'issue', + number: 1, + title: 'Issue', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/1', + labels: [], + updatedAt: '2026-06-22T00:00:00.000Z', + author: null, + repoId: 'repo-1', + ...overrides + } +} diff --git a/src/renderer/src/components/task-page-github-status-actions.ts b/src/renderer/src/components/task-page-github-status-actions.ts new file mode 100644 index 00000000000..1d3ed687767 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.ts @@ -0,0 +1,102 @@ +import type { + GitHubIssueCloseReason, + GitHubIssueUpdate, + GitHubWorkItem +} from '../../../shared/types' + +export type TaskPageGitHubCloseAction = + | { stateReason: Exclude } + | { stateReason: 'duplicate'; duplicateOf: number } + +export type TaskPageGitHubDuplicateValidation = + | { ok: true; duplicateOf: number } + | { + ok: false + reason: 'missing' | 'not_integer' | 'not_positive' | 'same_issue' + } + +export type TaskPageGitHubDuplicateValidationError = Extract< + TaskPageGitHubDuplicateValidation, + { ok: false } +> + +type TranslateDuplicateError = (key: string, fallback: string) => string + +export function buildTaskPageGitHubCloseUpdate( + action: TaskPageGitHubCloseAction +): GitHubIssueUpdate { + return { + state: 'closed', + stateReason: action.stateReason, + ...(action.stateReason === 'duplicate' ? { duplicateOf: action.duplicateOf } : {}) + } +} + +export function validateTaskPageGitHubDuplicateTarget( + value: string, + currentIssueNumber: number +): TaskPageGitHubDuplicateValidation { + const trimmed = value.trim() + if (!trimmed) { + return { ok: false, reason: 'missing' } + } + if (!/^\d+$/.test(trimmed)) { + return { ok: false, reason: 'not_integer' } + } + const duplicateOf = Number(trimmed) + if (!Number.isSafeInteger(duplicateOf) || duplicateOf <= 0) { + return { ok: false, reason: 'not_positive' } + } + if (duplicateOf === currentIssueNumber) { + return { ok: false, reason: 'same_issue' } + } + return { ok: true, duplicateOf } +} + +export function getTaskPageGitHubDuplicateTargetErrorMessage( + validation: TaskPageGitHubDuplicateValidationError, + translate: TranslateDuplicateError +): string { + switch (validation.reason) { + case 'missing': + return translate( + 'auto.components.TaskPage.duplicateIssueMissing', + 'Enter an issue number in this repository.' + ) + case 'not_integer': + return translate( + 'auto.components.TaskPage.duplicateIssueNotInteger', + 'Use a whole issue number.' + ) + case 'not_positive': + return translate( + 'auto.components.TaskPage.duplicateIssueNotPositive', + 'Use a positive issue number.' + ) + case 'same_issue': + return translate( + 'auto.components.TaskPage.duplicateIssueSameIssue', + 'Choose a different issue.' + ) + } +} + +export function getTaskPageGitHubDuplicateCandidates( + items: GitHubWorkItem[], + currentIssueNumber: number, + query: string +): GitHubWorkItem[] { + const normalizedQuery = query.trim().toLowerCase() + return items.filter((candidate) => { + if (candidate.type !== 'issue' || candidate.number === currentIssueNumber) { + return false + } + if (!normalizedQuery) { + return true + } + return ( + candidate.title.toLowerCase().includes(normalizedQuery) || + String(candidate.number).includes(normalizedQuery) + ) + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 885882707ec..176edb3940e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "Unmark viewed", "56ec6eafb7": "Open the PR details to view current reviewers.", "7f964a365a": "Remove reviewer", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Move", @@ -1602,7 +1605,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", "4ac8ff2275": "Open {{value0}} in Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5cfaa362c6b..50f55cca9ac 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "Desmarcar visto", "56ec6eafb7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -1602,7 +1605,25 @@ "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", "4ac8ff2275": "Abrir {{value0}} en Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Cerca", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 14cbf557eaf..9a0f98e8bc7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "閲覧済みのマークを外す", "56ec6eafb7": "PR の詳細を開いて、現在のレビュアーを表示します。", "7f964a365a": "レビュアーを削除", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "移動", @@ -1602,7 +1605,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", "4ac8ff2275": "{{value0}} を Jira で開く", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4478b7fa714..ed0b6ce346e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "본 표시 해제", "56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "7f964a365a": "리뷰어 삭제", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "이동", @@ -1602,7 +1605,25 @@ "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", "4ac8ff2275": "Jira에서 {{value0}} 열기", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0e6c0214a88..b67d1db3bf5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "取消标记已查看", "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "commentTooLarge": "评论过长,无法安全提交。", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1602,7 +1605,25 @@ "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", "4ac8ff2275": "在 Jira 中打开 {{value0}}", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "关闭", From a5aee31f6ecd87e576e69734e0d5cdb699a9757d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:05 +0000 Subject: [PATCH 53/57] release: v1.4.95-rc.0 [rc-slot:2026-06-23-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b08ed1e086..8cb01bd38bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94", + "version": "1.4.95-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4e63bb0e637646a64e8419ec4455b9362d2bb5a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:36:48 -0700 Subject: [PATCH 54/57] Keep pages usable after lazy chunk reload failures (#6206) --- ...bleRenderErrorBoundary.lazy-chunk.test.tsx | 112 ++++++++++++++++++ .../RecoverableRenderErrorBoundary.tsx | 4 + src/renderer/src/lib/lazy-with-retry.test.ts | 53 ++++++++- src/renderer/src/lib/lazy-with-retry.ts | 66 +++++++++-- 4 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx new file mode 100644 index 00000000000..a4730f59e92 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('RecoverableRenderErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const LazyRejectingImport = lazyWithRetry( + () => + Promise.reject( + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') + ), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).not.toHaveBeenCalled() + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenSurface(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'page.automations', + surface: 'page', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index afa7fbf3dc9..eddfbd14252 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react' import { AlertTriangle, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' import { translate } from '@/i18n/i18n' @@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component null const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") +const chunkFetchError = (): TypeError => + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') function spyOnReload(): ReturnType { const reload = vi.fn() @@ -107,21 +109,58 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('does NOT reload twice — re-throws once the guard is already set', async () => { + it('does NOT reload twice — wraps known chunk failures once the guard is already set', async () => { const reload = spyOnReload() window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') - const error = chunkParseError() + const error = chunkFetchError() const factory = vi.fn(() => Promise.reject(error)) const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toMatchObject({ + name: 'LazyChunkLoadError', + cause: error + }) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(true) + }) + + it('preserves the original error when the guarded failure is not a dynamic import failure', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = new Error('render bug from lazy module evaluation') + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) const assertion = expect(loaded).rejects.toBe(error) await vi.advanceTimersByTimeAsync(5000) await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) - it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + it('preserves bare parse errors so lazy module evaluation bugs still report', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) + }) + + it('fails closed with the original error when sessionStorage reads throw', async () => { const reload = spyOnReload() // Private-mode / sandboxed storage makes reads throw. The guard must treat // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. @@ -135,6 +174,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { @@ -166,7 +207,7 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('re-throws without reloading when there is no window (SSR / node)', async () => { + it('re-throws the original error without reloading when there is no window (SSR / node)', async () => { vi.stubGlobal('window', undefined) const error = chunkParseError() const factory = vi.fn(() => Promise.reject(error)) @@ -177,6 +218,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(factory).toHaveBeenCalledTimes(2) + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 5a09f6b2a95..7c3cc604382 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -18,6 +18,8 @@ type AnyComponent = ComponentType type LazyFactory = () => Promise<{ default: T }> +type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable' + export type LazyWithRetryOptions = { retries?: number baseDelayMs?: number @@ -25,6 +27,18 @@ export type LazyWithRetryOptions = { reloadKey?: string } +export class LazyChunkLoadError extends Error { + constructor(cause: unknown) { + super('Lazy chunk load failed after reload recovery was exhausted') + this.name = 'LazyChunkLoadError' + ;(this as { cause?: unknown }).cause = cause + } +} + +export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadError { + return error instanceof LazyChunkLoadError +} + // One recovery reload per session. The guard survives the reload itself (so we // never loop) but resets when the window/app closes, so a later launch — e.g. // after an update ships fresh chunks — can earn another reload. sessionStorage @@ -35,21 +49,26 @@ const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const DEFAULT_RETRIES = 2 const DEFAULT_BASE_DELAY_MS = 250 -function hasAttemptedChunkReload(): boolean { +function readChunkReloadGuardState(): ReloadGuardState { + if (typeof window === 'undefined') { + return 'unavailable' + } try { - return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted' } catch { - // Why: when sessionStorage is unavailable (private mode / sandboxed), fail - // closed (treat as already-reloaded) so we never risk an infinite reload loop. - return true + // Why: when storage is blocked we cannot prove a reload happened, but still + // fail closed on reloads so a broken chunk never loops. + return 'unavailable' } } -function markChunkReloadAttempted(): void { +function markChunkReloadAttempted(): boolean { try { window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + return true } catch { - // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + // A reload without a durable guard can loop, so treat write failure as unavailable. + return false } } @@ -71,6 +90,24 @@ const wait = (ms: number): Promise => new Promise((resolve) => setTimeout( // down, so the error fallback never flashes in the moment before the reload lands. const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) +function isKnownDynamicImportFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + if (error.name === 'ChunkLoadError') { + return true + } + + return [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /failed to load module script/i, + /loading chunk .+ failed/i + ].some((pattern) => pattern.test(error.message)) +} + export async function loadLazyWithRetry( factory: LazyFactory, options: LazyWithRetryOptions = {} @@ -91,8 +128,11 @@ export async function loadLazyWithRetry( } } - if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { - markChunkReloadAttempted() + const reloadGuardState = readChunkReloadGuardState() + if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') { + if (!markChunkReloadAttempted()) { + throw lastError + } recordReloadBreadcrumb( options.reloadKey ?? 'unknown', lastError instanceof Error ? lastError.message : String(lastError) @@ -101,8 +141,12 @@ export async function loadLazyWithRetry( return SUSPEND_UNTIL_RELOAD } - // Already reloaded once this session, or no window (SSR / node): re-throw so - // RecoverableRenderErrorBoundary catches and reports it instead of looping. + if (reloadGuardState === 'attempted' && isKnownDynamicImportFailure(lastError)) { + throw new LazyChunkLoadError(lastError) + } + + // No proven reload attempt (SSR / node / blocked storage) or unknown failure: + // re-throw the original error so normal error reporting semantics stay intact. throw lastError } From 9e88231434600fd952974aaced73cac132a2a276 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:38:49 -0700 Subject: [PATCH 55/57] Preserve remote host repo state (#6136) Co-authored-by: Orca --- .../src/lib/repo-runtime-owner.test.ts | 30 ++ src/renderer/src/lib/repo-runtime-owner.ts | 28 +- .../src/store/slices/repo-host-identity.ts | 56 +++ .../slices/repo-identity-reconcile.test.ts | 31 ++ .../store/slices/repo-identity-reconcile.ts | 5 +- .../slices/repo-reorder-host-split.test.ts | 11 + .../store/slices/repo-reorder-host-split.ts | 23 +- .../repos-host-identity-routing.test.ts | 345 ++++++++++++++++ src/renderer/src/store/slices/repos.ts | 288 +++++++++++--- .../src/store/slices/worktrees.test.ts | 83 ++++ src/renderer/src/store/slices/worktrees.ts | 372 +++++++++++++++--- 11 files changed, 1156 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/store/slices/repo-host-identity.ts create mode 100644 src/renderer/src/store/slices/repos-host-identity-routing.test.ts diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index fab3a7547f0..719c964676e 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -55,6 +55,36 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ).toBe('focused-runtime') }) + it('uses the focused host row when duplicate repo ids exist', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('does not silently choose a duplicate repo row when the focused host does not match', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'other-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('other-runtime') + }) + it('returns settings scoped to an explicit local repo owner', () => { expect( getSettingsForRepoRuntimeOwner( diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 81905e9d1b3..36b7cf757d2 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' import type { GlobalSettings, Repo } from '../../../shared/types' export type RepoRuntimeOwnerState = { @@ -6,6 +10,26 @@ export type RepoRuntimeOwnerState = { settings?: Pick | null } +function findRepoOwner( + state: RepoRuntimeOwnerState, + repoId: string +): Pick | null { + const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? [] + if (matchingRepos.length === 0) { + return null + } + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + const focusedHostId = getSettingsFocusedExecutionHostId(state.settings) + const focusedMatches = matchingRepos.filter( + (entry) => getRepoExecutionHostId(entry) === focusedHostId + ) + // Why: duplicate bare repo ids are only safe to route when focus selects one + // owner unambiguously; otherwise callers must avoid guessing a host. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} + export function getRuntimeEnvironmentIdForRepo( state: RepoRuntimeOwnerState, repoId: string | null | undefined @@ -13,7 +37,7 @@ export function getRuntimeEnvironmentIdForRepo( if (!repoId) { return null } - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoOwner(state, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts new file mode 100644 index 00000000000..92afdafc3ad --- /dev/null +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -0,0 +1,56 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +type RepoIdentityParts = Pick + +export function getRepoHostIdentity(repo: RepoIdentityParts): string { + return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) +} + +export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { + // Why: host ids and repo ids can contain punctuation; NUL keeps the composite + // key collision-free without escaping user/provider-owned strings. + return `${hostId}\0${repoId}` +} + +export function repoMatchesHostIdentity( + repo: RepoIdentityParts, + repoId: string, + hostId: string +): boolean { + return repo.id === repoId && getRepoExecutionHostId(repo) === hostId +} + +export function findRepoForHost( + repos: readonly RepoIdentityParts[], + repoId: string, + options: { + hostId?: ExecutionHostId | string | null + settings?: Pick | null + } = {} +): RepoIdentityParts | null { + const matchingRepos = repos.filter((repo) => repo.id === repoId) + if (matchingRepos.length === 0) { + return null + } + + if (options.hostId) { + return matchingRepos.find((repo) => getRepoExecutionHostId(repo) === options.hostId) ?? null + } + + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + + const focusedHostId = getSettingsFocusedExecutionHostId(options.settings) + const focusedMatches = matchingRepos.filter( + (repo) => getRepoExecutionHostId(repo) === focusedHostId + ) + // Why: when duplicate ids exist even within the focused host, mutating by bare + // id would be ambiguous. Let callers surface no owner instead of guessing. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index 71615147156..f8c334ff4a7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import fs from 'node:fs' import type { Repo } from '../../../../shared/types' import { reconcileFetchedRepos } from './repo-identity-reconcile' @@ -47,4 +48,34 @@ describe('reconcileFetchedRepos', () => { expect(result[0]).toBe(previous[0]) expect(result.map((r) => r.id)).toEqual(['a', 'b']) }) + + it('preserves same-id repo identity only within the matching host', () => { + const previous = [ + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }), + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }) + ] + const next = [ + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }), + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }) + ] + + const result = reconcileFetchedRepos(previous, next) + + expect(result[0]).toBe(previous[1]) + expect(result[1]).toBe(previous[0]) + }) + + it('uses map-style host identity lookups for large duplicate refreshes', () => { + const previous = Array.from({ length: 1000 }, (_, index) => + makeRepo(`repo-${index}`, { executionHostId: index % 2 === 0 ? 'local' : 'runtime:env-1' }) + ) + const next = previous.map((repo) => ({ ...repo })) + + const result = reconcileFetchedRepos(previous, next) + + expect(result).toBe(previous) + expect(result.every((repo, index) => repo === previous[index])).toBe(true) + const source = fs.readFileSync(new URL('./repo-identity-reconcile.ts', import.meta.url), 'utf8') + expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) + }) }) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 55d313e0cd6..119c4ed3e2a 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../../shared/types' +import { getRepoHostIdentity } from './repo-host-identity' // Why: after a drag-reorder we optimistically set `repos`, persist, and main // broadcasts `repos:changed`. The renderer's own echo handler refetches, which @@ -27,10 +28,10 @@ function areReposEqual(a: Repo, b: Repo): boolean { } export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { - const previousById = new Map(previous.map((repo) => [repo.id, repo])) + const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { - const existing = previousById.get(repo.id) + const existing = previousById.get(getRepoHostIdentity(repo)) if (existing && areReposEqual(existing, repo)) { if (existing !== previous[index]) { identical = false diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts index 8ba15f5edd9..fd4ace117f3 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -43,4 +43,15 @@ describe('splitRepoReorderByHost', () => { }) expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) }) + + it('routes duplicate bare ids by occurrence in current repo order', () => { + const repos = [repo('same', 'local'), repo('same', 'runtime:env-1')] + const groups = splitRepoReorderByHost(['same', 'same'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'local', orderedIds: ['same'] }, + { hostId: 'runtime:env-1', orderedIds: ['same'] } + ]) + }) }) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts index 2ef975b4646..9924df17c7f 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -9,6 +9,11 @@ export type RepoReorderHostGroup = { orderedIds: string[] } +type RepoHostCursor = { + hosts: string[] + nextIndex: number +} + /** Split a cross-host reorder permutation into per-host permutations. * * Why: each host persists only its own repos and rejects any id list that is not @@ -25,14 +30,26 @@ export function splitRepoReorderByHost( settings: Pick | null | undefined ): RepoReorderHostGroup[] { const focusedHostId = getSettingsFocusedExecutionHostId(settings) - const hostByRepoId = new Map() + const remainingHostsByRepoId = new Map() for (const repo of repos) { const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) - hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + const hostId = hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId + const existing = remainingHostsByRepoId.get(repo.id) + if (existing) { + existing.hosts.push(hostId) + } else { + remainingHostsByRepoId.set(repo.id, { hosts: [hostId], nextIndex: 0 }) + } } const groups = new Map() for (const id of orderedIds) { - const hostId = hostByRepoId.get(id) + const remainingHosts = remainingHostsByRepoId.get(id) + // Why: a bare repo id can appear once per host in the combined order. The + // cursor consumes each occurrence against its owner host exactly once. + const hostId = remainingHosts?.hosts[remainingHosts.nextIndex] + if (remainingHosts) { + remainingHosts.nextIndex += 1 + } if (!hostId) { continue } diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts new file mode 100644 index 00000000000..22690115f32 --- /dev/null +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localDuplicate: Repo = { + id: 'same-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' +} + +const remoteDuplicate: Repo = { + id: 'same-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2, + executionHostId: 'runtime:env-1' +} + +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function projectHostSetup(overrides: Pick): ProjectHostSetup { + return { + projectId: 'repo:same-repo', + repoId: 'same-repo', + path: '/same-repo', + displayName: 'Same Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice host identity routing', () => { + it('updates only the focused host row when repo ids are duplicated across hosts', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + await store.getState().updateRepo('same-repo', { displayName: 'Remote Renamed' }) + + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote Renamed' } + ]) + expect(reposUpdate).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote Renamed' } }, + timeoutMs: 15_000 + }) + }) + + it('updates a legacy local duplicate without overwriting an explicit remote sibling', async () => { + const { executionHostId: _executionHostId, ...legacyLocalDuplicate } = localDuplicate + reposUpdate.mockResolvedValue(undefined) + const store = createTestStore() + store.setState({ repos: [legacyLocalDuplicate as Repo, remoteDuplicate] }) + + await store.getState().updateRepo('same-repo', { displayName: 'Local Renamed' }) + + expect(store.getState().repos).toEqual([ + { ...legacyLocalDuplicate, displayName: 'Local Renamed' }, + remoteDuplicate + ]) + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: 'same-repo', + updates: { displayName: 'Local Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.update' }) + ) + }) + + it('keeps queued focused-host repo updates pinned when focus changes', async () => { + const firstUpdate = deferred<{ + id: string + ok: true + result: { repo: Repo } + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.update') { + const { updates } = (args as unknown as { params: { updates: { displayName: string } } }) + .params + const displayName = updates.displayName + if (displayName === 'Remote slow') { + return firstUpdate.promise + } + return Promise.resolve({ + id: 'rpc-queued-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName } }, + _meta: { runtimeId: 'runtime-remote' } + }) + } + return Promise.resolve({ + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + const first = store.getState().updateRepo('same-repo', { displayName: 'Remote slow' }) + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote slow' } } + }) + ) + }) + + const second = store.getState().updateRepo('same-repo', { displayName: 'Remote queued' }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + firstUpdate.resolve({ + id: 'rpc-first-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote slow' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote queued' } } + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.update' }) + ) + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote queued' } + ]) + }) + + it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + projects: [ + { + id: 'repo:same-repo', + displayName: 'Same Repo', + badgeColor: '#000', + sourceRepoIds: ['same-repo'], + createdAt: 1, + updatedAt: 1 + } satisfies Project + ], + projectHostSetups: [ + projectHostSetup({ id: 'local-setup', hostId: 'local' }), + projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) + ], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([remoteDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toBeUndefined() + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ + { id: 'remote-tab', worktreeId: remoteWorktree.id } + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) + ]) + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) + ]) + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(ptyKill).toHaveBeenCalledWith('local-pty') + expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') + }) + + it('removes a runtime duplicate without purging legacy local worktrees', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-remove', + ok: true, + result: { status: 'removed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([localDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toEqual([ + { id: 'local-tab', worktreeId: localWorktree.id } + ]) + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toBeUndefined() + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktree.id]: 10 }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + expect(ptyKill).toHaveBeenCalledWith('remote-pty') + expect(ptyKill).not.toHaveBeenCalledWith('local-pty') + }) + + it('reorders duplicate repo ids once per owning host', async () => { + reposReorder.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ repos: [localDuplicate, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) + expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo'] }, + timeoutMs: 15_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6465697299f..16258886392 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -44,9 +44,14 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' -import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + findRepoForHost, + getRepoHostIdentity, + getRepoHostIdentityForParts, + repoMatchesHostIdentity +} from './repo-host-identity' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, @@ -56,7 +61,6 @@ import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-clien import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' @@ -187,13 +191,21 @@ function getRepoUpdateChains(get: () => AppState): Map> return chains } -function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { +function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { + return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId +} + +function getKnownRepoWorktreeIds(state: AppState, projectId: string, hostId?: string): string[] { const ids = new Set() for (const worktree of state.worktreesByRepo[projectId] ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } return [...ids] } @@ -460,21 +472,34 @@ function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet currentRepoIds.has(repoId)) } +function getReposById(repos: readonly Repo[]): Map { + const reposById = new Map() + for (const repo of repos) { + const existing = reposById.get(repo.id) + if (existing) { + existing.push(repo) + } else { + reposById.set(repo.id, [repo]) + } + } + return reposById +} + function getSourceRepoIdsOutsideHost( project: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return project.sourceRepoIds.filter((repoId) => { - const repo = reposById.get(repoId) - return repo && getRepoExecutionHostId(repo) !== hostId + const repos = reposById.get(repoId) ?? [] + return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) }) } function getMergedSourceRepoIdsForHostRefresh( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return [ @@ -498,7 +523,7 @@ function projectWithCurrentSourceRepoIds( function mergePreviousProjectMetadata( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): Project { const project = mergeProjectCompatibilityProject(previous, current) @@ -535,7 +560,7 @@ function mergeProjectHostSetupCompatibility( const derivedSetups = derived.projectHostSetups.filter( (setup) => !fetchedSetupOwners.has(getProjectHostSetupOwnerKey(setup)) ) - const projectHostSetups = mergeById(derivedSetups, fetched.setups) + const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) return { @@ -547,7 +572,27 @@ function mergeProjectHostSetupCompatibility( } function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { - return `${setup.hostId}:${setup.repoId ?? setup.id}` + return `${setup.hostId}:${setup.repoId || setup.id}` +} + +function mergeProjectHostSetupsByOwner( + base: readonly ProjectHostSetup[], + overlay: readonly ProjectHostSetup[] +): ProjectHostSetup[] { + const merged = [...base] + const indexByOwner = new Map( + merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) + ) + for (const entry of overlay) { + const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) + if (index === undefined) { + indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged } function getProjectHostIds( @@ -568,15 +613,14 @@ function getExplicitProjectHostIds( repos: readonly Repo[] ): Set { const hostIds = new Set() - const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const sourceRepoIds = new Set(project.sourceRepoIds) for (const setup of setups) { if (setup.projectId === project.id) { hostIds.add(setup.hostId) } } - for (const repoId of project.sourceRepoIds) { - const repo = repoById.get(repoId) - if (repo) { + for (const repo of repos) { + if (sourceRepoIds.has(repo.id)) { hostIds.add(getRepoExecutionHostId(repo)) } } @@ -596,9 +640,9 @@ function mergeFetchedProjectCompatibilityForHost({ }): Pick { const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) - const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) + const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) - const reposById = new Map(repos.map((repo) => [repo.id, repo])) + const reposById = getReposById(repos) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean => getProjectHostIds(project, setups, repos).has(hostId) @@ -663,25 +707,41 @@ function mergeFetchedReposForHost( fetched: Repo[], hostId: string ): Repo[] { - const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const fetchedIdentities = new Set(fetched.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIds.has(repo.id) + return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) }) - const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) const merged = [...preserved] + const indexByIdentity = new Map(merged.map((repo, index) => [getRepoHostIdentity(repo), index])) for (const repo of fetched) { - const existingIndex = merged.findIndex((entry) => entry.id === repo.id) - if (existingIndex === -1) { + const identity = getRepoHostIdentity(repo) + const existingIndex = indexByIdentity.get(identity) + if (existingIndex === undefined) { + indexByIdentity.set(identity, merged.length) merged.push(repo) continue } merged[existingIndex] = repo } - return reconcileFetchedRepos( + return reconcileFetchedRepos(previous, merged) +} + +function mergeProjectCompatibilityForHostRepoChange({ + previous, + nextRepos, + hostId +}: { + previous: Pick + nextRepos: readonly Repo[] + hostId: string +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ previous, - merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) - ) + fetched: projectCompatibilityFromRepos(nextRepos), + repos: nextRepos, + hostId + }) } function getProjectGroupHostId(group: Pick) { @@ -821,7 +881,26 @@ async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[] } function settingsForRepoOwner(state: Pick, repoId: string) { - return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings } function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { @@ -1712,6 +1791,9 @@ export const createRepoSlice: StateCreator = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { + if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + return false + } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' @@ -1732,10 +1814,21 @@ export const createRepoSlice: StateCreator = (set, return false } const ownedMoved = repoWithFetchedOwner(moved, target) - set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), - folderWorkspacePathStatuses: {} - })) + const movedHostId = getRepoExecutionHostId(ownedMoved) + set((s) => { + const nextRepos = s.repos.map((repo) => + repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + ) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: movedHostId + }), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to move repo to group:', err) @@ -1778,18 +1871,24 @@ export const createRepoSlice: StateCreator = (set, return null } repo = repoWithFetchedOwner(repo, target) - const alreadyAdded = get().repos.some((r) => r.id === repo.id) + const repoIdentity = getRepoHostIdentity(repo) + const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) } set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { + if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { return s } const nextRepos = [...s.repos, repo] + const hostId = getRepoExecutionHostId(repo) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId + }), folderWorkspacePathStatuses: {} } }) @@ -1836,10 +1935,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = repoWithFetchedOwner(result.repo, target) + const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) set((s) => { - const nextRepos = s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + const nextRepos = s.repos.some((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) + ) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry + ) : [...s.repos, repo] const nextProjects = s.projects.some((entry) => entry.id === result.project.id) ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) @@ -1924,10 +2028,13 @@ export const createRepoSlice: StateCreator = (set, ).result const setup = setupWithFetchedOwner(result.setup, target) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ repos: repo - ? s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry + ) : [...s.repos, repo] : s.repos, projects: s.projects.some((entry) => entry.id === result.project.id) @@ -1968,11 +2075,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => { const projectHostSetups = s.projectHostSetups.filter( (setup) => setup.id !== result.setup.id ) - const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos + const repos = + repo && repoHostId + ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) + : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) @@ -2106,19 +2217,26 @@ export const createRepoSlice: StateCreator = (set, removeProject: async (projectId) => { try { + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return + } + const ownerHostId = getRepoExecutionHostId(ownerRepo) const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) await (target.kind === 'local' ? window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(projectId) - const repoPath = get().repos.find((repo) => repo.id === projectId)?.path + const repoPath = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, ownerHostId) + )?.path get().evictGitHubRepoCaches(projectId, repoPath) const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') clearRepoSlugCacheEntry(projectId) // Kill PTYs for all worktrees belonging to this repo - const worktreeIds = getKnownRepoWorktreeIds(get(), projectId) + const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) const killedTabIds = new Set() const killedPtyIds = new Set() if (target.kind === 'environment') { @@ -2155,9 +2273,26 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const nextWorktrees = { ...s.worktreesByRepo } - delete nextWorktrees[projectId] + const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingWorktrees.length > 0) { + nextWorktrees[projectId] = remainingWorktrees + } else { + delete nextWorktrees[projectId] + } const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } - delete nextDetectedWorktrees[projectId] + const detected = nextDetectedWorktrees[projectId] + if (detected) { + const remainingDetected = detected.worktrees.filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingDetected.length > 0) { + nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } + } else { + delete nextDetectedWorktrees[projectId] + } + } const nextTabs = { ...s.tabsByWorktree } const nextLayouts = { ...s.terminalLayoutsByTabId } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } @@ -2195,17 +2330,21 @@ export const createRepoSlice: StateCreator = (set, // forever after the repo is removed. let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { - if (getRepoIdFromWorktreeId(id) === projectId) { + if (worktreeIdSet.has(id)) { if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } } delete nextLastVisitedAtByWorktreeId[id] } } - const nextRepos = s.repos.filter((r) => r.id !== projectId) + const nextRepos = s.repos.filter((r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -2282,10 +2421,25 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (projectId, updates) => { const updateRepoChains = getRepoUpdateChains(get) + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return false + } + const ownerHasExplicitHost = Boolean( + ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() + ) + const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = ownerHasExplicitHost + ? getProjectSetupRuntimeTarget(explicitOwnerHostId) + : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const ownerHostId = ownerHasExplicitHost + ? explicitOwnerHostId + : getRuntimeTargetHostId(ownerTarget) + const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const target = ownerTarget const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -2299,7 +2453,10 @@ export const createRepoSlice: StateCreator = (set, ).repo set((s) => { const nextRepos = s.repos.map((r) => { - if (r.id !== projectId) { + const matchesOwner = ownerHasExplicitHost + ? repoMatchesHostIdentity(r, projectId, ownerHostId) + : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo + if (!matchesOwner) { return r } if (updatedRepo) { @@ -2320,7 +2477,11 @@ export const createRepoSlice: StateCreator = (set, }) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), folderWorkspacePathStatuses: {} } }) @@ -2330,16 +2491,16 @@ export const createRepoSlice: StateCreator = (set, return false } } - const previous = updateRepoChains.get(projectId) + const previous = updateRepoChains.get(updateChainKey) // Why: repo settings are persisted as full nested values. Preserve call // order per repo so a slower IPC/RPC response cannot overwrite newer state. const next = previous ? previous.catch(() => undefined).then(applyRepoUpdate) : applyRepoUpdate() - updateRepoChains.set(projectId, next) + updateRepoChains.set(updateChainKey, next) const cleanup = () => { - if (updateRepoChains.get(projectId) === next) { - updateRepoChains.delete(projectId) + if (updateRepoChains.get(updateChainKey) === next) { + updateRepoChains.delete(updateChainKey) } } void next.then(cleanup, cleanup) @@ -2352,10 +2513,22 @@ export const createRepoSlice: StateCreator = (set, // Optimistically apply the new order so the sidebar updates instantly; // resync only if main rejects (stale permutation due to a racing add/remove). const previous = get().repos - const byId = new Map(previous.map((r) => [r.id, r])) + const remainingById = new Map() + for (const repo of previous) { + const existing = remainingById.get(repo.id) + if (existing) { + existing.repos.push(repo) + } else { + remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) + } + } const next: Repo[] = [] for (const id of orderedIds) { - const repo = byId.get(id) + const remaining = remainingById.get(id) + const repo = remaining?.repos[remaining.nextIndex] + if (remaining) { + remaining.nextIndex += 1 + } if (repo) { next.push(repo) } @@ -2366,7 +2539,6 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next, - ...projectCompatibilityFromRepos(next), folderWorkspacePathStatuses: {} }) try { @@ -2392,11 +2564,11 @@ export const createRepoSlice: StateCreator = (set, }) ) if (results.some((result) => result.status === 'rejected')) { - await get().fetchRepos() + await get().fetchReposForAllHosts() } } catch (err) { console.error('Failed to reorder repos:', err) - await get().fetchRepos() + await get().fetchReposForAllHosts() } } }) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index c76cc38406a..00e8c7903b2 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4285,6 +4285,89 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) + it('preserves sibling host worktrees during hydrated refresh when repo ids are duplicated', async () => { + const store = createTestStore() + const localRepo = { + id: 'same-repo', + path: '/repos/local', + displayName: 'same local', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'local' + } + const runtimeRepo = { + id: 'same-repo', + path: '/repos/remote', + displayName: 'same remote', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo', + path: '/local/wt' + }) + const staleRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/stale', + repoId: 'same-repo', + path: '/remote/stale', + hostId: 'runtime:env-1' + }) + const refreshedRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/fresh', + repoId: 'same-repo', + path: '/remote/fresh', + hostId: 'local' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('same-repo', [localWorktree]) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-worktrees', + ok: true, + result: makeDetectedResult('same-repo', [refreshedRemoteWorktree]), + _meta: { runtimeId: 'runtime-remote' } + }) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [localRepo, runtimeRepo], + worktreesByRepo: { + 'same-repo': [localWorktree, staleRemoteWorktree] + }, + detectedWorktreesByRepo: { + 'same-repo': makeDetectedResult('same-repo', [localWorktree, staleRemoteWorktree]) + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + + const refreshed = store.getState().worktreesByRepo['same-repo'] ?? [] + expect(refreshed).toHaveLength(2) + expect(refreshed).toEqual( + expect.arrayContaining([ + localWorktree, + { ...refreshedRemoteWorktree, hostId: 'runtime:env-1' } + ]) + ) + expect(refreshed.map((worktree) => worktree.id)).not.toContain(staleRemoteWorktree.id) + expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: localWorktree.id }), + expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) + ]) + ) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.detectedList', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + }) + it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() const repos = Array.from({ length: 7 }, (_, index) => ({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index b44bece7790..2c9b3ef5e67 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -24,6 +24,7 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { @@ -72,11 +73,11 @@ const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -async function mapReposForWorktreeRefresh( - repos: readonly { id: string }[], - mapper: (repo: { id: string }) => Promise -): Promise { - const results = Array(repos.length) +async function mapReposForWorktreeRefresh( + repos: readonly TRepo[], + mapper: (repo: TRepo) => Promise +): Promise { + const results = Array(repos.length) let nextIndex = 0 const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) @@ -326,8 +327,12 @@ function withRepoHostId( return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId } } -function repoHostId(state: Pick, repoId: string): ExecutionHostId { - const repo = state.repos.find((entry) => entry.id === repoId) +function repoHostId( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +): ExecutionHostId { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } @@ -345,19 +350,133 @@ function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) } -function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] { +type WorktreeHostMatchOptions = { + unhostedWorktreesMatchHost?: boolean +} + +type RepoHostSummary = { + count: number + onlyHostId?: ExecutionHostId +} + +const repoHostSummariesByRepos = new WeakMap>() + +function getRepoHostSummaries(repos: AppState['repos']): Map { + const cached = repoHostSummariesByRepos.get(repos) + if (cached) { + return cached + } + + const summaries = new Map() + for (const repo of repos) { + const current = summaries.get(repo.id) + if (current) { + summaries.set(repo.id, { count: current.count + 1 }) + } else { + summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) + } + } + repoHostSummariesByRepos.set(repos, summaries) + return summaries +} + +function unhostedWorktreesMatchRefreshHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + + const summary = getRepoHostSummaries(state.repos).get(repoId) + return summary?.count === 1 && summary.onlyHostId === hostId +} + +function worktreeHostMatchOptions( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): WorktreeHostMatchOptions { + return { + // Why: pre-host persisted runtime/SSH worktrees were stored without hostId. + // Treat them as the sole repo owner's rows, but keep ambiguous duplicates local. + unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) + } +} + +function worktreeMatchesHost( + worktree: { hostId?: ExecutionHostId }, + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions = {} +): boolean { + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID +} + +function mergeWorktreesForHost( + current: readonly T[] | undefined, + refreshed: readonly T[], + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): T[] { + // Why: host-scoped refreshes should replace that host in place so alternating + // local/runtime refreshes do not churn sibling row order or sortEpoch. + const existing = current ?? [] + const next: T[] = [] + let inserted = false + + for (const worktree of existing) { + if (worktreeMatchesHost(worktree, hostId, options)) { + if (!inserted) { + next.push(...refreshed) + inserted = true + } + continue + } + next.push(worktree) + } + + return inserted ? next : [...next, ...refreshed] +} + +function mergeDetectedWorktreesForHost( + current: DetectedWorktreeListResult | undefined, + refreshed: DetectedWorktreeListResult, + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): DetectedWorktreeListResult { + const refreshedForHost = refreshed.worktrees.map((worktree) => withRepoHostId(worktree, hostId)) + return { + ...refreshed, + worktrees: mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + } +} + +function getKnownWorktreeIdsForPurge( + state: AppState, + repoId: string, + hostId: ExecutionHostId +): string[] { const detected = state.detectedWorktreesByRepo[repoId] const knownIds = new Set() + const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) if (detected?.authoritative === true) { for (const worktree of detected.worktrees) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } else { for (const worktree of state.worktreesByRepo[repoId] ?? []) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } - if (!state.hasHydratedWorktreePurge) { + if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { // Why (#1158): hydration can preserve tab keys before worktree metadata exists; // the first authoritative scan still needs to reap deleted session-only keys. for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { @@ -370,13 +489,14 @@ function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] function getRemovedWorktreeIdsAfterAuthoritativeScan( state: AppState, repoId: string, - detected: DetectedWorktreeListResult + detected: DetectedWorktreeListResult, + hostId: ExecutionHostId ): string[] { if (!detected.authoritative) { return [] } const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - return getKnownWorktreeIdsForPurge(state, repoId).filter((id) => !detectedIds.has(id)) + return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) } function toLegacyDetectedWorktreeResult( @@ -595,30 +715,41 @@ function replaceWorktreeInRepoLists( } } -function settingsForRepoOwner(state: Pick, repoId: string) { - const repo = state.repos.find((entry) => entry.id === repoId) +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +) { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) if (!repo) { return state.settings } + return settingsForKnownRepoOwner(state.settings, repo) +} + +function settingsForKnownRepoOwner( + settings: AppState['settings'], + repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } +) { if (!repo.executionHostId && !repo.connectionId) { - return state.settings + return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) } - if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { - return { ...state.settings, activeRuntimeEnvironmentId: null } + if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { + return { ...settings, activeRuntimeEnvironmentId: null } } if (parsed?.kind !== 'ssh') { - return state.settings + return settings } // Why: SSH repos are owned by the desktop client/SSH provider, not the // currently focused runtime server. - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } @@ -641,14 +772,21 @@ function settingsForExecutionHostOwner( } function settingsForWorktreeOwner( - state: Pick, + state: Pick, worktreeId: string ) { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) if (worktree?.hostId) { return settingsForExecutionHostOwner(state.settings, worktree.hostId) } - return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return settingsForExecutionHostOwner(state.settings, detected.hostId) + } + return settingsForRepoOwner(state, repoId) } async function listDetectedWorktreesForRepo( @@ -860,7 +998,7 @@ async function refreshRemoteWorktreeLineageBestEffort( } function getWorktreeHostId( - state: Pick, + state: Pick, worktreeId: string ): ExecutionHostId | null { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) @@ -868,12 +1006,21 @@ function getWorktreeHostId( return worktree.hostId } const repoId = getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos.find((entry) => entry.id === repoId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return detected.hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : null } function mergeLineageForHost( - state: Pick, + state: Pick< + AppState, + 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -887,7 +1034,14 @@ function mergeLineageForHost( } function mergeWorkspaceLineageForHost( - state: Pick, + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'workspaceLineageByChildKey' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -1552,24 +1706,58 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId, options) => { try { - const settings = settingsForRepoOwner(get(), repoId) + const ownerState = get() + const hostId = repoHostId(ownerState, repoId) + const settings = settingsForRepoOwner(ownerState, repoId, hostId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false } - const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId)) + const worktrees = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[repoId] - if (areWorktreesEqual(current, worktrees)) { + const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) + if (areWorktreesEqual(currentForHost, worktrees)) { set((s) => { - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + repoId, + detected, + hostId + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) if ( - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], detected) && + !worktreesChanged && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && removedIds.length === 0 ) { return s } return { - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { + ...s.worktreesByRepo, + [repoId]: mergedWorktrees + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + }, + ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) @@ -1584,9 +1772,17 @@ export const createWorktreeSlice: StateCreator // badge then shows raw worktree IDs instead of display names, and click- // to-navigate silently fails because findWorktreeById returns undefined. // Keep the stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && current && current.length > 0) { + if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + worktreeHostMatchOptions(s, repoId, hostId) + ) + } })) return false } @@ -1595,14 +1791,27 @@ export const createWorktreeSlice: StateCreator // Why: hidden worktrees are not in worktreesByRepo. Purge decisions // must diff against the previous authoritative detected list so hiding // does not delete state, and deleting a hidden worktree still does. - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) return { // Why: active worktrees can change branches entirely from a terminal. // We refresh that live git identity into renderer state, but only bump // sortEpoch when git actually reports a different worktree payload. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, sortEpoch: s.sortEpoch + 1, ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } @@ -1622,7 +1831,41 @@ export const createWorktreeSlice: StateCreator // calls just need to refresh each repo's cached list. No need to // double-probe the IPC for the per-repo success signal. if (get().hasHydratedWorktreePurge) { - await mapReposForWorktreeRefresh(repos, (r) => get().fetchWorktrees(r.id)) + await mapReposForWorktreeRefresh(repos, async (r) => { + const hostId = getRepoExecutionHostId(r) + const settings = settingsForKnownRepoOwner(get().settings, r) + const detected = await listDetectedWorktreesForRepo(settings, r.id) + const worktrees = toVisibleWorktrees(detected, hostId) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, r.id, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[r.id], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + if ( + areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && + removedIds.length === 0 + ) { + return s + } + return { + worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, + sortEpoch: s.sortEpoch + 1, + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + }) return } @@ -1646,24 +1889,51 @@ export const createWorktreeSlice: StateCreator | { repoId: string; ok: false } > => { try { + const hostId = getRepoExecutionHostId(r) const detected = await listDetectedWorktreesForRepo( - settingsForRepoOwner(get(), r.id), + settingsForKnownRepoOwner(get().settings, r), r.id ) - const list = toVisibleWorktrees(detected, repoHostId(get(), r.id)) + const list = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[r.id] + const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) if ( - !areWorktreesEqual(current, list) && - !(list.length === 0 && current && current.length > 0 && !detected.authoritative) + !areWorktreesEqual(currentForHost, list) && + !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) ) { - set((s) => ({ - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected }, - sortEpoch: s.sortEpoch + 1 - })) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + return { + worktreesByRepo: { + ...s.worktreesByRepo, + [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + }, + sortEpoch: s.sortEpoch + 1 + } + }) } else { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + worktreeHostMatchOptions(s, r.id, hostId) + ) + } })) } return { repoId: r.id, ok: detected.authoritative, detected } From d14498a11188e29ae17febaea0053e26711c76d6 Mon Sep 17 00:00:00 2001 From: AJ <60591772+devajmeireles@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:49:38 -0300 Subject: [PATCH 56/57] feat(worktree): add a "create multiple" toggle to the worktree composer (#5813) Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../components/NewWorkspaceComposerCard.tsx | 47 +++++++++++++- src/renderer/src/hooks/useComposerState.ts | 63 ++++++++++++++++++- src/renderer/src/i18n/locales/en.json | 1 + 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/lib/pending-worktree-creation.ts | 3 + .../src/lib/worktree-creation-flow.ts | 2 +- 8 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0079a9aa868..6c1f8c1d9bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -95,6 +95,10 @@ type NewWorkspaceComposerCardProps = { canReuseSelectedBranch: boolean reuseSelectedBranch: boolean onReuseSelectedBranchChange: (next: boolean) => void + /** Shows the footer "Create more" switch — worktree targets only. */ + showCreateMultiple?: boolean + createMultiple?: boolean + onCreateMultipleChange?: (next: boolean) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ forkPushWarning: string | null @@ -331,6 +335,9 @@ export default function NewWorkspaceComposerCard({ canReuseSelectedBranch, reuseSelectedBranch, onReuseSelectedBranchChange, + showCreateMultiple = false, + createMultiple = false, + onCreateMultipleChange, smartNameGitHubSourceContext, forkPushWarning, detectedAgentIds, @@ -810,7 +817,10 @@ export default function NewWorkspaceComposerCard({ />
-
+ {/* Why: Advanced is a disclosure header, so keep it visually grouped + with the content or footer below it while preserving normal spacing + from the Agent field above. */} +
+ ) : null}