(
+ () => new Map()
+ )
+ const jiraPrioritySiteIdsKey = useMemo(() => {
+ const siteIds =
+ selectedJiraSiteId && selectedJiraSiteId !== 'all'
+ ? [selectedJiraSiteId]
+ : jiraIssues.flatMap((issue) => (issue.siteId ? [issue.siteId] : []))
+ // Why: result refreshes replace the issue array; depend on the represented sites, not identity.
+ return JSON.stringify([...new Set(siteIds)].sort())
+ }, [jiraIssues, selectedJiraSiteId])
+
+ useEffect(() => {
+ if (taskSource !== 'jira' || !jiraConnected || jiraOrderBy !== 'priority') {
+ setJiraPrioritiesBySite((current) => (current.size === 0 ? current : new Map()))
+ return
+ }
+ let cancelled = false
+ const jiraPrioritySiteIds = JSON.parse(jiraPrioritySiteIdsKey) as string[]
+ void Promise.all(
+ jiraPrioritySiteIds.map(async (siteId) => {
+ try {
+ return [
+ siteId,
+ await jiraListPriorities(jiraTaskSourceContext ?? settings, siteId)
+ ] as const
+ } catch {
+ return [siteId, [] as JiraPriority[]] as const
+ }
+ })
+ ).then((prioritiesBySite) => {
+ if (!cancelled) {
+ setJiraPrioritiesBySite(new Map(prioritiesBySite))
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [
+ jiraConnected,
+ jiraOrderBy,
+ jiraPrioritySiteIdsKey,
+ jiraTaskSourceContext,
+ settings,
+ taskSource
+ ])
+
+ const handleJiraSort = useCallback(
+ (column: JiraIssueSortColumn) => {
+ if (jiraOrderBy === column) {
+ setJiraOrderDirection((prevDir) => (prevDir === 'asc' ? 'desc' : 'asc'))
+ } else {
+ setJiraOrderBy(column)
+ setJiraOrderDirection(column === 'updated' || column === 'status' ? 'desc' : 'asc')
+ }
+ },
+ [jiraOrderBy]
+ )
useEffect(() => {
if (taskResumeAppliedRef.current || !persistedUIReady || !settings) {
@@ -5621,6 +5690,14 @@ export default function TaskPage(): React.JSX.Element {
? jiraProjectStatusOrder.order
: null
+ const sortedJiraIssues = useMemo(() => {
+ return sortJiraIssues(
+ displayedJiraIssues,
+ jiraOrderBy,
+ jiraOrderDirection,
+ jiraPrioritiesBySite
+ )
+ }, [displayedJiraIssues, jiraOrderBy, jiraOrderDirection, jiraPrioritiesBySite])
// New Linear project dialog state
const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false)
const [newLinearProjectName, setNewLinearProjectName] = useState('')
@@ -10010,17 +10087,11 @@ export default function TaskPage(): React.JSX.Element {
-
- {translate('auto.components.TaskPage.37e7ee311e', 'Key')}
- {translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}
- {translate('auto.components.TaskPage.154b0fa623', 'Status')}
- {translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
-
- {translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
-
- {translate('auto.components.TaskPage.f362667d55', 'Updated')}
-
-
+
diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts
new file mode 100644
index 00000000000..a95680c1c34
--- /dev/null
+++ b/src/renderer/src/components/jira-issue-sorter.ts
@@ -0,0 +1,80 @@
+import type { JiraIssue, JiraPriority } from '../../../shared/types'
+
+export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated'
+
+export type JiraIssueSortDirection = 'asc' | 'desc'
+export type JiraPrioritiesBySite = ReadonlyMap
+
+// Why: Jira instances can use synonymous names for the same standard priority tier.
+export const JIRA_PRIORITY_ORDER: Record = {
+ blocker: 99,
+ highest: 99,
+ critical: 99,
+ high: 75,
+ major: 75,
+ medium: 50,
+ normal: 50,
+ low: 25,
+ minor: 25,
+ lowest: 1,
+ trivial: 1
+}
+
+export function getJiraPriorityWeight(
+ priorityName?: string,
+ priorityId?: string,
+ jiraPriorities: readonly JiraPriority[] = []
+): number {
+ if (!priorityName) {
+ return 0
+ }
+ if (jiraPriorities.length > 0) {
+ const idx = jiraPriorities.findIndex(
+ (p) => p.id === priorityId || p.name.toLowerCase() === priorityName.toLowerCase()
+ )
+ if (idx !== -1) {
+ // Why: site schemes have different tier counts, so raw indices are not cross-site comparable.
+ if (jiraPriorities.length === 1) {
+ return 50
+ }
+ return 1 + ((jiraPriorities.length - 1 - idx) / (jiraPriorities.length - 1)) * 98
+ }
+ }
+ const nameKey = priorityName.toLowerCase()
+ if (nameKey in JIRA_PRIORITY_ORDER) {
+ return JIRA_PRIORITY_ORDER[nameKey]
+ }
+ // Why: custom priority IDs are opaque identifiers, not ordering ranks.
+ return 50
+}
+
+export function sortJiraIssues(
+ issues: readonly JiraIssue[],
+ orderBy: JiraIssueSortColumn,
+ orderDirection: JiraIssueSortDirection,
+ jiraPrioritiesBySite: JiraPrioritiesBySite = new Map()
+): JiraIssue[] {
+ return [...issues].sort((a, b) => {
+ let comparison = 0
+ if (orderBy === 'key') {
+ comparison = a.key.localeCompare(b.key, undefined, { numeric: true })
+ } else if (orderBy === 'title') {
+ comparison = a.title.localeCompare(b.title)
+ } else if (orderBy === 'status') {
+ comparison = 0
+ } else if (orderBy === 'priority') {
+ const prioritiesA = jiraPrioritiesBySite.get(a.siteId ?? '')
+ const prioritiesB = jiraPrioritiesBySite.get(b.siteId ?? '')
+ const weightA = getJiraPriorityWeight(a.priority?.name, a.priority?.id, prioritiesA)
+ const weightB = getJiraPriorityWeight(b.priority?.name, b.priority?.id, prioritiesB)
+ comparison = weightA - weightB
+ } else if (orderBy === 'assignee') {
+ const userA = a.assignee?.displayName ?? ''
+ const userB = b.assignee?.displayName ?? ''
+ comparison = userA.localeCompare(userB)
+ } else if (orderBy === 'updated') {
+ comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
+ }
+ return orderDirection === 'asc' ? comparison : -comparison
+ })
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts
index 51646b559b7..86077e1c598 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts
@@ -1,4 +1,4 @@
-import { useCallback } from 'react'
+import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
@@ -7,10 +7,12 @@ import { useAppStore } from '@/store'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
+import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
import {
- findAiVaultSessionLiveState,
- findOriginalAiVaultSessionPane
-} from './ai-vault-original-pane'
+ createLazyAiVaultOriginalPaneIndex,
+ findAiVaultSessionLiveStateInIndex,
+ findOriginalAiVaultSessionPaneInIndex
+} from './ai-vault-original-pane-index'
export function useAiVaultOriginalPaneActions(): {
getOriginalPaneTarget: (
@@ -29,15 +31,23 @@ export function useAiVaultOriginalPaneActions(): {
terminalLayoutsByTabId: s.terminalLayoutsByTabId
}))
)
-
- const getOriginalPaneTarget = useCallback(
- (session: AiVaultSession) => findOriginalAiVaultSessionPane(originalPaneLookupState, session),
+ // Why: loading, filtered, or collapsed views may render no session rows.
+ // Build once on the first actual lookup, then share it across visible rows.
+ const getOriginalPaneIndex = useMemo(
+ () => createLazyAiVaultOriginalPaneIndex(originalPaneLookupState),
[originalPaneLookupState]
)
+ const getOriginalPaneTarget = useCallback(
+ (session: AiVaultSession) =>
+ findOriginalAiVaultSessionPaneInIndex(getOriginalPaneIndex(), session),
+ [getOriginalPaneIndex]
+ )
+
const getSessionLiveState = useCallback(
- (session: AiVaultSession) => findAiVaultSessionLiveState(originalPaneLookupState, session),
- [originalPaneLookupState]
+ (session: AiVaultSession) =>
+ findAiVaultSessionLiveStateInIndex(getOriginalPaneIndex(), session),
+ [getOriginalPaneIndex]
)
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts
new file mode 100644
index 00000000000..127ec6553b7
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts
@@ -0,0 +1,254 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import {
+ buildAiVaultOriginalPaneIndex,
+ createLazyAiVaultOriginalPaneIndex,
+ findAiVaultSessionLiveStateInIndex,
+ findOriginalAiVaultSessionPaneInIndex
+} from './ai-vault-original-pane-index'
+import {
+ findAiVaultSessionLiveState,
+ findOriginalAiVaultSessionPane
+} from './ai-vault-original-pane'
+
+const SESSION: AiVaultSession = {
+ id: 'codex:target-session',
+ executionHostId: 'local',
+ agent: 'codex',
+ sessionId: 'target-session',
+ title: 'Target session',
+ cwd: '/repo',
+ branch: null,
+ model: null,
+ filePath: '/tmp/target-session.jsonl',
+ codexHome: null,
+ createdAt: null,
+ updatedAt: '2026-07-10T00:00:00.000Z',
+ modifiedAt: '2026-07-10T00:00:00.000Z',
+ messageCount: 1,
+ totalTokens: 1,
+ previewMessages: [],
+ queuedMessageCount: 0,
+ subagentTranscriptCount: 0,
+ resumeCommand: "codex resume 'target-session'",
+ subagent: null
+}
+
+function countedRecord(count: number, makeValue: (index: number) => T) {
+ const reads = { value: 0 }
+ const record: Record = {}
+ for (let index = 0; index < count; index += 1) {
+ Object.defineProperty(record, `entry-${index}`, {
+ enumerable: true,
+ get: () => {
+ reads.value += 1
+ return makeValue(index)
+ }
+ })
+ }
+ return { reads, record }
+}
+
+function unrelatedEntry(index: number): AgentStatusEntry {
+ return {
+ state: 'done',
+ prompt: `Other task ${index}`,
+ updatedAt: index,
+ stateStartedAt: index,
+ agentType: 'claude',
+ paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
+ tabId: `other-tab-${index}`,
+ worktreeId: 'other-worktree',
+ stateHistory: [],
+ providerSession: { key: 'session_id', id: `other-session-${index}` }
+ }
+}
+
+describe('AI Vault original-pane index', () => {
+ it('builds lazily on the first lookup and shares one index across callbacks', () => {
+ const live = countedRecord(500, unrelatedEntry)
+ const retained = countedRecord(500, (index) => ({
+ entry: unrelatedEntry(index),
+ worktreeId: 'other-worktree',
+ tab: { id: `other-tab-${index}` },
+ agentType: 'claude',
+ startedAt: index
+ }))
+ const sleeping = countedRecord(500, (index) => ({
+ paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
+ tabId: `other-tab-${index}`,
+ worktreeId: 'other-worktree',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: `other-session-${index}` },
+ prompt: `Other task ${index}`,
+ state: 'done',
+ capturedAt: index,
+ updatedAt: index,
+ origin: 'live'
+ }))
+ const getIndex = createLazyAiVaultOriginalPaneIndex({
+ agentStatusByPaneKey: live.record,
+ retainedAgentsByPaneKey: retained.record,
+ sleepingAgentSessionsByPaneKey: sleeping.record,
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {}
+ } as never)
+
+ expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(0)
+ expect(findAiVaultSessionLiveStateInIndex(getIndex(), SESSION)).toBeNull()
+ expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
+ expect(findOriginalAiVaultSessionPaneInIndex(getIndex(), SESSION)).toBeNull()
+ expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
+ })
+
+ it('builds once instead of rescanning every agent collection for every visible row', () => {
+ const live = countedRecord(500, unrelatedEntry)
+ const retained = countedRecord(500, (index) => ({
+ entry: unrelatedEntry(index),
+ worktreeId: 'other-worktree',
+ tab: { id: `other-tab-${index}` },
+ agentType: 'claude',
+ startedAt: index
+ }))
+ const sleeping = countedRecord(500, (index) => ({
+ paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
+ tabId: `other-tab-${index}`,
+ worktreeId: 'other-worktree',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: `other-session-${index}` },
+ prompt: `Other task ${index}`,
+ state: 'done',
+ capturedAt: index,
+ updatedAt: index,
+ origin: 'live'
+ }))
+ const state = {
+ agentStatusByPaneKey: live.record,
+ retainedAgentsByPaneKey: retained.record,
+ sleepingAgentSessionsByPaneKey: sleeping.record,
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {}
+ } as never
+
+ const index = buildAiVaultOriginalPaneIndex(state)
+ expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
+
+ live.reads.value = 0
+ retained.reads.value = 0
+ sleeping.reads.value = 0
+ for (let row = 0; row < 20; row += 1) {
+ expect(findOriginalAiVaultSessionPaneInIndex(index, SESSION)).toBeNull()
+ expect(findAiVaultSessionLiveStateInIndex(index, SESSION)).toBeNull()
+ }
+ expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(0)
+ })
+
+ it('preserves provider, prompt-fallback, retained, sleeping, and missing matches', () => {
+ const leafIds = [
+ '11111111-1111-4111-8111-111111111111',
+ '22222222-2222-4222-8222-222222222222',
+ '33333333-3333-4333-8333-333333333333',
+ '44444444-4444-4444-8444-444444444444'
+ ]
+ const tabs = leafIds.map((_, index) => ({
+ id: `tab-${index + 1}`,
+ ptyId: null,
+ worktreeId: 'wt-1',
+ title: 'Agent',
+ customTitle: null,
+ color: null,
+ sortOrder: index,
+ createdAt: index
+ }))
+ const layouts = Object.fromEntries(
+ leafIds.map((leafId, index) => [
+ `tab-${index + 1}`,
+ {
+ root: { type: 'leaf', leafId },
+ activeLeafId: leafId,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { [leafId]: `pty-${index + 1}` }
+ }
+ ])
+ )
+ const liveDirect = {
+ ...unrelatedEntry(1),
+ agentType: 'codex',
+ paneKey: `tab-1:${leafIds[0]}`,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ state: 'blocked',
+ providerSession: { key: 'session_id', id: 'live-direct' }
+ } as AgentStatusEntry
+ const livePrompt = {
+ ...unrelatedEntry(2),
+ agentType: 'codex',
+ paneKey: `tab-2:${leafIds[1]}`,
+ tabId: 'tab-2',
+ worktreeId: 'wt-1',
+ prompt: 'Unique prompt match that is long enough',
+ providerSession: undefined
+ } as AgentStatusEntry
+ const retainedEntry = {
+ ...unrelatedEntry(3),
+ agentType: 'codex',
+ paneKey: `tab-3:${leafIds[2]}`,
+ tabId: 'tab-3',
+ worktreeId: 'wt-1',
+ providerSession: { key: 'session_id', id: 'retained-direct' }
+ } as AgentStatusEntry
+ const state = {
+ agentStatusByPaneKey: {
+ [liveDirect.paneKey]: liveDirect,
+ [livePrompt.paneKey]: livePrompt
+ },
+ retainedAgentsByPaneKey: {
+ [retainedEntry.paneKey]: {
+ entry: retainedEntry,
+ worktreeId: 'wt-1',
+ tab: tabs[2],
+ agentType: 'codex',
+ startedAt: 1
+ }
+ },
+ sleepingAgentSessionsByPaneKey: {
+ 'tab-4:1': {
+ paneKey: 'tab-4:1',
+ tabId: 'tab-4',
+ worktreeId: 'wt-1',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'sleeping-direct' },
+ prompt: 'Sleeping',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'live'
+ }
+ },
+ tabsByWorktree: { 'wt-1': tabs },
+ terminalLayoutsByTabId: layouts
+ } as never
+ const index = buildAiVaultOriginalPaneIndex(state)
+ const sessions = [
+ { ...SESSION, sessionId: 'live-direct' },
+ {
+ ...SESSION,
+ sessionId: 'prompt-only',
+ title: 'Unique prompt match that is long enough'
+ },
+ { ...SESSION, sessionId: 'retained-direct' },
+ { ...SESSION, sessionId: 'sleeping-direct' },
+ { ...SESSION, sessionId: 'missing' }
+ ]
+
+ for (const session of sessions) {
+ expect(findOriginalAiVaultSessionPaneInIndex(index, session)).toEqual(
+ findOriginalAiVaultSessionPane(state, session)
+ )
+ expect(findAiVaultSessionLiveStateInIndex(index, session)).toBe(
+ findAiVaultSessionLiveState(state, session)
+ )
+ }
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts
new file mode 100644
index 00000000000..95079f43593
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts
@@ -0,0 +1,189 @@
+import type { AgentStatusState } from '../../../../shared/agent-status-types'
+import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import {
+ promptsMatchSession,
+ resolveOriginalPaneTarget,
+ type OriginalPaneState,
+ type AiVaultOriginalPaneTarget
+} from './ai-vault-original-pane'
+
+type LiveEntry = NonNullable
+type RetainedEntry = NonNullable
+type SleepingEntry = NonNullable
+
+type ProviderIndex = Map
+type AgentIndex = Map
+
+export type AiVaultOriginalPaneIndex = {
+ state: OriginalPaneState
+ liveByProvider: ProviderIndex
+ liveWithoutProviderByAgent: AgentIndex
+ retainedByProvider: ProviderIndex
+ retainedWithoutProviderByAgent: AgentIndex
+ sleepingByProvider: ProviderIndex
+}
+
+function providerKey(agent: string, sessionId: string): string {
+ // Why: unlike punctuation delimiters, NUL cannot collide with agent or
+ // provider-session text, so distinct identity pairs stay distinct keys.
+ return `${agent}\u0000${sessionId}`
+}
+
+function appendToIndex(index: Map, key: string, value: T): void {
+ const entries = index.get(key)
+ if (entries) {
+ entries.push(value)
+ } else {
+ index.set(key, [value])
+ }
+}
+
+export function buildAiVaultOriginalPaneIndex(state: OriginalPaneState): AiVaultOriginalPaneIndex {
+ const liveByProvider: ProviderIndex = new Map()
+ const liveWithoutProviderByAgent: AgentIndex = new Map()
+ const retainedByProvider: ProviderIndex = new Map()
+ const retainedWithoutProviderByAgent: AgentIndex = new Map()
+ const sleepingByProvider: ProviderIndex = new Map()
+
+ for (const entry of Object.values(state.agentStatusByPaneKey)) {
+ if (!entry?.agentType) {
+ continue
+ }
+ if (entry.providerSession) {
+ appendToIndex(liveByProvider, providerKey(entry.agentType, entry.providerSession.id), entry)
+ } else if (entry.providerSession === undefined) {
+ appendToIndex(liveWithoutProviderByAgent, entry.agentType, entry)
+ }
+ }
+ for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
+ if (!retained?.agentType) {
+ continue
+ }
+ if (retained.entry.providerSession) {
+ appendToIndex(
+ retainedByProvider,
+ providerKey(retained.agentType, retained.entry.providerSession.id),
+ retained
+ )
+ } else if (retained.entry.providerSession === undefined) {
+ appendToIndex(retainedWithoutProviderByAgent, retained.agentType, retained)
+ }
+ }
+ for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
+ if (record) {
+ appendToIndex(
+ sleepingByProvider,
+ providerKey(record.agent, record.providerSession.id),
+ record
+ )
+ }
+ }
+
+ return {
+ state,
+ liveByProvider,
+ liveWithoutProviderByAgent,
+ retainedByProvider,
+ retainedWithoutProviderByAgent,
+ sleepingByProvider
+ }
+}
+
+export function createLazyAiVaultOriginalPaneIndex(
+ state: OriginalPaneState
+): () => AiVaultOriginalPaneIndex {
+ let index: AiVaultOriginalPaneIndex | null = null
+ return () => {
+ index ??= buildAiVaultOriginalPaneIndex(state)
+ return index
+ }
+}
+
+export function findOriginalAiVaultSessionPaneInIndex(
+ index: AiVaultOriginalPaneIndex,
+ session: AiVaultSession
+): AiVaultOriginalPaneTarget | null {
+ const key = providerKey(session.agent, session.sessionId)
+ const promptMatchedTargets: AiVaultOriginalPaneTarget[] = []
+
+ for (const entry of index.liveByProvider.get(key) ?? []) {
+ const target = resolveOriginalPaneTarget({
+ state: index.state,
+ paneKey: entry.paneKey,
+ worktreeIdHint: entry.worktreeId,
+ tabIdHint: entry.tabId
+ })
+ if (target) {
+ return target
+ }
+ }
+ for (const entry of index.liveWithoutProviderByAgent.get(session.agent) ?? []) {
+ if (!promptsMatchSession(session, entry)) {
+ continue
+ }
+ const target = resolveOriginalPaneTarget({
+ state: index.state,
+ paneKey: entry.paneKey,
+ worktreeIdHint: entry.worktreeId,
+ tabIdHint: entry.tabId
+ })
+ if (target) {
+ promptMatchedTargets.push(target)
+ }
+ }
+ for (const retained of index.retainedByProvider.get(key) ?? []) {
+ const target = resolveOriginalPaneTarget({
+ state: index.state,
+ paneKey: retained.entry.paneKey,
+ worktreeIdHint: retained.worktreeId,
+ tabIdHint: retained.entry.tabId ?? retained.tab.id
+ })
+ if (target) {
+ return target
+ }
+ }
+ for (const retained of index.retainedWithoutProviderByAgent.get(session.agent) ?? []) {
+ if (!promptsMatchSession(session, retained.entry)) {
+ continue
+ }
+ const target = resolveOriginalPaneTarget({
+ state: index.state,
+ paneKey: retained.entry.paneKey,
+ worktreeIdHint: retained.worktreeId,
+ tabIdHint: retained.entry.tabId ?? retained.tab.id
+ })
+ if (target) {
+ promptMatchedTargets.push(target)
+ }
+ }
+ for (const record of index.sleepingByProvider.get(key) ?? []) {
+ const target = resolveOriginalPaneTarget({
+ state: index.state,
+ paneKey: record.paneKey,
+ worktreeIdHint: record.worktreeId,
+ tabIdHint: record.tabId
+ })
+ if (target) {
+ return target
+ }
+ }
+
+ return promptMatchedTargets.length === 1 ? promptMatchedTargets[0] : null
+}
+
+export function findAiVaultSessionLiveStateInIndex(
+ index: AiVaultOriginalPaneIndex,
+ session: AiVaultSession
+): AgentStatusState | null {
+ const direct = index.liveByProvider.get(providerKey(session.agent, session.sessionId))
+ if (direct?.[0]) {
+ return direct[0].state
+ }
+ const promptMatchedStates: AgentStatusState[] = []
+ for (const entry of index.liveWithoutProviderByAgent.get(session.agent) ?? []) {
+ if (promptsMatchSession(session, entry)) {
+ promptMatchedStates.push(entry.state)
+ }
+ }
+ return promptMatchedStates.length === 1 ? promptMatchedStates[0] : null
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts
index 48f76ac29e2..013684c7870 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts
@@ -12,7 +12,7 @@ export type AiVaultOriginalPaneTarget = {
leafId: string
}
-type OriginalPaneState = Pick<
+export type OriginalPaneState = Pick<
AppState,
| 'agentStatusByPaneKey'
| 'retainedAgentsByPaneKey'
@@ -87,10 +87,10 @@ function entryPromptCandidates(entry: {
return [...candidates]
}
-function promptsMatchSession(
+export function promptsMatchSession(
session: AiVaultSession,
entry: Parameters[0]
-) {
+): boolean {
const sessionCandidates = sessionPromptCandidates(session)
if (sessionCandidates.length === 0) {
return false
@@ -134,7 +134,7 @@ function getTabOwnerWorktreeId(
return null
}
-function resolveOriginalPaneTarget(args: {
+export function resolveOriginalPaneTarget(args: {
state: OriginalPaneState
paneKey: string
worktreeIdHint?: string
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts
index 9282328cc49..19c8b2a9bee 100644
--- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts
+++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts
@@ -15,7 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts',
'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts',
'src/renderer/src/components/right-sidebar/useFileDuplicate.ts',
- 'src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts',
+ 'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts',
'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts',
'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts',
'src/renderer/src/components/quick-open-file-list.ts'
diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts
new file mode 100644
index 00000000000..9f420b6aba0
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts
@@ -0,0 +1,114 @@
+import { useEffect, useState } from 'react'
+import { getConnectionId } from '@/lib/connection-context'
+import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
+import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
+
+const EMPTY_IGNORED_PATHS: readonly string[] = []
+export const FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS = 300
+
+export type IgnoredPathResult = {
+ activeWorktreeId: string
+ paths: string[]
+ worktreePath: string
+}
+
+export function getEffectiveFileExplorerIgnoredPaths({
+ activeWorktreeId,
+ canLoadIgnoredPaths,
+ ignoredPathResult,
+ worktreePath
+}: {
+ activeWorktreeId: string | null
+ canLoadIgnoredPaths: boolean
+ ignoredPathResult: IgnoredPathResult | null
+ worktreePath: string | null
+}): readonly string[] {
+ const ignoredPathResultMatchesCurrentWorktree =
+ ignoredPathResult !== null &&
+ ignoredPathResult.activeWorktreeId === activeWorktreeId &&
+ ignoredPathResult.worktreePath === worktreePath
+
+ if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) {
+ return EMPTY_IGNORED_PATHS
+ }
+
+ // Why: expanding folders changes the query before the async ignored refresh returns.
+ // Keep same-worktree answers so known ignored rows do not flash as normal text.
+ return ignoredPathResult.paths
+}
+
+export function useFileExplorerIgnoredPaths({
+ activeWorktreeId,
+ canLoadIgnoredPaths,
+ relativePaths,
+ shouldDebounceIgnoredQuery,
+ worktreePath
+}: {
+ activeWorktreeId: string | null
+ canLoadIgnoredPaths: boolean
+ relativePaths: readonly string[]
+ shouldDebounceIgnoredQuery: boolean
+ worktreePath: string | null
+}): readonly string[] {
+ const [ignoredPathResult, setIgnoredPathResult] = useState(null)
+
+ useEffect(() => {
+ if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) {
+ return
+ }
+
+ let canceled = false
+ const refresh = (): void => {
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ void getRuntimeGitIgnoredPaths(
+ {
+ settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ [...relativePaths]
+ )
+ .then((paths) => {
+ if (!canceled) {
+ setIgnoredPathResult({ activeWorktreeId, paths, worktreePath })
+ }
+ })
+ .catch(() => {
+ if (!canceled) {
+ setIgnoredPathResult({ activeWorktreeId, paths: [], worktreePath })
+ }
+ })
+ }
+
+ // Why: every filter keystroke changes relativePaths. Waiting for a short
+ // quiet window prevents obsolete queries from launching uncancellable Git
+ // subprocess chains while the visible name projection stays immediate.
+ const timer = shouldDebounceIgnoredQuery
+ ? window.setTimeout(refresh, FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS)
+ : null
+ if (timer === null) {
+ refresh()
+ }
+
+ return () => {
+ canceled = true
+ if (timer !== null) {
+ window.clearTimeout(timer)
+ }
+ }
+ }, [
+ activeWorktreeId,
+ canLoadIgnoredPaths,
+ relativePaths,
+ shouldDebounceIgnoredQuery,
+ worktreePath
+ ])
+
+ return getEffectiveFileExplorerIgnoredPaths({
+ activeWorktreeId,
+ canLoadIgnoredPaths,
+ ignoredPathResult,
+ worktreePath
+ })
+}
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx
new file mode 100644
index 00000000000..b6dd71c8a05
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment happy-dom
+
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import type { AppState } from '@/store/types'
+import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection'
+import { FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS } from './use-file-explorer-ignored-paths'
+
+const getRuntimeGitIgnoredPathsMock = vi.hoisted(() => vi.fn())
+vi.mock('@/runtime/runtime-git-client', () => ({
+ getRuntimeGitIgnoredPaths: getRuntimeGitIgnoredPathsMock
+}))
+
+const initialAppState = useAppStore.getInitialState()
+const relativePaths = Array.from({ length: 5_000 }, (_, index) => `src/generated-${index}.ts`)
+
+function useProjection(query: string) {
+ return useFileExplorerVisibleRowProjection('worktree-1', '/repo', {}, new Set(), true, true, {
+ query,
+ relativePaths
+ })
+}
+
+function useTreeProjection() {
+ return useFileExplorerVisibleRowProjection(
+ 'worktree-1',
+ '/repo',
+ {
+ '/repo': {
+ children: [
+ {
+ name: 'src',
+ path: '/repo/src',
+ relativePath: 'src',
+ isDirectory: true,
+ depth: 0
+ }
+ ],
+ loading: false
+ }
+ },
+ new Set(),
+ true,
+ true,
+ null
+ )
+}
+
+describe('file explorer ignored-path query debounce', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ getRuntimeGitIgnoredPathsMock.mockReset().mockResolvedValue([])
+ useAppStore.setState(initialAppState, true)
+ useAppStore.setState({
+ settings: { activeRuntimeEnvironmentId: null } as AppState['settings']
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ useAppStore.setState(initialAppState, true)
+ vi.useRealTimers()
+ })
+
+ it('coalesces a broad typing burst into one final ignored-path request', async () => {
+ const hook = renderHook(({ query }) => useProjection(query), {
+ initialProps: { query: 's' }
+ })
+
+ await act(async () => vi.advanceTimersByTimeAsync(100))
+ hook.rerender({ query: 'sr' })
+ await act(async () => vi.advanceTimersByTimeAsync(100))
+ hook.rerender({ query: 'src' })
+
+ await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS - 1))
+ expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled()
+
+ await act(async () => vi.advanceTimersByTimeAsync(1))
+ expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
+ expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toHaveLength(relativePaths.length)
+ })
+
+ it('cancels the pending ignored-path request when the explorer unmounts', async () => {
+ const hook = renderHook(() => useProjection('src'))
+ hook.unmount()
+
+ await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS))
+ expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled()
+ })
+
+ it('keeps ordinary expanded-tree ignored checks immediate', () => {
+ renderHook(() => useTreeProjection())
+
+ expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
+ expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src'])
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts
index 600d4aa76a5..692ffebe729 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DirCache, TreeNode } from './file-explorer-types'
import {
createVisibleFileExplorerRowProjection,
- getEffectiveFileExplorerIgnoredPaths,
getFileExplorerIgnoredQueryRelativePaths
} from './useFileExplorerVisibleRowProjection'
+import { getEffectiveFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths'
import {
FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES,
getFileExplorerNameFilterExpandedPaths,
@@ -336,8 +336,6 @@ describe('file explorer visible row projection', () => {
})
it('keeps same-worktree ignored paths while an expanded-folder query is loading', () => {
- const previousRelativePaths = ['out', 'src']
-
expect(
getEffectiveFileExplorerIgnoredPaths({
activeWorktreeId: 'worktree-1',
@@ -345,7 +343,6 @@ describe('file explorer visible row projection', () => {
ignoredPathResult: {
activeWorktreeId: 'worktree-1',
paths: ['out'],
- relativePaths: previousRelativePaths,
worktreePath: '/repo'
},
worktreePath: '/repo'
@@ -372,7 +369,6 @@ describe('file explorer visible row projection', () => {
ignoredPathResult: {
activeWorktreeId: 'worktree-1',
paths: ['out'],
- relativePaths: ['out'],
worktreePath: '/repo'
},
worktreePath: '/repo'
@@ -386,7 +382,6 @@ describe('file explorer visible row projection', () => {
ignoredPathResult: {
activeWorktreeId: 'worktree-1',
paths: ['out'],
- relativePaths: ['out'],
worktreePath: '/repo'
},
worktreePath: '/other-repo'
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts
index acda16138bf..813555af1c5 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts
@@ -1,8 +1,5 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useMemo } from 'react'
import { useAppStore } from '@/store'
-import { getConnectionId } from '@/lib/connection-context'
-import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
-import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import { isDotfileRelativePath } from './file-explorer-entries'
import type { DirCache, TreeNode } from './file-explorer-types'
import {
@@ -16,17 +13,10 @@ import {
getFileExplorerNameFilterIgnoredQueryRelativePaths,
type FileExplorerNameFilterProjectionSource
} from './file-explorer-name-filter-projection'
+import { useFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths'
-const EMPTY_IGNORED_PATHS: readonly string[] = []
const EMPTY_RELATIVE_PATHS: string[] = []
-export type IgnoredPathResult = {
- activeWorktreeId: string
- paths: string[]
- relativePaths: readonly string[]
- worktreePath: string
-}
-
type VisibleFileExplorerRowProjectionOptions = {
ignoredSet: Set
nameFilter?: FileExplorerNameFilterProjectionSource | null
@@ -119,31 +109,6 @@ export function createVisibleFileExplorerRowProjection(
return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath)
}
-export function getEffectiveFileExplorerIgnoredPaths({
- activeWorktreeId,
- canLoadIgnoredPaths,
- ignoredPathResult,
- worktreePath
-}: {
- activeWorktreeId: string | null
- canLoadIgnoredPaths: boolean
- ignoredPathResult: IgnoredPathResult | null
- worktreePath: string | null
-}): readonly string[] {
- const ignoredPathResultMatchesCurrentWorktree =
- ignoredPathResult !== null &&
- ignoredPathResult.activeWorktreeId === activeWorktreeId &&
- ignoredPathResult.worktreePath === worktreePath
-
- if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) {
- return EMPTY_IGNORED_PATHS
- }
-
- // Why: expanding folders changes the query before the async ignored refresh returns.
- // Keep same-worktree answers so known ignored rows do not flash as normal text.
- return ignoredPathResult.paths
-}
-
export function useFileExplorerVisibleRowProjection(
activeWorktreeId: string | null,
worktreePath: string | null,
@@ -163,7 +128,6 @@ export function useFileExplorerVisibleRowProjection(
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true
- const [ignoredPathResult, setIgnoredPathResult] = useState(null)
const relativePaths = useMemo(
() =>
activeRepoSupportsGit
@@ -181,53 +145,12 @@ export function useFileExplorerVisibleRowProjection(
Boolean(activeWorktreeId) &&
Boolean(worktreePath) &&
relativePaths.length > 0
-
- useEffect(() => {
- if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) {
- return
- }
-
- let canceled = false
- const connectionId = getConnectionId(activeWorktreeId) ?? undefined
- void getRuntimeGitIgnoredPaths(
- {
- settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
- worktreeId: activeWorktreeId,
- worktreePath,
- connectionId
- },
- [...relativePaths]
- )
- .then((nextIgnoredPaths) => {
- if (!canceled) {
- setIgnoredPathResult({
- activeWorktreeId,
- paths: nextIgnoredPaths,
- relativePaths,
- worktreePath
- })
- }
- })
- .catch(() => {
- if (!canceled) {
- setIgnoredPathResult({
- activeWorktreeId,
- paths: [],
- relativePaths,
- worktreePath
- })
- }
- })
-
- return () => {
- canceled = true
- }
- }, [activeWorktreeId, canLoadIgnoredPaths, relativePaths, worktreePath])
-
- const effectiveIgnoredPaths = getEffectiveFileExplorerIgnoredPaths({
+ const shouldDebounceIgnoredQuery = nameFilter !== null
+ const effectiveIgnoredPaths = useFileExplorerIgnoredPaths({
activeWorktreeId,
canLoadIgnoredPaths,
- ignoredPathResult,
+ relativePaths,
+ shouldDebounceIgnoredQuery,
worktreePath
})
const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths])
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index dbd4b752e6b..5268a890e36 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -62,9 +62,19 @@ import {
DialogTitle
} from '../ui/dialog'
import { getCodexAccountAuthWarning } from './codex-account-auth-warning'
+import {
+ getProviderAccountActiveIdForView,
+ getProviderAccountRuntime,
+ providerAccountIsActiveInView,
+ providerAccountMatchesView,
+ WSL_DEFAULT_DISTRO_KEY,
+ type ProviderAccountRuntimeView
+} from './provider-account-visibility'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
+ emptyClaudeAccountsState,
+ emptyCodexAccountsState,
hasRemoteProviderAccountOwner,
removeClaudeProviderAccount,
removeCodexProviderAccount,
@@ -146,6 +156,7 @@ type AccountsPaneProps = {
wslAvailable?: boolean
wslDistros?: string[]
wslCapabilitiesLoading?: boolean
+ accountOwnerPlatform?: NodeJS.Platform | null
}
function getHostRuntimeLabel(): string {
@@ -164,46 +175,6 @@ function getCodexAccountLabel(
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
}
-function getActiveCodexAccountIdForRuntime(
- state: CodexRateLimitAccountsState,
- runtime: LocalAccountRuntime
-): string | null {
- if (runtime.runtime === 'host') {
- return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId
- }
- if (runtime.wslDistro) {
- return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null
- }
- const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__
- if (defaultSelection) {
- return defaultSelection
- }
- const selectedIds = Array.from(
- new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean))
- )
- return selectedIds.length === 1 ? selectedIds[0] : null
-}
-
-function getActiveClaudeAccountIdForRuntime(
- state: ClaudeRateLimitAccountsState,
- runtime: LocalAccountRuntime
-): string | null {
- if (runtime.runtime === 'host') {
- return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId
- }
- if (runtime.wslDistro) {
- return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null
- }
- const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__
- if (defaultSelection) {
- return defaultSelection
- }
- const selectedIds = Array.from(
- new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean))
- )
- return selectedIds.length === 1 ? selectedIds[0] : null
-}
-
function getClaudeAccountLabel(
state: ClaudeRateLimitAccountsState,
accountId: string | null | undefined
@@ -288,26 +259,6 @@ type LocalAccountRuntime = {
label: string
}
-function accountMatchesRuntime(
- account:
- | CodexRateLimitAccountsState['accounts'][number]
- | ClaudeRateLimitAccountsState['accounts'][number],
- runtime: LocalAccountRuntime
-): boolean {
- const accountRuntime =
- 'authMethod' in account
- ? (account.managedAuthRuntime ?? 'host')
- : (account.managedHomeRuntime ?? 'host')
- const accountDistro = account.wslDistro ?? null
- if (runtime.runtime === 'host') {
- return accountRuntime !== 'wsl'
- }
- if (accountRuntime !== 'wsl') {
- return false
- }
- return runtime.wslDistro ? accountDistro === runtime.wslDistro : true
-}
-
function getSelectedAccountRuntime(
settings: GlobalSettings,
wslSupportedPlatform: boolean,
@@ -344,7 +295,8 @@ export function AccountsPane({
wslSupportedPlatform = false,
wslAvailable = false,
wslDistros = EMPTY_WSL_DISTROS,
- wslCapabilitiesLoading = false
+ wslCapabilitiesLoading = false,
+ accountOwnerPlatform = null
}: AccountsPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const codexRateLimits = useAppStore((s) => s.rateLimits.codex)
@@ -388,33 +340,51 @@ export function AccountsPane({
? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}`
: localAccountRuntime.label
- const [codexAccounts, setCodexAccounts] = useState({
- accounts: [],
- activeAccountId: null,
- activeAccountIdsByRuntime: { host: null, wsl: {} }
- })
+ const [codexAccounts, setCodexAccounts] =
+ useState(emptyCodexAccountsState)
const [codexAccountsLoaded, setCodexAccountsLoaded] = useState(false)
const [codexAction, setCodexAction] = useState<
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
>('idle')
- const [claudeAccounts, setClaudeAccounts] = useState({
- accounts: [],
- activeAccountId: null,
- activeAccountIdsByRuntime: { host: null, wsl: {} }
- })
+ const [claudeAccounts, setClaudeAccounts] =
+ useState(emptyClaudeAccountsState)
const [claudeAction, setClaudeAction] = useState<
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
>('idle')
- const [removeAccountId, setRemoveAccountId] = useState(null)
- const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null)
+ // Why: capture the account's runtime slot when the dialog opens; the roster
+ // can change underneath an open dialog and lose the slot to diff for restarts.
+ const [removeCodexTarget, setRemoveCodexTarget] = useState<{
+ id: string
+ runtime: ProviderAccountRuntimeView
+ } | null>(null)
+ const [removeClaudeTarget, setRemoveClaudeTarget] = useState<{
+ id: string
+ runtime: ProviderAccountRuntimeView
+ } | null>(null)
+ const accountVisibilityOptions = {
+ remoteOwner: isRemoteAccountScope,
+ ownerPlatform: accountOwnerPlatform
+ }
const visibleClaudeAccounts = claudeAccounts.accounts.filter((account) =>
- accountMatchesRuntime(account, accountRuntime)
+ providerAccountMatchesView(account, accountRuntime, accountVisibilityOptions)
)
const visibleCodexAccounts = codexAccounts.accounts.filter((account) =>
- accountMatchesRuntime(account, accountRuntime)
+ providerAccountMatchesView(account, accountRuntime, accountVisibilityOptions)
+ )
+ const activeCodexAccountId = getProviderAccountActiveIdForView(codexAccounts, accountRuntime)
+ // Why: System default lights only when no account row is active; while a remote
+ // owner's platform is unknown WSL rows hide fail-closed, so check the full roster.
+ const ownerPlatformUnknown = isRemoteAccountScope && accountOwnerPlatform === null
+ const systemCodexActive = !(
+ ownerPlatformUnknown ? codexAccounts.accounts : visibleCodexAccounts
+ ).some((account) =>
+ providerAccountIsActiveInView(account, codexAccounts, accountRuntime, accountVisibilityOptions)
+ )
+ const systemClaudeActive = !(
+ ownerPlatformUnknown ? claudeAccounts.accounts : visibleClaudeAccounts
+ ).some((account) =>
+ providerAccountIsActiveInView(account, claudeAccounts, accountRuntime, accountVisibilityOptions)
)
- const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime)
- const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime)
// Why: the auth warning is derived from the desktop's own rate-limit poll;
// with a remote owner it would misattribute local auth state to the server.
const activeCodexAuthWarning =
@@ -518,9 +488,15 @@ export function AccountsPane({
{ activeRuntimeEnvironmentId },
{
onSnapshot: (snapshot) => {
- setCodexAccounts(snapshot.codex)
- setClaudeAccounts(snapshot.claude)
- setCodexAccountsLoaded(true)
+ // Why: a failed provider's half is a substituted empty roster, not
+ // authoritative data; keep prior state and leave the loaded gate shut.
+ if (!snapshot.failedProviders?.includes('codex')) {
+ setCodexAccounts(snapshot.codex)
+ setCodexAccountsLoaded(true)
+ }
+ if (!snapshot.failedProviders?.includes('claude')) {
+ setClaudeAccounts(snapshot.claude)
+ }
},
onError: (error) => {
toast.error(
@@ -615,11 +591,11 @@ export function AccountsPane({
/>
{wslSupportedPlatform && accountRuntime.runtime === 'wsl' ? (