From e35deb9720f8154faeceb0bddbb336a49671ef62 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:51:49 -0400 Subject: [PATCH 01/17] Fix serve-created mobile terminals on desktop * Fix serve-created mobile terminals on desktop Co-authored-by: Orca * Address headless mobile terminal merge review Co-authored-by: Orca * Document headless mobile merge invariants Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 264 ++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 334 ++++++++++++++++++++++++-- 2 files changed, 573 insertions(+), 25 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 54354fc8335..d295d8e6c4b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7020,6 +7020,270 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: created.tab.id, + parentTabId: created.tab.parentTabId, + leafId: created.tab.leafId, + ptyId: 'serve-mobile-pty', + status: 'ready' + }) + ]) + }) + + it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:split-siblings', + snapshotVersion: 1, + activeGroupId: 'headless-group', + activeTabId: 'host-tab::pane:2', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'headless-group', + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + } + ], + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'left', + isActive: false + }, + { + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2', + title: 'right', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2' + }) + ]) + expect(listed.activeTabId).toBe('host-tab::pane:2') + }) + + it('keeps preserved headless mobile session publication epochs idempotent', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:stable-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'host-tab::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const firstMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch) + expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1) + }) + + it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => { + const focusTerminal = vi.fn() + const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-persisted-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Mobile Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-persisted-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: `host-tab::${HEADLESS_LEAF_ID}`, + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId: 'serve-persisted-pty', + status: 'pending-handle' + }) + ]) + expect(listed.tabGroups?.[0]).toMatchObject({ + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + }) + + const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'serve-persisted-pty', + persistHostSessionBinding: true, + worktreeId: TEST_WORKTREE_ID + }) + ) + expect(focusTerminal).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready' + }) + }) + it('hydrates legacy persisted terminal tabs without layout entries', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aede15959e4..fe85fe4f0ef 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -163,6 +163,7 @@ import type { RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabGroup, + RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, @@ -2127,9 +2128,13 @@ export class OrcaRuntimeService { private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, - options: { force?: boolean } = {} + options: { + force?: boolean + allowAttachedWindow?: boolean + onlyServeOwnedTerminals?: boolean + } = {} ): void { - if (this.getAvailableAuthoritativeWindow()) { + if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return } const session = this.store?.getWorkspaceSession?.() @@ -2142,35 +2147,150 @@ export class OrcaRuntimeService { : Object.entries(session.tabsByWorktree ?? {}) for (const [entryWorktreeId, persistedTabs] of entries) { const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) - if (existing && existing.tabs.length > 0 && options.force !== true) { + if ( + existing && + existing.tabs.length > 0 && + options.force !== true && + options.onlyServeOwnedTerminals !== true + ) { continue } - const tabs = this.buildHeadlessMobileSessionTerminalTabs(entryWorktreeId, persistedTabs) + const tabs = this.buildHeadlessMobileSessionTerminalTabs( + entryWorktreeId, + persistedTabs + ).filter( + (tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab) + ) if (tabs.length === 0) { continue } const activeTab = this.pickHeadlessActiveTerminalTab(tabs) const tabOrder = this.collectHeadlessParentTabOrder(tabs) const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) + const mergedTabs = + options.onlyServeOwnedTerminals === true && existing + ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) + : tabs + const mergedActiveTab = + existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? + activeTab ?? + (mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null) + const mergedTerminalTabs = mergedTabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) this.mobileSessionTabsByWorktree.set(entryWorktreeId, { - worktree: entryWorktreeId, + worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: groupId, - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab ? 'terminal' : null, - tabGroups: [ - { - id: groupId, - activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, - tabOrder - } - ], - tabs + activeGroupId: existing?.activeGroupId ?? groupId, + activeTabId: mergedActiveTab?.id ?? null, + activeTabType: mergedActiveTab?.type ?? null, + tabGroups: + options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ) + : [ + { + id: groupId, + activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, + tabOrder + } + ], + tabs: mergedTabs }) } } + private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith('serve-') + } + + private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { + if (this.isServeOwnedPtyId(tab.ptyId)) { + return true + } + return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => + this.isServeOwnedPtyId(ptyId) + ) + } + + private mergeMobileSessionSnapshotTabs( + baseTabs: readonly RuntimeMobileSessionSnapshotTab[], + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): RuntimeMobileSessionSnapshotTab[] { + const seenIds = new Set() + const merged: RuntimeMobileSessionSnapshotTab[] = [] + const add = (tab: RuntimeMobileSessionSnapshotTab): void => { + const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) + if (ids.some((id) => seenIds.has(id))) { + return + } + for (const id of ids) { + seenIds.add(id) + } + merged.push(tab) + } + for (const tab of baseTabs) { + add(tab) + } + for (const tab of extraTabs) { + add(tab) + } + return merged + } + + private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { + if (tab.type === 'terminal') { + // Why: split terminal leaves share one parent tab; merge dedup must stay + // leaf-scoped or preserved siblings collapse into a single surface. + return [tab.id, `${tab.parentTabId}::${tab.leafId}`] + } + if (tab.type === 'browser') { + return [tab.id, tab.browserWorkspaceId] + } + return [tab.id] + } + + private mergeMobileSessionTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + terminalTabs: readonly RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ): RuntimeMobileSessionTabGroup[] { + const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) + if (parentTabOrder.length === 0) { + return [...groups] + } + const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) + const nextGroups = + groups.length > 0 + ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + : [ + { + id: targetGroupId, + activeTabId: null, + tabOrder: [] + } + ] + const target = nextGroups[0]! + for (const tabId of parentTabOrder) { + if (!target.tabOrder.includes(tabId)) { + target.tabOrder.push(tabId) + } + } + const activeParentId = + activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null + target.activeTabId = + activeParentId && target.tabOrder.includes(activeParentId) + ? activeParentId + : (target.tabOrder[0] ?? null) + return nextGroups + } + private buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[] @@ -2468,11 +2588,14 @@ export class OrcaRuntimeService { const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find( (candidate) => candidate.type === 'terminal' && candidate.id === tab.id ) - if ( + // Why: serve-created tabs can be visible before any renderer has adopted + // their tab id, so focusing the renderer would silently no-op. + const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && - !this.notifier?.focusTerminal - ) { + (!this.notifier?.focusTerminal || + this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) + if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined try { await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, { @@ -2511,6 +2634,16 @@ export class OrcaRuntimeService { return this.getMobileSessionTabsForWorktree(worktreeId) } + private shouldMaterializeHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -12287,26 +12420,177 @@ export class OrcaRuntimeService { if (snapshots === undefined) { return } + // Why: renderer graphs are authoritative for renderer tabs, but headless + // serve terminals never enter that graph unless we preserve their bindings. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { + allowAttachedWindow: true, + onlyServeOwnedTerminals: true + }) const nextWorktrees = new Set() for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) if ( !existing || - snapshot.publicationEpoch !== existing.publicationEpoch || - snapshot.snapshotVersion >= existing.snapshotVersion + nextSnapshot.publicationEpoch !== existing.publicationEpoch || + nextSnapshot.snapshotVersion >= existing.snapshotVersion ) { - this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) + this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot) } } - for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) { + for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.notifyMobileSessionTabsRemoved(worktreeId) + const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) + if (preserved) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + nextWorktrees.add(worktreeId) + } else { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) + } } } } + private mergePreservedHeadlessMobileSessionTabs( + snapshot: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): RuntimeMobileSessionTabsSnapshot { + if (!existing) { + return snapshot + } + const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) + if (preservedTabs.length === 0) { + return snapshot + } + const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) + const normalizedPreservedTabs = preservedTabs.map((tab) => + hasIncomingActiveTab ? { ...tab, isActive: false } : tab + ) + const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) + if (tabs.length === snapshot.tabs.length) { + return snapshot + } + const activeTab = + snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...snapshot, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch( + snapshot, + normalizedPreservedTabs + ), + snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), + activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private buildPreservedHeadlessMobileSessionSnapshot( + existing: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot | null { + const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) + if (tabs.length === 0) { + return null + } + const activeTab = + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...existing, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + activeGroupId: + existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + existing.worktree, + existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private collectPreservedHeadlessMobileSessionTabs( + existing: RuntimeMobileSessionTabsSnapshot, + incoming?: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionSnapshotTab[] { + const incomingIds = new Set( + incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] + ) + return existing.tabs.filter((tab) => { + if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { + return false + } + return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + }) + } + + private shouldPreserveHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionSnapshotTab + ): boolean { + if (tab.type !== 'terminal') { + return false + } + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + + private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { + return ( + publicationEpoch.startsWith('headless:') || + publicationEpoch.startsWith('headless-hydrated:') || + publicationEpoch.includes(':headless-merge:') + ) + } + + private getMergedMobileSessionPublicationEpoch( + snapshot: RuntimeMobileSessionTabsSnapshot, + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string { + // Why: preserved snapshots can be merged repeatedly; normalize the prior + // merge suffix before recomputing so the publication epoch is idempotent. + const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] + const signature = createHash('sha1') + .update( + preservedTabs + .map((tab) => + tab.type === 'terminal' + ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` + : tab.id + ) + .join('|') + ) + .digest('hex') + .slice(0, 12) + return `${normalizedPublicationEpoch}:headless-merge:${signature}` + } + private notifyMobileSessionTabsRemoved(worktreeId: string): void { const removed: RuntimeMobileSessionTabsRemovedResult = { worktree: worktreeId, From f0945c7aa233e1ce21e9a7aa9ee353e176c6fd8b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:39:23 -0700 Subject: [PATCH 02/17] allow agy to be one of the commit agent (#4852) --- src/shared/commit-message-agent-spec.test.ts | 57 +++++++++++++++++++- src/shared/commit-message-agent-spec.ts | 28 ++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 2c42a59906d..f3d31207e3c 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -10,6 +10,7 @@ import { isCustomAgentId, listCommitMessageAgentCapabilities, listCommitMessageAgentIds, + parseAntigravityModels, parseCodexModels, parseCursorModels, parseLineModels, @@ -20,7 +21,17 @@ import { describe('COMMIT_MESSAGE_AGENT_SPECS', () => { it('exposes the installed local agents as commit-message agents', () => { const ids = listCommitMessageAgentIds().sort() - expect(ids).toEqual(['amp', 'claude', 'codex', 'copilot', 'cursor', 'kimi', 'opencode', 'pi']) + expect(ids).toEqual([ + 'amp', + 'antigravity', + 'claude', + 'codex', + 'copilot', + 'cursor', + 'kimi', + 'opencode', + 'pi' + ]) }) it('uses the strongest available defaults for core agents', () => { @@ -255,6 +266,30 @@ describe('model discovery parsers', () => { } ]) }) + + it('parses Antigravity model output', () => { + const output = [ + 'Gemini 3.5 Flash (Medium)', + 'Gemini 3.5 Flash (High)', + 'Gemini 3.5 Flash (Low)', + 'Gemini 3.1 Pro (Low)', + 'Gemini 3.1 Pro (High)', + 'Claude Sonnet 4.6 (Thinking)', + 'Claude Opus 4.6 (Thinking)', + 'GPT-OSS 120B (Medium)' + ].join('\n') + + expect(parseAntigravityModels(output)).toEqual([ + { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, + { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, + { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' }, + { id: 'Gemini 3.1 Pro (Low)', label: 'Gemini 3.1 Pro (Low)' }, + { id: 'Gemini 3.1 Pro (High)', label: 'Gemini 3.1 Pro (High)' }, + { id: 'Claude Sonnet 4.6 (Thinking)', label: 'Claude Sonnet 4.6 (Thinking)' }, + { id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6 (Thinking)' }, + { id: 'GPT-OSS 120B (Medium)', label: 'GPT-OSS 120B (Medium)' } + ]) + }) }) describe('buildArgs (Codex)', () => { @@ -295,3 +330,23 @@ describe('buildArgs (Codex)', () => { expect(args).not.toContain('-c') }) }) + +describe('buildArgs (Antigravity)', () => { + const spec = getCommitMessageAgentSpec('antigravity')! + + it('runs agy with --print, --sandbox, and --model flags', () => { + const args = spec.buildArgs({ prompt: '', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args).toEqual(['--print', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)']) + expect(spec.promptDelivery).toBe('stdin') + }) + + it('uses dynamic model discovery via agy models', () => { + expect(spec.modelSource).toBe('dynamic') + expect(spec.modelDiscovery?.binary).toBe('agy') + expect(spec.modelDiscovery?.args).toEqual(['models']) + }) + + it('uses Gemini 3.5 Flash (Medium) as default model', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.antigravity?.defaultModelId).toBe('Gemini 3.5 Flash (Medium)') + }) +}) diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 883a7af5fbf..25d938d1f66 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -205,6 +205,19 @@ export function parseCursorModels(stdout: string): CommitMessageModel[] { ) } +export function parseAntigravityModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((id) => ({ + id, + label: id + })) + ) +} + export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { claude: { id: 'claude', @@ -568,6 +581,21 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial ['--print', '--sandbox', '--model', model], + modelSource: 'dynamic', + modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, + models: [ + { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, + { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, + { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' } + ], + defaultModelId: 'Gemini 3.5 Flash (Medium)' } } From 3ef63c972a30496e74481e8c73dc32dc51430d80 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:41:25 -0400 Subject: [PATCH 03/17] Persist agent session metadata for workspace resume (#4706) Co-authored-by: Orca --- .../agent-session-resume-cli-evidence.md | 341 ++++++++++++++++++ src/main/agent-hooks/server.test.ts | 3 + src/main/agent-hooks/server.ts | 7 + src/main/index.ts | 2 + src/main/opencode/hook-service.test.ts | 10 + src/main/opencode/hook-service.ts | 8 +- ...ay-session-agent-hooks.integration.test.ts | 4 +- src/main/ssh/ssh-relay-session.ts | 2 + src/relay/agent-hook-server.ts | 1 + .../components/sidebar/WorktreeCardAgents.tsx | 2 +- src/renderer/src/hooks/useIpcEvents.test.ts | 36 +- src/renderer/src/hooks/useIpcEvents.ts | 3 +- .../src/lib/resume-sleeping-agent-session.ts | 80 ++++ src/renderer/src/lib/tui-agent-startup.ts | 1 + .../lib/workspace-session-focus-recency.ts | 10 + .../src/lib/workspace-session-patch.test.ts | 10 + .../src/lib/workspace-session-patch.ts | 7 +- .../workspace-session-relevant-fields.test.ts | 3 +- .../lib/workspace-session-sleeping-agents.ts | 10 + src/renderer/src/lib/workspace-session.ts | 21 +- .../worktree-activation-created-agent.test.ts | 86 +++++ src/renderer/src/lib/worktree-activation.ts | 6 + src/renderer/src/store/slices/agent-status.ts | 169 ++++++++- .../src/store/slices/store-cascades.test.ts | 93 +++++ src/renderer/src/store/slices/terminals.ts | 12 + src/shared/agent-hook-listener.ts | 8 + src/shared/agent-hook-relay.ts | 3 + src/shared/agent-session-resume.test.ts | 47 +++ src/shared/agent-session-resume.ts | 151 ++++++++ src/shared/agent-status-types.ts | 6 + src/shared/tui-agent-startup.test.ts | 35 +- src/shared/tui-agent-startup.ts | 51 ++- src/shared/types.ts | 3 + src/shared/workspace-session-schema.test.ts | 175 +++++++++ src/shared/workspace-session-schema.ts | 45 ++- 35 files changed, 1412 insertions(+), 39 deletions(-) create mode 100644 docs/reference/agent-session-resume-cli-evidence.md create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session.ts create mode 100644 src/renderer/src/lib/workspace-session-focus-recency.ts create mode 100644 src/renderer/src/lib/workspace-session-sleeping-agents.ts create mode 100644 src/shared/agent-session-resume.test.ts create mode 100644 src/shared/agent-session-resume.ts diff --git a/docs/reference/agent-session-resume-cli-evidence.md b/docs/reference/agent-session-resume-cli-evidence.md new file mode 100644 index 00000000000..5c26f9ecbc6 --- /dev/null +++ b/docs/reference/agent-session-resume-cli-evidence.md @@ -0,0 +1,341 @@ +# Agent Session Resume Evidence for Sleep/Wake + +Date: June 5, 2026 + +Scope: issue [stablyai/orca#1796](https://github.com/stablyai/orca/issues/1796), current worktree code, installed agent CLIs on this machine, cloned open-source CLI repos under `/tmp/orca-agent-resume-research`, and official provider docs where available. + +## Executive conclusion + +Orca can implement one-click agent resume on wake, but it cannot use terminal PTY IDs, pane keys, tab IDs, or worktree IDs as the agent session ID. Those are Orca terminal identifiers. The required value is the provider's conversation/session/thread ID. + +For several first-tier providers, the exact provider ID is available in hook payloads today, but Orca drops it during normalization: + +- Claude: hook payloads include `session_id`; resume command is `claude --resume `. +- Codex: hook runtime sends `session_id`; resume command is `codex resume `. +- Gemini: hook payloads include `session_id`; resume command is `gemini --resume `. +- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation `. +- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session `. +- Droid: hook payloads include `session_id`; resume command is `droid --resume `. +- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume `. + +The current blocker is not primarily "how do we resume?" The exact resume action is to spawn a new agent process on wake in the same workspace/pane context using the provider-specific resume command. The blocker is that Orca's current hook event and status types have no durable provider-session field, so the ID is discarded before sleep. + +## Current Orca behavior + +Issue #1796 is accurate about current behavior. The issue says sleep loses agent type, session ID, last prompt, and resume command because `dropAgentStatusByWorktree` wipes rows before wake can offer resume. + +Verified current code path: + +- Sleep calls terminal shutdown with identifiers preserved, not provider conversation metadata. See `src/renderer/src/components/sidebar/sleep-worktree-flow.ts`. +- `shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })` preserves terminal IDs/layout wake hints, then calls `dropAgentStatusByWorktree(worktreeId)`. See `src/renderer/src/store/slices/terminals.ts`. +- `agentStatusByPaneKey` is explicitly documented as "Real-time only - lives in renderer memory, not persisted to disk." See `src/renderer/src/store/slices/agent-status.ts`. +- `WorkspaceSessionState` persists terminal/browser/editor/SSH state, including terminal layouts and remote relay PTY IDs, but no sleeping-agent records. See `src/shared/types.ts`, `src/renderer/src/lib/workspace-session.ts`, and `src/shared/workspace-session-schema.ts`. +- `AgentStatusEntry` has `state`, `prompt`, timestamps, `agentType`, pane/tab/worktree attribution, tool previews, assistant preview, and orchestration context. It has no provider session ID or resume command field. See `src/shared/agent-status-types.ts`. + +The existing sleep persistence is terminal-resume metadata. The feature needs provider-conversation-resume metadata. + +## Hook pipeline gap + +The current local and SSH hook wire shapes drop provider IDs: + +- `AgentHookEventPayload` in `src/shared/agent-hook-listener.ts` includes pane/tab/worktree, prompt cache fields, hook event name, Claude tool/subagent IDs, and normalized `payload`. It has no provider session ID field. +- `AgentHookRelayEnvelope` in `src/shared/agent-hook-relay.ts` mirrors that normalized shape for SSH. Its comment states the relay normalizes before sending the envelope. Therefore remote/SSH events lose provider IDs at the relay boundary too unless the new field is added to the shared envelope. +- `ParsedAgentStatusPayload` in `src/shared/agent-status-types.ts` includes only status fields: `state`, `prompt`, `agentType`, `toolName`, `toolInput`, `lastAssistantMessage`, `interrupted`. + +Implementation implication: provider-session metadata must be extracted before normalization completes and must be carried through both the local HTTP path and the SSH relay path. + +## Installed CLI versions checked + +These exact binaries were available on this machine: + +| Agent | Version checked | +| --- | --- | +| Claude Code | `2.1.165` | +| Codex | `codex-cli 0.137.0` | +| Gemini | `0.44.0` | +| OpenCode | `1.15.13` | +| Cursor Agent | `2025.09.18-7ae6800` | +| Antigravity | `1.0.5` | +| Droid | `0.122.0` | +| Grok | `grok 0.2.22 (967574cb117) [stable]` | +| Pi | `0.72.1` | +| Amp, Copilot, Command Code, Hermes | Not installed locally | + +## Provider matrix + +Legend: + +- `Exact supported`: exact resume command and exact ID source are both verified. +- `Resume verified, ID capture missing`: CLI can resume, but current Orca hook path does not expose a durable exact ID. +- `ID seen, resume not verified`: Orca/source exposes an ID, but no exact resume CLI command was verified. +- `Out of current hook scope`: Orca has no current hook source for this TUI agent. + +| Agent | Resume command evidence | Exact ID source evidence | Current Orca support status | +| --- | --- | --- | --- | +| Claude | `claude --resume ` verified by local `claude --help` and Anthropic CLI docs. | Claude hook docs show hook input includes `session_id` and `transcript_path`; current Orca normalizer drops `session_id`. | Exact supported after adding provider-session metadata extraction. | +| Codex | `codex resume ` verified by local help and cloned Codex source `codex-rs/utils/cli/src/resume_command.rs`. | Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on `SessionStart`, `PreToolUse`, `PermissionRequest`, and `PostToolUse` hook requests. | Exact supported after extraction. | +| Gemini | `gemini --resume ` verified by local help and Gemini docs. | Gemini hooks reference says all hooks receive `session_id` and `transcript_path`. | Exact supported after extraction. | +| Antigravity | `agy --conversation ` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. | +| OpenCode | `opencode --session ` verified by local help, official docs, and cloned OpenCode TUI source. | Orca's OpenCode plugin already reads `event.properties?.sessionID` before posting. | Exact supported after preserving `sessionID`. | +| Droid | `droid --resume ` verified by local help and Factory CLI docs. | Factory hooks reference includes `session_id`; Orca installs Droid hooks and already subscribes to `SessionStart`. | Exact supported after extraction. | +| Grok | `grok --resume ` verified by local help. | Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`, but only to locate chat history. | Exact supported after preserving that same ID. | +| Cursor | `cursor-agent --resume ` and `cursor-agent resume` verified by local help and Cursor docs. | Current Orca Cursor hook install deliberately omits `sessionStart`/`sessionEnd`; the subscribed event set does not currently prove a chat ID source. `cursor-agent create-chat` was locally verified to return a UUID, but preallocation for Orca launches still needs an end-to-end test before relying on it. | Resume verified, ID capture missing. | +| Pi | `pi --session `, `pi --resume`, and `pi --continue` verified by local help and Pi docs. | Orca's bundled Pi/OMP extension posts status events but does not include session file path, session UUID, or session name in the status payload. | Resume verified, ID capture missing. | +| OMP | Same launch family as Pi in Orca. | Orca reuses Pi extension API shape and does not include exact session pointer. | Resume verified by inheritance only from Pi-like CLI contract; exact OMP ID capture missing. | +| Amp | Orca's managed plugin posts `threadId: event.thread.id`. | Amp is not installed locally, and no exact Amp CLI resume command was verified in this investigation. | ID seen, resume not verified. | +| Hermes | Orca's managed Hermes plugin selects `session_id` for session/LLM/tool events. | Hermes is not installed locally, and no exact Hermes CLI resume command was verified in this investigation. | ID seen, resume not verified. | +| Copilot | Orca installs broad Copilot hooks. | Local Copilot CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | +| Command Code | Orca installs hooks for `PreToolUse`, `PostToolUse`, and `Stop`. | Local Command Code CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | +| openclaude, autohand, aider, goose, kilo, kiro, crush, aug, cline, codebuff, continue, kimi, mistral-vibe, qwen-code, rovo, openclaw | Not investigated to exact-resume standard. | Not in `HOOK_SOURCE_BY_PATHNAME` today. | Out of current hook scope. | + +## Exact resume behavior by provider + +### Claude + +Verified: + +- Local `claude --help` shows `--continue`, `--resume [value]`, `--session-id `, `--fork-session`, and `--no-session-persistence`. +- Anthropic CLI docs show `claude -r "" "query"` and document `--resume` as resuming a specific session by ID. +- Claude hooks docs show hook input includes `session_id` and `transcript_path`. + +Resume command: + +```sh +claude --resume +``` + +If Orca stores `lastPromptSummary` separately, it should not pass that summary as a new user prompt by default. The one-click resume action should reopen the prior conversation. Auto-submitting a summary would create a new turn. + +### Codex + +Verified: + +- Local `codex resume --help` accepts `[SESSION_ID] [PROMPT]`, where the session target is a UUID or session name. +- Cloned Codex source `codex-rs/utils/cli/src/resume_command.rs` formats `codex resume `. +- Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on hook requests. +- Cloned Codex source has tests asserting resumed root sessions use the thread ID as session ID. + +Resume command: + +```sh +codex resume +``` + +Avoid `codex resume --last` for this feature when an exact ID is available. `--last` can resume the wrong conversation if another Codex session ran after the slept workspace. + +### Gemini + +Verified: + +- Local `gemini --help` shows `--resume`, `--session-id`, `--session-file`, and `--list-sessions`. +- Gemini official hooks reference states all hook input includes `session_id` and `transcript_path`. +- Gemini official configuration docs describe `--resume [session_id]` and state that a UUID can be supplied. + +Resume command: + +```sh +gemini --resume +``` + +### Antigravity + +Verified: + +- Local `agy --help` shows `--continue` and `--conversation`. +- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation ` for a specific session. +- Antigravity hooks docs define common hook input `conversationId`. + +Resume command: + +```sh +agy --conversation +``` + +### OpenCode + +Verified: + +- Local `opencode --help` shows `--continue` and `--session`. +- Official OpenCode docs list `--session` as the session ID to continue. +- Cloned OpenCode source `packages/opencode/src/cli/cmd/tui/thread.ts` defines `--session` and passes it into TUI args as `sessionID`. +- Cloned OpenCode source `validate-session.ts` validates the supplied session ID through the OpenCode client. +- Orca's managed OpenCode plugin reads `event.properties?.sessionID`. + +Resume command: + +```sh +opencode --session +``` + +### Droid + +Verified: + +- Local `droid --help` shows `--resume [sessionId]`, `--fork `, and `--cwd`. +- Factory CLI docs list resume behavior and `droid exec -s ` for exec mode. +- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`. + +Resume command: + +```sh +droid --resume +``` + +### Grok + +Verified: + +- Local `grok --help` shows `--resume []`, `--continue`, `--cwd`, and `--restore-code`. +- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`. + +Resume command: + +```sh +grok --resume +``` + +### Cursor + +Verified: + +- Local `cursor-agent --help` shows `--resume [chatId]`. +- Cursor docs show listing prior chats with `cursor-agent ls`, resuming latest with `cursor-agent resume`, and resuming a specific conversation with `cursor-agent --resume="chat-id-here"`. +- Local help shows `cursor-agent create-chat` creates an empty chat and returns its ID. +- Running `cursor-agent create-chat` locally returned UUID `1fff2e62-9dd0-4cf3-9d8c-3569fea0aff7`, proving the command can preallocate a chat ID without an initial prompt. + +Not verified: + +- Current Orca Cursor hook install subscribes to `beforeSubmitPrompt`, `stop`, tool events, approval events, and `afterAgentResponse`, but intentionally does not subscribe to `sessionStart`/`sessionEnd`. +- I did not verify a current subscribed Cursor hook payload containing `chatId`. +- I did not run `cursor-agent --resume ` because that would start a real agent turn and may make workspace changes. + +Conclusion: exact Cursor resume is CLI-supported, but Orca currently needs either a verified hook payload chat ID or a launch preallocation strategy that is tested end to end. + +### Pi and OMP + +Verified: + +- Local `pi --help` shows `--continue`, `--resume`, `--session `, `--fork `, `--session-dir`, and `--no-session`. +- Pi docs state `/resume` opens a picker and `pi -r` opens the same picker at startup. +- Orca's Pi/OMP extension posts status events from Pi's extension API, but the status events do not include session path, UUID, or session name. + +Resume command: + +```sh +pi --session +``` + +Conclusion: exact Pi resume is CLI-supported, but current Orca capture is missing the exact pointer. + +## What Orca should store + +Store a durable record per sleeping agent pane, not per worktree only: + +```ts +type SleepingAgentSessionRecord = { + id: string + worktreeId: string + tabId?: string + paneKey: string + connectionId: string | null + agentType: TuiAgent + providerSession: { + kind: 'session_id' | 'conversation_id' | 'thread_id' | 'chat_id' | 'path' + value: string + } + resumeCommand: { + argv: string[] + cwd?: string + } + lastPrompt: string + lastAssistantMessage?: string + capturedAt: number + retentionUntil?: number | null +} +``` + +Important constraints: + +- `resumeCommand.argv` should be structured argv, not a shell string. This avoids cross-platform quoting bugs. +- Do not persist a terminal PTY ID as `providerSession.value`. +- For SSH, `cwd` and the command execute on the remote host. The provider session ID also belongs to the remote CLI's session store. +- For local workspaces, execute locally in the worktree path. +- For auto-resume, launch exactly one process per sleeping agent record and guard against double-click/duplicate wake races. + +## Provider extraction map + +This is the extraction map that is supported by the evidence above: + +| Hook source | Provider session field(s) to extract | Resume argv | +| --- | --- | --- | +| `claude` | `session_id` | `['claude', '--resume', id]` | +| `codex` | `session_id` | `['codex', 'resume', id]` | +| `gemini` | `session_id` | `['gemini', '--resume', id]` | +| `antigravity` | `conversationId` | `['agy', '--conversation', id]` | +| `opencode` | `sessionID` | `['opencode', '--session', id]` | +| `droid` | `session_id` | `['droid', '--resume', id]` | +| `grok` | `sessionId`, fallback `session_id` | `['grok', '--resume', id]` | +| `amp` | `threadId` | not enabled until Amp resume command is verified | +| `hermes` | `session_id` | not enabled until Hermes resume command is verified | + +## Required implementation shape + +1. Add a provider-session metadata type to shared hook types. +2. Extract provider session evidence in `parseAgentHookEvent` from raw `hookPayloadRecord` before normalizing to `ParsedAgentStatusPayload`. +3. Carry it in `AgentHookEventPayload`. +4. Carry it in `AgentHookRelayEnvelope` so SSH does not lose it. +5. Store latest provider-session evidence by pane key alongside live agent status. +6. On sleep, snapshot the live agent status plus latest provider-session evidence before `dropAgentStatusByWorktree`. +7. Persist sleeping-agent records in `WorkspaceSessionState` and schema. +8. On wake, render sleeping resumable rows separately from active rows and launch the provider-specific structured argv when the user resumes. +9. Clear records on explicit worktree removal, not on sleep. +10. If the provider session no longer exists, let the provider CLI fail and surface that failure without deleting the record until the user dismisses it. + +## Open questions that remain genuinely unverified + +- Cursor: whether any currently subscribed Cursor hook payload carries `chatId`. If not, the only plausible exact strategy is preallocating a chat with `cursor-agent create-chat` at launch and starting/resuming that known chat. That needs an end-to-end test before productizing. +- Pi/OMP: whether the extension API can expose the current session file/path/UUID. Current Orca extension does not forward it. +- Amp: whether the Amp CLI has an exact thread resume command matching `threadId`. +- Hermes: whether Hermes has an exact session resume CLI command matching `session_id`. +- Copilot and Command Code: exact resume command and exact hook session field were not verified because the CLIs were not installed locally and no sufficient primary docs/source were found during this pass. +- Non-hook TUI agents: no exact support should be promised until Orca adds a hook/metadata capture path for each. + +## Sources + +Local Orca source: + +- `src/shared/agent-hook-listener.ts` +- `src/shared/agent-hook-relay.ts` +- `src/shared/agent-status-types.ts` +- `src/shared/types.ts` +- `src/shared/tui-agent-config.ts` +- `src/renderer/src/store/slices/terminals.ts` +- `src/renderer/src/store/slices/agent-status.ts` +- `src/renderer/src/lib/workspace-session.ts` +- `src/shared/workspace-session-schema.ts` +- `src/main/opencode/hook-service.ts` +- `src/main/amp/hook-service.ts` +- `src/main/hermes/hook-service.ts` +- `src/main/pi/agent-status-extension-source.ts` +- `src/main/cursor/hook-service.ts` + +Cloned source: + +- `/tmp/orca-agent-resume-research/codex` +- `/tmp/orca-agent-resume-research/gemini-cli` +- `/tmp/orca-agent-resume-research/opencode` + +Official docs: + +- GitHub issue: https://github.com/stablyai/orca/issues/1796 +- Claude CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-usage +- Claude hooks reference: https://code.claude.com/docs/en/hooks +- Gemini hooks reference: https://geminicli.com/docs/hooks/reference/ +- Cursor CLI parameters: https://docs.cursor.com/en/cli/reference/parameters +- Cursor CLI usage: https://docs.cursor.com/en/cli/using +- OpenCode CLI docs: https://opencode.ai/docs/cli/ +- Antigravity conversation docs: https://antigravity.google/docs/cli-conversations +- Antigravity hooks docs: https://www.antigravity.google/docs/hooks +- Factory Droid hooks reference: https://docs.factory.ai/cli/configuration/hooks-reference +- Factory Droid CLI reference: https://docs.factory.ai/cli/configuration/cli-reference +- Pi docs: https://pi.dev/docs/latest/tree diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 313971bbc4d..c87c864b373 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -89,6 +89,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, payload: { state: 'working', prompt: 'long task', agentType: 'codex' } }, 'conn-1' @@ -112,6 +113,7 @@ describe('AgentHookServer listener replay', () => { state: 'done', prompt: 'long task', agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, interrupted: true, receivedAt: 1_500, stateStartedAt: 1_500 @@ -120,6 +122,7 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenLastCalledWith( expect.objectContaining({ paneKey: PANE, + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, payload: expect.objectContaining({ state: 'done', interrupted: true }) }) ) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 1ecbeb61d2e..f9cb59fec81 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -55,6 +55,7 @@ import { } from '../../shared/agent-interrupt-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' export type { AgentHookSource } @@ -222,6 +223,7 @@ function sanitizeHydratedEntry( toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + providerSession: normalizeAgentProviderSession(record.providerSession) ?? undefined, payload, receivedAt, stateStartedAt @@ -236,6 +238,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...entry.payload } } @@ -525,6 +528,7 @@ export class AgentHookServer { tabId: existing.tabId, worktreeId: existing.worktreeId, connectionId: existing.connectionId, + providerSession: existing.providerSession, payload: { state: 'done', prompt: payload.prompt, @@ -982,6 +986,7 @@ export class AgentHookServer { toolUseId?: string toolAgentId?: string toolAgentType?: string + providerSession?: unknown isReplay?: boolean payload: unknown }, @@ -1056,6 +1061,7 @@ export class AgentHookServer { typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 ? envelope.toolAgentType.trim() : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: the relay is across a trust boundary; re-run the canonical // normalizer on the inner payload so prompt/agentType/toolName/toolInput // length caps, embedded-newline collapse, and the `interrupted`-only-on- @@ -1085,6 +1091,7 @@ export class AgentHookServer { toolUseId, toolAgentId, toolAgentType, + providerSession, isReplay: envelope.isReplay === true ? true : undefined, payload: normalizedPayload } diff --git a/src/main/index.ts b/src/main/index.ts index 5bfd03371c1..6a6f1c5d944 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -607,6 +607,7 @@ function openMainWindow(): BrowserWindow { payload, receivedAt, stateStartedAt, + providerSession, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -624,6 +625,7 @@ function openMainWindow(): BrowserWindow { connectionId, receivedAt, stateStartedAt, + ...(providerSession ? { providerSession } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index a07963b6763..add20ca79e2 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -110,6 +110,16 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('await post("AskUserQuestion", event.properties || {});') }) + it('forwards sessionID on status and message posts for resume metadata', () => { + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain( + 'await post("MessagePart", { role, text: part.text, messageID: part.messageID, sessionID });' + ) + expect(source).toContain('await setStatus("busy", { sessionID });') + expect(source.match(/await setStatus\("idle", \{ sessionID \}\);/g) ?? []).toHaveLength(2) + }) + it('guards endpoint-file parse warnings with a process-lifetime latch', () => { // Why: ENOENT is the normal pre-install case and must stay silent, but a // malformed/unreadable file (EACCES, EIO, parse error) would otherwise diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index cfb723ede29..ff0d9bde7d8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -312,23 +312,23 @@ function getOpenCodePluginSource(): string { ' if (!part || part.type !== "text" || !part.text) return;', ' const role = messageRoleById.get(part.messageID);', ' if (!role) return;', - ' await post("MessagePart", { role, text: part.text, messageID: part.messageID });', + ' await post("MessagePart", { role, text: part.text, messageID: part.messageID, sessionID });', ' return;', ' }', '', ' if (event.type === "session.idle" || event.type === "session.error") {', - ' await setStatus("idle");', + ' await setStatus("idle", { sessionID });', ' return;', ' }', '', ' if (event.type === "session.status") {', ' const statusType = getStatusType(event);', ' if (statusType === "busy" || statusType === "retry") {', - ' await setStatus("busy");', + ' await setStatus("busy", { sessionID });', ' return;', ' }', ' if (statusType === "idle") {', - ' await setStatus("idle");', + ' await setStatus("idle", { sessionID });', ' }', ' }', ' },', diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 057e095de12..31b357ca047 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -423,6 +423,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', toolAgentType: 'Review', + providerSession: { key: 'session_id', id: 'ssh-relay-session-1' }, payload: { state: 'working', prompt: 'remote prompt', @@ -440,7 +441,8 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { promptInteractionKey: 'command-code-transcript-user-3', toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', - toolAgentType: 'Review' + toolAgentType: 'Review', + providerSession: { key: 'session_id', id: 'ssh-relay-session-1' } }), 'conn-hook-metadata' ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19d94c35710..d9568a3335a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -738,6 +738,7 @@ export class SshRelaySession { toolAgentId?: unknown toolAgentType?: unknown isReplay?: unknown + providerSession?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -767,6 +768,7 @@ export class SshRelaySession { toolAgentType: typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, isReplay: envelope.isReplay === true ? true : undefined, + providerSession: envelope.providerSession, payload: envelope.payload }, this.targetId diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 2dd9c23b0a4..fa404b88f3f 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -297,6 +297,7 @@ export class RelayAgentHookServer { toolUseId: event.toolUseId, toolAgentId: event.toolAgentId, toolAgentType: event.toolAgentType, + ...(event.providerSession ? { providerSession: event.providerSession } : {}), isReplay: options.isReplay === true ? true : undefined, env, version, diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index a97b1637c66..2a941ed7f1d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -396,7 +396,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ aria-label="Agents" data-compact-agent-list="true" > - {shouldUseSummaryRow ? ( + {agents.length === 0 ? null : shouldUseSummaryRow ? ( // Why: the worktree card is already the surface. Expanded compact // agents stay a quiet tree; only the collapsed summary reads as a pill.
{ expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), 'Future Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -3519,7 +3520,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Inactive Tab', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -3609,7 +3611,8 @@ describe('useIpcEvents agent status snapshot integration', () => { expect.objectContaining({ state: 'working', prompt: 'hidden worker', agentType: 'codex' }), undefined, { updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) @@ -3752,7 +3755,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Cursor ready', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -3836,7 +3840,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Codex ready', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) expect(updateTabTitle).toHaveBeenCalledTimes(1) expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Codex ready') @@ -4000,7 +4005,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Terminal 2', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -4075,7 +4081,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Inactive Tab', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -4178,7 +4185,8 @@ describe('useIpcEvents agent status snapshot integration', () => { expect.objectContaining({ state: 'working', prompt: 'queued prompt', agentType: 'codex' }), 'Future Tab', { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) expect(setAgentStatus).toHaveBeenNthCalledWith( 2, @@ -4191,7 +4199,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), 'Future Tab', { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -4256,7 +4265,8 @@ describe('useIpcEvents agent status snapshot integration', () => { expect.objectContaining({ state: 'working', prompt: 'remote p', agentType: 'codex' }), 'SSH Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) @@ -4703,7 +4713,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }), undefined, { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, - expect.objectContaining({ worktreeId: 'wt-1', terminalHandle: 'term-child' }) + expect.objectContaining({ worktreeId: 'wt-1', terminalHandle: 'term-child' }), + undefined ) }) @@ -4818,7 +4829,8 @@ describe('useIpcEvents agent status snapshot integration', () => { expect.objectContaining({ state: 'working' }), 'Terminal 1', { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }, - expectWorktreeRouting('wt-1') + expectWorktreeRouting('wt-1'), + undefined ) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index a07fcb96c54..42af0283ea0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2488,7 +2488,8 @@ export function useIpcEvents(): void { tabId: data.tabId, worktreeId: statusWorktreeId, terminalHandle: data.terminalHandle - } + }, + data.providerSession ? { providerSession: data.providerSession } : undefined ) applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle) if (options?.replay !== true && statusWorktreeId) { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts new file mode 100644 index 00000000000..2b26d8d48a0 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -0,0 +1,80 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { isWslUncPath } from '../../../shared/wsl-paths' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' + +function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { + const state = useAppStore.getState() + const worktree = state.getKnownWorktreeById(worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { + return 'linux' + } + return CLIENT_PLATFORM +} + +function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { + const state = useAppStore.getState() + const termIds = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = state.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((f) => f.id) + const browserIds = (state.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) + const base = reconcileTabOrder( + state.tabBarOrderByWorktree[worktreeId], + termIds, + editorIds, + browserIds + ) + const order = base.filter((id) => id !== tabId) + order.push(tabId) + state.setTabBarOrder(worktreeId, order) +} + +function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean { + const state = useAppStore.getState() + const startupPlan = buildAgentResumeStartupPlan({ + agent: record.agent, + providerSession: record.providerSession, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + platform: getResumeLaunchPlatform(record.worktreeId) + }) + if (!startupPlan) { + toast.error('This agent session cannot be resumed.') + return false + } + + const tab = state.createTab(record.worktreeId, undefined, undefined, { + launchAgent: record.agent + }) + state.queueTabStartupCommand(tab.id, { + command: startupPlan.launchCommand, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }) + state.clearSleepingAgentSession(record.paneKey) + state.setActiveTabType('terminal') + appendTabToWorktreeOrder(record.worktreeId, tab.id) + return true +} + +export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { + const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey) + .filter((record) => record.worktreeId === worktreeId) + .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) + + let launched = 0 + for (const record of records) { + if (launchSleepingAgentSession(record)) { + launched += 1 + } + } + return launched +} diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index 6427aa17dc0..478a9d9d0c6 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -1,4 +1,5 @@ export { + buildAgentResumeStartupPlan, buildAgentDraftLaunchPlan, buildAgentStartupPlan, isShellProcess diff --git a/src/renderer/src/lib/workspace-session-focus-recency.ts b/src/renderer/src/lib/workspace-session-focus-recency.ts new file mode 100644 index 00000000000..2db18208975 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-focus-recency.ts @@ -0,0 +1,10 @@ +import type { WorkspaceSessionState } from '../../../shared/types' + +export function buildLastVisitedAtByWorktreeId(snapshot: { + lastVisitedAtByWorktreeId: WorkspaceSessionState['lastVisitedAtByWorktreeId'] +}): WorkspaceSessionState['lastVisitedAtByWorktreeId'] { + return snapshot.lastVisitedAtByWorktreeId && + Object.keys(snapshot.lastVisitedAtByWorktreeId).length > 0 + ? snapshot.lastVisitedAtByWorktreeId + : undefined +} diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index f36d10822d3..6d2cad8c251 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -245,4 +245,14 @@ describe('buildWorkspaceSessionPatch', () => { expect(Object.hasOwn(patch, 'defaultTerminalTabsAppliedByWorktreeId')).toBe(true) expect(patch.defaultTerminalTabsAppliedByWorktreeId).toBeUndefined() }) + + it('keeps sleeping agent session clearing keys in patches', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ sleepingAgentSessionsByPaneKey: {} }), + ['sleepingAgentSessionsByPaneKey'] + ) + + expect(Object.hasOwn(patch, 'sleepingAgentSessionsByPaneKey')).toBe(true) + expect(patch.sleepingAgentSessionsByPaneKey).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 19f0edad786..b1b9e4d8c98 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -4,13 +4,14 @@ import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-sessio import { buildActiveConnectionIdsAtShutdown, buildEditorSessionData, - buildLastVisitedAtByWorktreeId, buildPersistedBrowserPagesByWorkspace, buildPersistedBrowserTabsByWorktree, buildSanitizedTabsByWorktree, buildTerminalSessionData, type WorkspaceSessionSnapshot } from './workspace-session' +import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' +import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' type SessionRelevantField = keyof WorkspaceSessionSnapshot @@ -133,6 +134,10 @@ export function buildWorkspaceSessionPatch( ? snapshot.defaultTerminalTabsAppliedByWorktreeId : undefined } + if (changed.has('sleepingAgentSessionsByPaneKey')) { + patch.sleepingAgentSessionsByPaneKey = + buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey + } return patch } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index df9794e4d99..d9ab05f5bc5 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -30,7 +30,8 @@ describe('SESSION_RELEVANT_FIELDS', () => { worktreesByRepo: true, lastKnownRelayPtyIdByTabId: true, lastVisitedAtByWorktreeId: true, - defaultTerminalTabsAppliedByWorktreeId: true + defaultTerminalTabsAppliedByWorktreeId: true, + sleepingAgentSessionsByPaneKey: true } it('contains every key of WorkspaceSessionSnapshot', () => { diff --git a/src/renderer/src/lib/workspace-session-sleeping-agents.ts b/src/renderer/src/lib/workspace-session-sleeping-agents.ts new file mode 100644 index 00000000000..fc54bc1c524 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-sleeping-agents.ts @@ -0,0 +1,10 @@ +import type { WorkspaceSessionState } from '../../../shared/types' + +export function buildSleepingAgentSessionData(snapshot: { + sleepingAgentSessionsByPaneKey?: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] +}): Pick { + const records = snapshot.sleepingAgentSessionsByPaneKey + return records && Object.keys(records).length > 0 + ? { sleepingAgentSessionsByPaneKey: records } + : {} +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 4ac7dbe11e6..7c48fadcb4c 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -9,6 +9,8 @@ import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-s import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-session-browser-history' import type { AppState } from '../store' import type { OpenFile } from '../store/slices/editor' +import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' +import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' /** Why (issue #1158): the debounced + shutdown session writers share this * gate so a hydration failure cannot overwrite orca-data.json with the @@ -55,7 +57,9 @@ export type WorkspaceSessionSnapshot = Pick< | 'lastKnownRelayPtyIdByTabId' | 'lastVisitedAtByWorktreeId' | 'defaultTerminalTabsAppliedByWorktreeId' -> +> & { + sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] +} // Why: the App-level Zustand subscriber that debounces session writes uses // this list as a shallow-equality gate so it only resets the timer when a @@ -89,7 +93,8 @@ export const SESSION_RELEVANT_FIELDS = [ 'worktreesByRepo', 'lastKnownRelayPtyIdByTabId', 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId' + 'defaultTerminalTabsAppliedByWorktreeId', + 'sleepingAgentSessionsByPaneKey' ] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] type _MissingSessionField = Exclude< @@ -320,15 +325,6 @@ export function buildActiveConnectionIdsAtShutdown( return connectedTargetIds.length > 0 ? connectedTargetIds : undefined } -export function buildLastVisitedAtByWorktreeId( - snapshot: WorkspaceSessionSnapshot -): WorkspaceSessionState['lastVisitedAtByWorktreeId'] { - return snapshot.lastVisitedAtByWorktreeId && - Object.keys(snapshot.lastVisitedAtByWorktreeId).length > 0 - ? snapshot.lastVisitedAtByWorktreeId - : undefined -} - export function buildWorkspaceSessionPayload( snapshot: WorkspaceSessionSnapshot ): WorkspaceSessionState { @@ -376,7 +372,8 @@ export function buildWorkspaceSessionPayload( snapshot.defaultTerminalTabsAppliedByWorktreeId && Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0 ? snapshot.defaultTerminalTabsAppliedByWorktreeId - : undefined + : undefined, + ...buildSleepingAgentSessionData(snapshot) } return pruneLocalTerminalScrollbackBuffers(payload, snapshot.repos) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 541c6d7af4b..d27f5970fa5 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -218,6 +218,92 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) + it('automatically resumes sleeping agent sessions when activating a slept worktree', () => { + const worktree = makeWorktree() + const revealWorktreeInSidebar = vi.fn() + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + activeRepoId: 'repo-1', + activeView: 'terminal', + tabsByWorktree: { + [worktree.id]: [ + { + id: 'slept-tab', + ptyId: 'wake-hint', + worktreeId: worktree.id, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'slept-tab:0': { + paneKey: 'slept-tab:0', + tabId: 'slept-tab', + worktreeId: worktree.id, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'resume prior task', + state: 'working', + capturedAt: 1000, + updatedAt: 1000, + terminalTitle: 'Codex' + } + }, + settings: { + agentCmdOverrides: {}, + setupScriptLaunchMode: 'new-tab' + } as unknown as ReturnType['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar + }) + + const result = activateAndRevealWorktree(worktree.id) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree[worktree.id]?.find((tab) => tab.id !== 'slept-tab') + + expect(result).toEqual({ primaryTabId: null }) + expect(resumedTab?.launchAgent).toBe('codex') + expect(state.pendingStartupByTabId[resumedTab!.id]).toEqual({ + command: "codex 'resume' 'codex-session-1'", + telemetry: { + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'resume' + } + }) + expect(state.sleepingAgentSessionsByPaneKey['slept-tab:0']).toBeUndefined() + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) + }) + it('forwards an explicit sidebar reveal behavior', () => { const worktree = makeWorktree() const revealWorktreeInSidebar = vi.fn() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index bfbd370f335..38090b59303 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -32,6 +32,7 @@ import { setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' import { isTuiAgent } from '../../../shared/tui-agent-config' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -216,6 +217,11 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } + // Why: sleeping an agent destroys the local PTY but preserves the provider + // session id. Waking the workspace should restore those CLI sessions without + // making the user click a separate sidebar row. + resumeSleepingAgentSessionsForWorktree(worktreeId) + // 4. Ensure a focusable surface exists for externally-created worktrees const primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index ad4cecd91c9..8cf38172044 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -11,6 +11,12 @@ import { type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { + getAgentResumeArgv, + isResumableTuiAgent, + type AgentProviderSessionMetadata, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -54,6 +60,10 @@ export type AgentStatusSlice = { * agent-status hover so the two surfaces display identical rows. */ retainedAgentsByPaneKey: Record + /** Durable agent sessions captured when a workspace sleeps. These are not + * live status rows; they power the one-click CLI resume action on wake. */ + sleepingAgentSessionsByPaneKey: Record + /** Pane keys explicitly torn down (pane close, tab close, PTY exit, manual * dismissal) and therefore forbidden from being re-retained on their next * disappearance. Consumed by the retention sync as a one-shot suppressor. */ @@ -65,7 +75,8 @@ export type AgentStatusSlice = { payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number }, - routing?: { tabId?: string; worktreeId?: string; terminalHandle?: string } + routing?: { tabId?: string; worktreeId?: string; terminalHandle?: string }, + metadata?: { providerSession?: AgentProviderSessionMetadata } ) => void setRuntimeAgentOrchestrationByPaneKey: ( @@ -101,6 +112,11 @@ export type AgentStatusSlice = { * survive sleep/remove. */ dropAgentStatusByWorktree: (worktreeId: string) => void + captureSleepingAgentSessionsByWorktree: (worktreeId: string) => void + clearSleepingAgentSession: (paneKey: string) => void + clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void + pruneSleepingAgentSessions: (validWorktreeIds: Set) => void + /** Retain agent snapshots (called by the top-level retention sync effect). * Accepts an array so multiple agents disappearing in the same frame * produce a single set(...) — avoids intermediate states visible @@ -155,6 +171,52 @@ function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | nul return null } +function findTabForAgentEntry( + state: AppState, + worktreeId: string, + entry: AgentStatusEntry +): TerminalTab | undefined { + const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) + if (!tabId) { + return undefined + } + return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId) +} + +function sleepingRecordFromEntry(args: { + state: AppState + entry: AgentStatusEntry + worktreeId: string + tab?: TerminalTab + capturedAt: number +}): SleepingAgentSessionRecord | null { + const agent = args.entry.agentType + if (!isResumableTuiAgent(agent) || !args.entry.providerSession) { + return null + } + if (!getAgentResumeArgv(agent, args.entry.providerSession)) { + return null + } + const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry) + return { + paneKey: args.entry.paneKey, + ...(tab ? { tabId: tab.id } : {}), + worktreeId: args.worktreeId, + agent, + providerSession: args.entry.providerSession, + prompt: args.entry.prompt, + state: args.entry.state, + capturedAt: args.capturedAt, + updatedAt: args.entry.updatedAt, + ...((args.entry.terminalTitle ?? tab?.title) + ? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! } + : {}), + ...(args.entry.lastAssistantMessage + ? { lastAssistantMessage: args.entry.lastAssistantMessage } + : {}) + } +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -243,6 +305,7 @@ export const createAgentStatusSlice: StateCreator { @@ -304,7 +367,7 @@ export const createAgentStatusSlice: StateCreator { + setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => { const updatedAt = timing?.updatedAt ?? Date.now() let completionRefreshWorktreeId: string | null = null let suppressedInheritedTerminalStatus = false @@ -399,6 +462,9 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey ) + const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey + const nextSleepingAgentSessions = hasSleepingRecord + ? { ...s.sleepingAgentSessionsByPaneKey } + : s.sleepingAgentSessionsByPaneKey + if (hasSleepingRecord) { + delete nextSleepingAgentSessions[paneKey] + } return { agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry }, + sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, migrationUnsupportedByPtyId: migrationUnsupported.next, retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, agentStatusEpoch: @@ -934,6 +1010,95 @@ export const createAgentStatusSlice: StateCreator { + set((s) => { + const capturedAt = Date.now() + const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const next: Record = { + ...s.sleepingAgentSessionsByPaneKey + } + let changed = false + + for (const retained of Object.values(s.retainedAgentsByPaneKey)) { + if (retained.worktreeId !== worktreeId) { + continue + } + const record = sleepingRecordFromEntry({ + state: s, + entry: retained.entry, + worktreeId, + tab: retained.tab, + capturedAt + }) + if (record && next[record.paneKey] !== record) { + next[record.paneKey] = record + changed = true + } + } + + for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + const record = sleepingRecordFromEntry({ + state: s, + entry, + worktreeId, + capturedAt + }) + if (record && next[record.paneKey] !== record) { + next[record.paneKey] = record + changed = true + } + } + + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + clearSleepingAgentSession: (paneKey) => { + set((s) => { + if (!(paneKey in s.sleepingAgentSessionsByPaneKey)) { + return s + } + const next = { ...s.sleepingAgentSessionsByPaneKey } + delete next[paneKey] + return { sleepingAgentSessionsByPaneKey: next } + }) + }, + + clearSleepingAgentSessionsByWorktree: (worktreeId) => { + set((s) => { + let changed = false + const next: Record = {} + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId === worktreeId) { + changed = true + continue + } + next[paneKey] = record + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + pruneSleepingAgentSessions: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (!validWorktreeIds.has(record.worktreeId)) { + changed = true + continue + } + next[paneKey] = record + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + retainAgents: (entries) => { // Why: retained entries are a pure read-overlay — consumers read // retainedAgentsByPaneKey directly each render, so no sort/status epoch diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 7186d3b0ea8..bedafb1bee8 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2067,6 +2067,99 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(s.agentStatusByPaneKey['tab-1:0']).toBeUndefined() }) + it('captures resumable provider session metadata before dropping sleep-time rows', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + store.getState().setAgentStatus( + 'tab-1:0', + { + state: 'working', + prompt: 'resume this', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + const state = store.getState() + expect(state.agentStatusByPaneKey['tab-1:0']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:0']).toMatchObject({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: wt, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'resume this', + terminalTitle: 'Codex' + }) + }) + + it('does not preserve provider session metadata when a pane switches agent type', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Claude' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + store.getState().setAgentStatus( + 'tab-1:0', + { + state: 'done', + prompt: 'codex prompt', + agentType: 'codex', + interrupted: false + }, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + + store.getState().setAgentStatus( + 'tab-1:0', + { + state: 'working', + prompt: 'claude prompt', + agentType: 'claude' + }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 2000 }, + { tabId: 'tab-1', worktreeId: wt } + ) + + const liveEntry = store.getState().agentStatusByPaneKey['tab-1:0'] + expect(liveEntry?.agentType).toBe('claude') + expect(liveEntry?.providerSession).toBeUndefined() + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + const state = store.getState() + expect(state.agentStatusByPaneKey['tab-1:0']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:0']).toBeUndefined() + }) + it('drops retainedAgentsByPaneKey entries for the slept worktree', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b7be29fdfd5..0dad6302de8 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1682,6 +1682,12 @@ export const createTerminalSlice: StateCreator } }) + if (keepIdentifiers) { + get().captureSleepingAgentSessionsByWorktree(worktreeId) + } else { + get().clearSleepingAgentSessionsByWorktree(worktreeId) + } + // Why: sleep/remove fold the whole worktree surface. The live PTY bindings // were cleared above and kill is about to run, so live rows are stale; // retained rows are folded too so a grey slept card does not keep a green @@ -2002,6 +2008,11 @@ export const createTerminalSlice: StateCreator .flat() .map((tab) => tab.id) ) + const sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => + validWorktreeIds.has(record.worktreeId) + ) + ) const activeWorktreeId = session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId) ? session.activeWorktreeId @@ -2169,6 +2180,7 @@ export const createTerminalSlice: StateCreator lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, defaultTerminalTabsAppliedByWorktreeId: session.defaultTerminalTabsAppliedByWorktreeId ?? {}, + sleepingAgentSessionsByPaneKey, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index cfc691644ce..1479e441280 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -30,6 +30,10 @@ import { join } from 'path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' +import { + extractAgentProviderSession, + type AgentProviderSessionMetadata +} from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' /** Maximum request body size accepted by the listener (1 MB). */ @@ -177,6 +181,8 @@ export type AgentHookEventPayload = { toolAgentId?: string /** Agent/subagent type from the source hook payload, when present. */ toolAgentType?: string + /** Provider-owned conversation/session id needed to resume a sleeping agent. */ + providerSession?: AgentProviderSessionMetadata /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean payload: ParsedAgentStatusPayload @@ -2891,6 +2897,7 @@ export function normalizeHookPayload( // it null; the relay forwards null on the wire and Orca's `ingestRemote` // stamps the real value from `mux` identity on receive. See // docs/design/agent-status-over-ssh.md §5. + const providerSession = extractAgentProviderSession(source, hookPayloadRecord) return payload ? { paneKey, @@ -2914,6 +2921,7 @@ export function normalizeHookPayload( toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), toolAgentType: readString(hookPayloadRecord, 'agent_type'), + ...(providerSession ? { providerSession } : {}), payload } : null diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 9c81a3e4bae..8a1809648a2 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -23,6 +23,7 @@ // default env is `remote`, a location marker ignored by dev-vs-prod checks. import type { ParsedAgentStatusPayload } from './agent-status-types' +import type { AgentProviderSessionMetadata } from './agent-session-resume' // Why: the local hook server knows the discriminator from URL pathname routing // (`/hook/`); the relay equally must tag each forwarded notification @@ -72,6 +73,8 @@ export type AgentHookRelayEnvelope = { toolAgentId?: string /** Claude agent type, used only as a lower-confidence identity fallback. */ toolAgentType?: string + /** Provider-owned conversation/session id needed to resume a sleeping agent. */ + providerSession?: AgentProviderSessionMetadata /** True when the relay is replaying its cache after Orca reconnects. */ isReplay?: boolean /** Forwarded from the agent CLI POST body. The relay default is `remote`, diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts new file mode 100644 index 00000000000..18e76809301 --- /dev/null +++ b/src/shared/agent-session-resume.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + extractAgentProviderSession, + getAgentResumeArgv, + normalizeAgentProviderSession +} from './agent-session-resume' + +describe('agent session resume metadata', () => { + it.each([ + ['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }], + ['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }], + ['gemini', { session_id: 'gemini-session' }, { key: 'session_id', id: 'gemini-session' }], + [ + 'antigravity', + { conversationId: 'agy-conversation' }, + { key: 'conversation_id', id: 'agy-conversation' } + ], + ['opencode', { sessionID: 'opencode-session' }, { key: 'session_id', id: 'opencode-session' }], + ['droid', { session_id: 'droid-session' }, { key: 'session_id', id: 'droid-session' }], + ['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }] + ] as const)('extracts %s provider session ids', (source, payload, expected) => { + expect(extractAgentProviderSession(source, payload)).toEqual(expected) + }) + + it.each([ + ['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']], + ['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']], + ['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']], + ['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']], + ['opencode', { key: 'session_id', id: 's1' }, ['opencode', '--session', 's1']], + ['droid', { key: 'session_id', id: 's1' }, ['droid', '--resume', 's1']], + ['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']] + ] as const)('builds %s resume argv', (agent, providerSession, expected) => { + expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected) + }) + + it('rejects unsupported sources and unsafe ids', () => { + expect(extractAgentProviderSession('pi', { session_id: 'pi-session' })).toBeNull() + expect(normalizeAgentProviderSession({ key: 'session_id', id: 'bad\nid' })).toBeNull() + expect(normalizeAgentProviderSession({ key: 'session_id', id: '--last' })).toBeNull() + expect(extractAgentProviderSession('codex', { session_id: '--last' })).toBeNull() + expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok' })).toEqual({ + key: 'session_id', + id: 'ok' + }) + }) +}) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts new file mode 100644 index 00000000000..ccf174fcdcc --- /dev/null +++ b/src/shared/agent-session-resume.ts @@ -0,0 +1,151 @@ +import type { AgentHookSource } from './agent-hook-relay' +import type { AgentStatusState } from './agent-status-types' +import type { TuiAgent } from './types' + +export const RESUMABLE_TUI_AGENTS = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode', + 'droid', + 'grok' +] as const satisfies readonly TuiAgent[] + +export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] + +export type AgentProviderSessionKey = 'session_id' | 'conversation_id' + +export type AgentProviderSessionMetadata = { + key: AgentProviderSessionKey + id: string +} + +export type SleepingAgentSessionRecord = { + paneKey: string + tabId?: string + worktreeId: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + prompt: string + state: AgentStatusState + capturedAt: number + updatedAt: number + terminalTitle?: string + lastAssistantMessage?: string + connectionId?: string | null +} + +const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) +const PROVIDER_SESSION_ID_MAX_LENGTH = 512 + +export function hasUnsafeProviderSessionIdChars(value: string): boolean { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i) + if (code <= 0x1f || code === 0x7f) { + return true + } + } + return false +} + +function normalizeSessionId(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if ( + trimmed.length === 0 || + trimmed.length > PROVIDER_SESSION_ID_MAX_LENGTH || + trimmed.startsWith('-') || + hasUnsafeProviderSessionIdChars(trimmed) + ) { + return null + } + return trimmed +} + +function readSessionId(record: Record, keys: readonly string[]): string | null { + for (const key of keys) { + const normalized = normalizeSessionId(record[key]) + if (normalized) { + return normalized + } + } + return null +} + +export function isResumableTuiAgent(value: unknown): value is ResumableTuiAgent { + return typeof value === 'string' && RESUMABLE_TUI_AGENT_SET.has(value) +} + +export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessionMetadata | null { + if (typeof raw !== 'object' || raw === null) { + return null + } + const record = raw as Record + const key = record.key + if (key !== 'session_id' && key !== 'conversation_id') { + return null + } + const id = normalizeSessionId(record.id) + return id ? { key, id } : null +} + +export function extractAgentProviderSession( + source: AgentHookSource, + payload: Record +): AgentProviderSessionMetadata | null { + switch (source) { + case 'claude': + case 'codex': + case 'gemini': + case 'droid': { + const id = readSessionId(payload, ['session_id']) + return id ? { key: 'session_id', id } : null + } + case 'antigravity': { + const id = readSessionId(payload, ['conversationId']) + return id ? { key: 'conversation_id', id } : null + } + case 'opencode': { + const id = readSessionId(payload, ['sessionID']) + return id ? { key: 'session_id', id } : null + } + case 'grok': { + const id = readSessionId(payload, ['sessionId', 'session_id']) + return id ? { key: 'session_id', id } : null + } + case 'amp': + case 'cursor': + case 'pi': + case 'omp': + case 'command-code': + case 'copilot': + case 'hermes': + return null + } +} + +export function getAgentResumeArgv( + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata +): string[] | null { + const id = providerSession.id + switch (agent) { + case 'claude': + return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null + case 'codex': + return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null + case 'gemini': + return providerSession.key === 'session_id' ? ['gemini', '--resume', id] : null + case 'antigravity': + return providerSession.key === 'conversation_id' ? ['agy', '--conversation', id] : null + case 'opencode': + return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null + case 'droid': + return providerSession.key === 'session_id' ? ['droid', '--resume', id] : null + case 'grok': + return providerSession.key === 'session_id' ? ['grok', '--resume', id] : null + } +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index ac5ac573fc9..8f87bd44bb6 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -5,6 +5,8 @@ // agent misses its own cancellation hook. We still do not infer status from // terminal titles anywhere in the data flow. +import type { AgentProviderSessionMetadata } from './agent-session-resume' + export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] // Why: agent types are not restricted to a fixed set — new agents appear @@ -113,6 +115,9 @@ export type AgentStatusEntry = { * Why: parent/child agent hierarchy is pane-level state, not worktree * lineage; workers often run in the same worktree as their coordinator. */ orchestration?: AgentStatusOrchestrationContext + /** Provider-owned conversation/session id captured from hook payloads. + * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ + providerSession?: AgentProviderSessionMetadata } export type MigrationUnsupportedPtyEntry = { @@ -172,6 +177,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Timestamp (ms) when the current state first appeared for this pane. */ stateStartedAt: number orchestration?: AgentStatusOrchestrationContext + providerSession?: AgentProviderSessionMetadata } /** Maximum character length for the prompt field. Truncated on parse. */ diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 06bb7ca33c1..62e6be5db74 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { + buildAgentDraftLaunchPlan, + buildAgentResumeStartupPlan, + buildAgentStartupPlan, + buildShellCommandFromArgv +} from './tui-agent-startup' describe('tui agent startup plans', () => { it('uses POSIX quoting when the target shell is Linux', () => { @@ -24,6 +29,12 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'") }) + it('invokes fully quoted argv commands in PowerShell', () => { + expect(buildShellCommandFromArgv(['codex', 'resume', 's1'], 'powershell')).toBe( + "& 'codex' 'resume' 's1'" + ) + }) + it('uses cmd escaping when requested explicitly', () => { const plan = buildAgentStartupPlan({ agent: 'claude', @@ -113,6 +124,28 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("codex --profile work 'fix it'") }) + it('builds Windows resume plans that PowerShell can invoke', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession: { key: 'session_id', id: 's1' }, + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("codex 'resume' 's1'") + }) + + it('honors command overrides when building POSIX resume plans', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession: { key: 'session_id', id: 's1' }, + cmdOverrides: { codex: 'codex --profile work' }, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("codex --profile work 'resume' 's1'") + }) + it('clears draft environment variables with the target shell syntax', () => { expect( buildAgentDraftLaunchPlan({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 56ce9b71b94..c0147fe848c 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,4 +1,9 @@ import { isShellProcess } from './agent-detection' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata, + type ResumableTuiAgent +} from './agent-session-resume' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './types' @@ -13,14 +18,14 @@ export type AgentStartupPlan = { export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' -function resolveStartupShell( +export function resolveStartupShell( platform: NodeJS.Platform, shell?: AgentStartupShell ): AgentStartupShell { return shell ?? (platform === 'win32' ? 'powershell' : 'posix') } -function quoteStartupArg(value: string, shell: AgentStartupShell): string { +export function quoteStartupArg(value: string, shell: AgentStartupShell): string { if (shell === 'powershell') { return `'${value.replace(/'/g, "''")}'` } @@ -30,6 +35,17 @@ function quoteStartupArg(value: string, shell: AgentStartupShell): string { return `'${value.replace(/'/g, `'\\''`)}'` } +export function buildShellCommandFromArgv( + args: readonly string[], + shell: AgentStartupShell +): string { + const command = args.map((arg) => quoteStartupArg(arg, shell)).join(' ') + if (shell === 'powershell' && command) { + return `& ${command}` + } + return command +} + function clearEnvCommand(name: string, shell: AgentStartupShell): string { if (shell === 'powershell') { return `Remove-Item Env:${name} -ErrorAction SilentlyContinue` @@ -135,6 +151,37 @@ export function buildAgentStartupPlan(args: { } } +export function buildAgentResumeStartupPlan(args: { + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell +}): AgentStartupPlan | null { + const argv = getAgentResumeArgv(args.agent, args.providerSession) + if (!argv) { + return null + } + const shell = resolveStartupShell(args.platform, args.shell) + const config = TUI_AGENT_CONFIG[args.agent] + const baseCommand = resolveBaseCommand({ + agent: args.agent, + cmdOverrides: args.cmdOverrides, + shell + }) + const resumeArgs = argv + .slice(1) + .map((arg) => quoteStartupArg(arg, shell)) + .join(' ') + const launchCommand = resumeArgs ? `${baseCommand} ${resumeArgs}` : baseCommand + return { + agent: args.agent, + launchCommand, + expectedProcess: config.expectedProcess, + followupPrompt: null + } +} + export type AgentDraftLaunchPlan = { agent: TuiAgent launchCommand: string diff --git a/src/shared/types.ts b/src/shared/types.ts index 77cec28a881..871a8a4901f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,7 @@ import type { SourceControlAiSettings } from './source-control-ai-types' import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events' +import type { SleepingAgentSessionRecord } from './agent-session-resume' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -726,6 +727,8 @@ export type WorkspaceSessionState = { * considered. Persisted so closing all tabs and re-opening the workspace * does not recreate the template. */ defaultTerminalTabsAppliedByWorktreeId?: Record + /** Provider-session resume records captured when workspaces sleep. */ + sleepingAgentSessionsByPaneKey?: Record } export type WorkspaceSessionPatch = Partial diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 7b02044a68f..d9609fd0b22 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -107,6 +107,181 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves valid sleeping agent resume records', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + terminalTitle: 'Codex', + lastAssistantMessage: 'done' + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') + } + }) + + it('drops malformed sleeping agent resume records without failing the whole session', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + worktreeId: 'wt', + agent: 'pi', + providerSession: { key: 'session_id', id: 'pi-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined() + } + }) + + it('preserves valid sleeping agent resume records when sibling records are malformed', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'tab2:pane-1', + worktreeId: 'wt', + agent: 'not-real', + providerSession: { key: 'session_id', id: 'bad-session' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) + + it('drops sleeping agent records with unsafe provider session ids without dropping valid siblings', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'tab2:pane-1', + tabId: 'tab2', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: '--last' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( + 'codex-session' + ) + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) + + it('drops sleeping agent records whose embedded pane key differs from the map key', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'other-tab:pane-1', + tabId: 'tab2', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'mismatched-session' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( + 'codex-session' + ) + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) + it('rejects a session where ptyId is a number (schema drift)', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index c8b1f42c187..a334cee2f19 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -19,6 +19,7 @@ import type { import { isValidTerminalTabId } from './terminal-tab-id' import { isTuiAgent } from './tui-agent-config' import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' +import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -80,6 +81,47 @@ const terminalTabSchema = z.object({ .catch(undefined) }) +// ─── Sleeping agent resume records ───────────────────────────────── + +const agentProviderSessionSchema = z.preprocess( + (raw) => normalizeAgentProviderSession(raw) ?? undefined, + z.object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512) + }) +) + +const sleepingAgentSessionRecordSchema = z.object({ + paneKey: z.string().refine((value) => value.length > 0), + tabId: terminalTabIdSchema.optional(), + worktreeId: z.string().min(1), + agent: z.enum(RESUMABLE_TUI_AGENTS), + providerSession: agentProviderSessionSchema, + prompt: z.string(), + state: z.enum(['working', 'blocked', 'waiting', 'done']), + capturedAt: z.number().finite().positive(), + updatedAt: z.number().finite().positive(), + terminalTitle: z.string().optional(), + lastAssistantMessage: z.string().optional(), + connectionId: z.string().nullable().optional() +}) + +const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => { + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined + } + + const cleaned: Record> = {} + for (const [paneKey, value] of Object.entries(raw as Record)) { + const parsed = sleepingAgentSessionRecordSchema.safeParse(value) + if (parsed.success && parsed.data.paneKey === paneKey) { + cleaned[paneKey] = parsed.data + } + } + + return Object.keys(cleaned).length > 0 ? cleaned : undefined +}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional()) + // ─── Unified tab model ────────────────────────────────────────────── const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser']) @@ -256,7 +298,8 @@ export const workspaceSessionStateSchema: z.ZodType = z.o z.record(z.string(), z.number().finite().nonnegative()) ) .optional(), - defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional() + defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(), + sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema }) export type ParsedWorkspaceSession = From 93f9f86823cbbcd517ace8e46942b077431f33f5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:48:16 -0700 Subject: [PATCH 04/17] Slim terminal scrollbar with xterm option and rounded thumb (#4857) Set xterm scrollbar width to 7px for a slimmer scrollbar, round the DOM thumb via CSS, and add default slider colors with better contrast. Hide the overview ruler border that enabling the scrollbar width activates. --- src/renderer/src/assets/terminal.css | 6 +++++ .../compose-active-terminal-theme.test.ts | 24 +++++++++++++++++-- .../terminal-pane/terminal-appearance.ts | 14 ++++++++++- .../lib/pane-manager/pane-lifecycle.test.ts | 4 ++++ .../lib/pane-manager/pane-terminal-options.ts | 7 ++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 3496db1fc7e..e7b5afe5030 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -31,6 +31,12 @@ overflow-y: scroll; } +/* Round the xterm DOM scrollbar thumb; xterm has no option for this, and its + default .xterm-slider has square corners. */ +.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider { + border-radius: 4px; +} + /* xterm.css sets position:absolute;top:0 on .xterm-helpers but omits left, leaving left:auto. In Electron's Blink, left:auto can resolve to a non-zero value and offset the IME candidate window away from the cursor. Anchoring it diff --git a/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts b/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts index 4e493598a47..97e81b97135 100644 --- a/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts +++ b/src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts @@ -12,10 +12,30 @@ describe('composeActiveTerminalTheme', () => { } as GlobalSettings } - it('returns the base theme when no overrides apply', () => { + it('layers terminal scrollbar defaults under the base theme', () => { const base = { background: '#101010', foreground: '#fafafa', cursor: '#fafafa' } const result = composeActiveTerminalTheme(base, settingsWith({})) - expect(result).toEqual(base) + expect(result).toEqual({ + overviewRulerBorder: 'transparent', + scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)', + scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)', + scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)', + ...base + }) + }) + + it('lets the base theme override terminal scrollbar defaults', () => { + const result = composeActiveTerminalTheme( + { + background: '#101010', + overviewRulerBorder: '#222222', + scrollbarSliderBackground: 'rgba(1, 2, 3, 0.4)' + }, + settingsWith({}) + ) + + expect(result!.overviewRulerBorder).toBe('#222222') + expect(result!.scrollbarSliderBackground).toBe('rgba(1, 2, 3, 0.4)') }) it('layers terminalColorOverrides on top of the base theme', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index dafbb562bee..12e3bace3cb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -156,7 +156,19 @@ export function composeActiveTerminalTheme( if (!baseTheme) { return null } - let theme: ITheme = { ...baseTheme } + // Why: setting scrollbar.width enables xterm's overview ruler, whose border + // defaults to the foreground color and paints a bright vertical line beside + // the scrollbar. We only want the slimmer scrollbar, not the ruler chrome. + // Why: xterm's default slider alpha (~0.2) is nearly invisible on dark + // backgrounds; raise the contrast so the thumb reads. Placed before the + // spread so an explicit theme value still wins. + let theme: ITheme = { + overviewRulerBorder: 'transparent', + scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)', + scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)', + scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)', + ...baseTheme + } // Why: merge user-imported Ghostty color overrides on top of the resolved // base theme so individual colors can be tweaked without losing the rest. if (settings.terminalColorOverrides) { 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 825e5709562..d926a7707b9 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -87,6 +87,10 @@ describe('buildDefaultTerminalOptions', () => { // silently breaks enhanced chords, especially inside tmux. expect(buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard).toBe(true) }) + + it('uses xterm scrollbar options for the slimmer terminal scrollbar', () => { + expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(7) + }) }) describe('attachWebgl', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 39bf07c6bab..814b732972c 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -26,6 +26,13 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { fontWeight: '300', fontWeightBold: '500', scrollback: 10000, + // Why: xterm 6.x renders its own DOM scrollbar (default 14px), which no CSS + // on .xterm-viewport can resize. Slim it via the option. Setting width also + // enables the overview ruler, whose border is hidden via overviewRulerBorder + // in composeActiveTerminalTheme. + scrollbar: { + width: 7 + }, allowTransparency: false, // Why: on macOS, non-US layouts rely on Option to compose characters like @ and €. macOptionIsMeta: false, From a67afb5a04006468b5c68519b623d9bc153cfc03 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:50:05 -0700 Subject: [PATCH 05/17] fix: address review findings (#4853) --- src/main/ipc/linear.ts | 90 +++ src/main/linear/projects.test.ts | 63 ++ src/main/linear/projects.ts | 64 ++ src/main/runtime/orca-runtime.ts | 11 +- .../rpc/methods/linear-project-create.ts | 41 ++ src/main/runtime/rpc/methods/linear.test.ts | 31 + src/main/runtime/rpc/methods/linear.ts | 2 + src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 13 + src/preload/index.ts | 16 + src/renderer/src/components/TaskPage.tsx | 652 ++++++++++++++++-- .../linear-project-view-surfaces.tsx | 5 +- .../src/runtime/runtime-linear-client.test.ts | 42 ++ .../src/runtime/runtime-linear-client.ts | 27 + 14 files changed, 1003 insertions(+), 55 deletions(-) create mode 100644 src/main/runtime/rpc/methods/linear-project-create.ts diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index fbce759cea4..1cf04dbb558 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -13,6 +13,7 @@ import { getIssueComments } from '../linear/issues' import { + createProject, getCustomView, getProject, listCustomViewIssues, @@ -56,6 +57,29 @@ function normalizeCustomViewModel(value: unknown): LinearCustomViewModel { return value } +function normalizeIdList(value: unknown, fieldName: string): string[] | undefined { + if (value === undefined) { + return undefined + } + if ( + !Array.isArray(value) || + !value.every((id): id is string => typeof id === 'string' && Boolean(id.trim())) + ) { + throw new Error(`Invalid ${fieldName}`) + } + return value.map((id) => id.trim()) +} + +function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { + throw new Error(`Invalid ${fieldName}`) + } + return value.trim() +} + export function registerLinearHandlers(): void { ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => { if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) { @@ -284,6 +308,72 @@ export function registerLinearHandlers(): void { } ) + ipcMain.handle( + 'linear:createProject', + async ( + _event, + args: { + name: string + description?: string + content?: string + teamIds?: string[] + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + workspaceId?: string + } + ) => { + if (typeof args?.name !== 'string' || !args.name.trim()) { + return { ok: false, error: 'Project name is required' } + } + let teamIds: string[] + try { + teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? [] + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' } + } + if (teamIds.length === 0) { + return { ok: false, error: 'At least one team is required' } + } + if ( + args.priority !== undefined && + (!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4) + ) { + return { ok: false, error: 'Invalid priority' } + } + let memberIds: string[] | undefined + let labelIds: string[] | undefined + let startDate: string | undefined + let targetDate: string | undefined + try { + memberIds = normalizeIdList(args.memberIds, 'member IDs') + labelIds = normalizeIdList(args.labelIds, 'label IDs') + startDate = normalizeOptionalDate(args.startDate, 'start date') + targetDate = normalizeOptionalDate(args.targetDate, 'target date') + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' } + } + return createProject( + { + name: args.name.trim(), + description: args.description?.trim() || undefined, + content: args.content?.trim() || undefined, + teamIds, + leadId: normalizeWorkspaceId(args.leadId), + memberIds, + labelIds, + priority: typeof args.priority === 'number' ? args.priority : undefined, + startDate, + targetDate + }, + normalizeWorkspaceId(args.workspaceId) + ) + } + ) + ipcMain.handle( 'linear:getProject', async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => { diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index 12f4a1ab520..c64c23971d0 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -210,6 +210,69 @@ describe('Linear project queries', () => { }) }) + it('creates a project with team metadata and maps the created project', async () => { + rawRequest.mockResolvedValueOnce({ + data: { + projectCreate: { + success: true, + project: { + ...rawProject('project-1'), + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] } + } + } + } + }) + const { createProject } = await import('./projects') + + const result = await createProject( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) + + expect(result).toMatchObject({ + ok: true, + project: { + id: 'project-1', + name: 'project-1', + workspaceId: 'workspace-1', + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: [{ id: 'team-1', name: 'Team', key: 'TM' }] + } + }) + expect(rawRequest.mock.calls[0]?.[1]).toEqual({ + input: { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + } + }) + }) + it('lets manual custom view list refresh bypass older in-flight reads', async () => { const staleRequest = deferred>() const refreshRequest = deferred>() diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 5b0a257840c..bb9ff3bf9d0 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -160,6 +160,26 @@ type CustomViewConnectionResponse = { | null } +type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} + const ORCA_PROJECT_FIELDS = ` id name @@ -316,6 +336,17 @@ const PROJECT_QUERY = ` } ` +const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + const PROJECT_ISSUES_QUERY = ` query OrcaLinearProjectIssues( $id: String!, @@ -865,6 +896,39 @@ export async function getProject( ) } +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} + export async function listProjectIssues( projectId: string, limit = 20, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fe85fe4f0ef..491352460ab 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -293,13 +293,15 @@ import { type LinearListFilter } from '../linear/issues' import { + createProject as createLinearProject, getCustomView as getLinearCustomView, getProject as getLinearProject, listCustomViewIssues as listLinearCustomViewIssues, listCustomViewProjects as listLinearCustomViewProjects, listCustomViews as listLinearCustomViews, listProjectIssues as listLinearProjectIssues, - listProjects as listLinearProjects + listProjects as listLinearProjects, + type LinearProjectCreateInput } from '../linear/projects' import { getTeamLabels as getLinearTeamLabels, @@ -13892,6 +13894,13 @@ export class OrcaRuntimeService { return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) } + linearCreateProject( + input: LinearProjectCreateInput, + workspaceId?: string + ): ReturnType { + return createLinearProject(input, workspaceId) + } + linearGetProject( id: string, workspaceId: string, diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts new file mode 100644 index 00000000000..026c585aba2 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' + +const LinearPriority = z.number().int().min(0).max(4).optional() +const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +const CreateProject = z.object({ + name: requiredString('Project name is required'), + description: OptionalString, + content: OptionalString, + workspaceId: OptionalString, + teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), + leadId: z.union([z.string(), z.null()]).optional(), + memberIds: z.array(requiredString('Invalid member ID')).optional(), + labelIds: LinearLabelIds, + priority: LinearPriority, + startDate: OptionalString, + targetDate: OptionalString +}) + +export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ + name: 'linear.createProject', + params: CreateProject, + handler: async (params, { runtime }) => + runtime.linearCreateProject( + { + name: params.name.trim(), + description: params.description?.trim() || undefined, + content: params.content?.trim() || undefined, + teamIds: params.teamIds.map((id) => id.trim()), + leadId: params.leadId ? params.leadId.trim() : undefined, + memberIds: params.memberIds?.map((id) => id.trim()), + labelIds: params.labelIds?.map((id) => id.trim()), + priority: params.priority, + startDate: params.startDate?.trim() || undefined, + targetDate: params.targetDate?.trim() || undefined + }, + params.workspaceId + ) +}) diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index e95fb56681f..f2c68db7801 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -163,6 +163,7 @@ describe('linear RPC methods', () => { linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }), linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }), + linearCreateProject: vi.fn().mockResolvedValue({ ok: true, project: { id: 'project-3' } }), linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) @@ -173,6 +174,21 @@ describe('linear RPC methods', () => { await dispatcher.dispatch( makeRequest('linear.listProjects', { query: 'roadmap', limit: 5, force: true }) ) + await dispatcher.dispatch( + makeRequest('linear.createProject', { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + workspaceId: 'workspace-1', + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }) + ) await dispatcher.dispatch( makeRequest('linear.getProject', { id: 'project-1', @@ -232,6 +248,21 @@ describe('linear RPC methods', () => { expect(runtime.linearListTeams).toHaveBeenCalledWith('all') expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined, true) + expect(runtime.linearCreateProject).toHaveBeenCalledWith( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1', true) expect(runtime.linearListProjectIssues).toHaveBeenCalledWith( 'project-1', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 3153a2c420b..d3581f78310 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const @@ -222,6 +223,7 @@ export const LINEAR_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force) }), + LINEAR_PROJECT_CREATE_METHOD, defineMethod({ name: 'linear.getProject', params: ProjectId, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b29f74b4dbb..e692a10f7f4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -227,6 +227,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'linear.addIssueComment', 'linear.connect', 'linear.createIssue', + 'linear.createProject', 'linear.issueComments', 'linear.listCustomViewIssues', 'linear.listCustomViewProjects', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 93248b49cea..fd69d1e50cf 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1433,6 +1433,19 @@ export type PreloadApi = { workspaceId?: LinearWorkspaceSelection force?: boolean }) => Promise> + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> getProject: (args: { id: string workspaceId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index a2f45cc13cc..db3fff187ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ import type { GitUpstreamStatus, GhosttyImportPreview, ListWorkItemsResult, + LinearProjectDetail, MemorySnapshot, NotificationDismissResult, NotificationDispatchResult, @@ -1282,6 +1283,21 @@ const api = { force?: boolean }): Promise => ipcRenderer.invoke('linear:listProjects', args), + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => ipcRenderer.invoke('linear:getProject', args), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index c03a42da12b..b385db3abc7 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -203,6 +203,7 @@ import { useTeamLabels } from '@/hooks/useIssueMetadata' import { + linearCreateProject, linearCreateIssue, linearGetIssue, linearTeamStates, @@ -498,10 +499,7 @@ const LINEAR_MODE_OPTIONS: { id: LinearMode; label: string }[] = [ { id: 'views', label: 'Views' } ] -const LINEAR_CUSTOM_VIEW_MODEL_OPTIONS: { id: LinearCustomViewModel; label: string }[] = [ - { id: 'issue', label: 'Issues' }, - { id: 'project', label: 'Projects' } -] +const LINEAR_CUSTOM_VIEW_MODELS = ['issue', 'project'] satisfies readonly LinearCustomViewModel[] const LINEAR_VIEW_OPTIONS: { id: LinearViewMode @@ -512,6 +510,17 @@ const LINEAR_VIEW_OPTIONS: { { id: 'board', label: 'Board', Icon: LayoutGrid } ] +function mergeLinearCollectionResults( + results: LinearCollectionResult[] +): LinearCollectionResult { + const errors = results.flatMap((result) => result.errors ?? []) + return { + items: results.flatMap((result) => result.items), + ...(errors.length > 0 ? { errors } : {}), + ...(results.some((result) => result.hasMore) ? { hasMore: true } : {}) + } +} + const LINEAR_GROUP_OPTIONS: { id: LinearGroupBy; label: string }[] = [ { id: 'none', label: 'No grouping' }, { id: 'status', label: 'Status' }, @@ -3200,7 +3209,6 @@ export default function TaskPage(): React.JSX.Element { >(null) const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false) const [linearProjectIssuesError, setLinearProjectIssuesError] = useState(null) - const [linearCustomViewModel, setLinearCustomViewModel] = useState('issue') const [linearCustomViewsResult, setLinearCustomViewsResult] = useState< LinearCollectionResult >({ items: [] }) @@ -3442,7 +3450,6 @@ export default function TaskPage(): React.JSX.Element { } if (context.kind === 'view' && context.model) { - setLinearCustomViewModel(context.model) setLinearMode('views') setLinearCustomViewsLoading(true) setLinearCustomViewsError(null) @@ -4246,6 +4253,41 @@ export default function TaskPage(): React.JSX.Element { [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache] ) + // New Linear project dialog state + const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false) + const [newLinearProjectName, setNewLinearProjectName] = useState('') + const [newLinearProjectDescription, setNewLinearProjectDescription] = useState('') + const [newLinearProjectContent, setNewLinearProjectContent] = useState('') + const [newLinearProjectTeamId, setNewLinearProjectTeamId] = useState(null) + const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState(null) + const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState([]) + const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState([]) + const [newLinearProjectPriority, setNewLinearProjectPriority] = useState(0) + const [newLinearProjectStartDate, setNewLinearProjectStartDate] = useState('') + const [newLinearProjectTargetDate, setNewLinearProjectTargetDate] = useState('') + const [newLinearProjectSubmitting, setNewLinearProjectSubmitting] = useState(false) + + const newLinearProjectTargetTeam = useMemo( + () => availableTeams.find((t) => t.id === newLinearProjectTeamId) ?? availableTeams[0] ?? null, + [availableTeams, newLinearProjectTeamId] + ) + const newLinearProjectMembers = useTeamMembers( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + const newLinearProjectLabels = useTeamLabels( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + + useEffect(() => { + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + }, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId]) + // New Linear issue dialog state const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false) const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('') @@ -4347,6 +4389,7 @@ export default function TaskPage(): React.JSX.Element { !gitlabDialogItem && !selectedLinearIssue && !newIssueOpen && + !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && activeModal === 'none', @@ -5112,6 +5155,7 @@ export default function TaskPage(): React.JSX.Element { githubMode !== 'items' || dialogWorkItem || newIssueOpen || + newLinearProjectOpen || newLinearIssueOpen || newJiraIssueOpen || activeModal !== 'none' @@ -5154,6 +5198,7 @@ export default function TaskPage(): React.JSX.Element { dialogWorkItem, githubMode, newIssueOpen, + newLinearProjectOpen, newLinearIssueOpen, newJiraIssueOpen, taskSource @@ -5350,6 +5395,81 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem ]) + const handleCreateNewLinearProject = useCallback(async (): Promise => { + if (!newLinearProjectTargetTeam) { + return + } + const name = newLinearProjectName.trim() + if (!name || newLinearProjectSubmitting) { + return + } + setNewLinearProjectSubmitting(true) + try { + const result = await linearCreateProject(settings, { + name, + description: newLinearProjectDescription.trim() || undefined, + content: newLinearProjectContent.trim() || undefined, + teamIds: [newLinearProjectTargetTeam.id], + workspaceId: newLinearProjectTargetTeam.workspaceId, + leadId: newLinearProjectLeadId || undefined, + memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined, + labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined, + priority: newLinearProjectPriority, + startDate: newLinearProjectStartDate || undefined, + targetDate: newLinearProjectTargetDate || undefined + }) + if (!result.ok) { + toast.error(result.error || 'Failed to create project.') + return + } + toast.success(`Created ${result.project.name}`, { + action: result.project.url + ? { + label: 'View', + onClick: () => window.open(result.project.url, '_blank') + } + : undefined + }) + setNewLinearProjectOpen(false) + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setAppliedLinearProjectSearch('') + setLinearProjectSearchInput('') + setLinearProjectsResult((current) => ({ + ...current, + items: [result.project, ...current.items.filter((item) => item.id !== result.project.id)] + })) + setSelectedLinearProjectDetail(result.project) + openLinearProjectContext(result.project) + setLinearRefreshNonce((n) => n + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create project.') + } finally { + setNewLinearProjectSubmitting(false) + } + }, [ + newLinearProjectContent, + newLinearProjectDescription, + newLinearProjectLabelIds, + newLinearProjectLeadId, + newLinearProjectMemberIds, + newLinearProjectName, + newLinearProjectPriority, + newLinearProjectStartDate, + newLinearProjectSubmitting, + newLinearProjectTargetDate, + newLinearProjectTargetTeam, + openLinearProjectContext, + settings + ]) + const handleCreateNewLinearIssue = useCallback(async (): Promise => { if (!newLinearIssueTargetTeam) { return @@ -5884,17 +6004,28 @@ export default function TaskPage(): React.JSX.Element { return } let cancelled = false - const cached = getCachedLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT) - if (cached) { - setLinearCustomViewsResult(cached) + const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => + getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT) + ) + const allCached = cachedResults.every( + (result): result is LinearCollectionResult => result !== null + ) + if (allCached) { + setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults)) } const force = linearRefreshNonce > 0 - setLinearCustomViewsLoading(force || cached === null) + setLinearCustomViewsLoading(force || !allCached) setLinearCustomViewsError(null) - void listLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT, undefined, { force }) + // Why: the Views tab already has a Model column, so listing both view + // models avoids a second, redundant Issues/Projects switch. + void Promise.all( + LINEAR_CUSTOM_VIEW_MODELS.map((model) => + listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force }) + ) + ) .then((result) => { if (!cancelled) { - setLinearCustomViewsResult(result) + setLinearCustomViewsResult(mergeLinearCollectionResults(result)) setLinearCustomViewsLoading(false) } }) @@ -5917,9 +6048,9 @@ export default function TaskPage(): React.JSX.Element { linearStatus.connected, selectedLinearWorkspaceId, selectedLinearCustomView, - linearCustomViewModel, linearRefreshNonce, - getCachedLinearCustomViews + getCachedLinearCustomViews, + listLinearCustomViews ]) useEffect(() => { @@ -6755,6 +6886,20 @@ export default function TaskPage(): React.JSX.Element { variant="outline" size="icon" onClick={() => { + if (linearMode === 'projects' && !selectedLinearProject) { + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectTeamId(availableTeams[0]?.id ?? null) + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setNewLinearProjectOpen(true) + return + } setNewLinearIssueTitle('') setNewLinearIssueBody('') const projectTeamId = @@ -6770,14 +6915,20 @@ export default function TaskPage(): React.JSX.Element { setNewLinearIssueOpen(true) }} disabled={availableTeams.length === 0} - aria-label="New Linear issue" + aria-label={ + linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue' + } className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent" > - New Linear issue + {linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue'} @@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element { ) : null}
- ) : linearMode === 'views' && !selectedLinearCustomView ? ( -
- {LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => { - const active = option.id === linearCustomViewModel - return ( - - ) - })} -
) : null} ) : taskSource === 'jira' && jiraStatus.connected ? ( @@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element { ) : null} ) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? ( @@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element { + { + if (!newLinearProjectSubmitting) { + setNewLinearProjectOpen(open) + } + }} + > + { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + > + New Linear project + + Create a Linear project for the selected team. + +
+
+ + New Project + + / + {availableTeams.length > 1 ? ( + + + + + +
+ Team +
+
+ {availableTeams.map((team) => ( + + ))} +
+
+
+ ) : ( + + {newLinearProjectTargetTeam + ? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}` + : ''} + + )} +
+ +
+ +
+ setNewLinearProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + placeholder="Project name" + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + + setNewLinearProjectDescription(event.target.value)} + placeholder="Add a short summary..." + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + +
+ + + + + +
+ Priority +
+ {[0, 1, 2, 3, 4].map((priority) => ( + + ))} +
+
+ + + + + + +
+ Lead +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ + {newLinearProjectMembers.data.map((member) => ( + + ))} +
+ )} +
+
+ + + + + + +
+ Members +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectMembers.data.map((member) => { + const selected = newLinearProjectMemberIds.includes(member.id) + return ( + + ) + })} +
+ )} +
+
+ + + + + + +
+ Labels +
+ {newLinearProjectLabels.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectLabels.data.length === 0 ? ( +
No labels
+ ) : ( + newLinearProjectLabels.data.map((label) => { + const selected = newLinearProjectLabelIds.includes(label.id) + return ( + + ) + }) + )} +
+ )} +
+
+ + + + +
+ +
+