diff --git a/application/state/useAIState.ts b/application/state/useAIState.ts index 78f3fc819..728abbd81 100644 --- a/application/state/useAIState.ts +++ b/application/state/useAIState.ts @@ -73,6 +73,10 @@ import { type PanelViewByScope, } from './aiStateSnapshots'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; +import { + handoffDissolvedWorkspaceAIScope, + seedWorkspaceAIActiveSessionFromMembers, +} from '../../domain/workspaceAiScopeHandoff'; export function useAIState() { // ── Provider Config ── const [providers, setProvidersRaw] = useState(() => @@ -968,6 +972,65 @@ export function useAIState() { setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {}); }, []); + const seedWorkspaceActiveSessionFromMembers = useCallback((input: { + workspaceId: string; + memberTerminalIds: readonly string[]; + preferredTerminalId?: string | null; + }) => { + const currentMap = + latestAIActiveSessionMapSnapshot + ?? localStorageAdapter.read>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) + ?? {}; + const nextMap = seedWorkspaceAIActiveSessionFromMembers({ + activeSessionIdMap: currentMap, + workspaceId: input.workspaceId, + memberTerminalIds: input.memberTerminalIds, + preferredTerminalId: input.preferredTerminalId, + }); + if (!nextMap) return; + setLatestAIActiveSessionMapSnapshot(nextMap); + localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextMap); + emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP); + setActiveSessionIdMapRaw(nextMap); + }, []); + + const handoffDissolvedWorkspaceScope = useCallback((input: { + workspaceId: string; + terminalIds: readonly string[]; + preferredTerminalId?: string | null; + }) => { + const currentMap = + latestAIActiveSessionMapSnapshot + ?? localStorageAdapter.read>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) + ?? {}; + const currentSessions = + latestAISessionsSnapshot + ?? localStorageAdapter.read(STORAGE_KEY_AI_SESSIONS) + ?? []; + const result = handoffDissolvedWorkspaceAIScope({ + activeSessionIdMap: currentMap, + sessions: currentSessions, + workspaceId: input.workspaceId, + terminalIds: input.terminalIds, + preferredTerminalId: input.preferredTerminalId, + }); + if (!result.changed) return; + + if (result.activeSessionIdMap !== currentMap) { + setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap); + localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap); + emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP); + setActiveSessionIdMapRaw(result.activeSessionIdMap); + } + + if (result.sessions !== currentSessions) { + sessionsRef.current = result.sessions; + setLatestAISessionsSnapshot(result.sessions); + persistSessions(result.sessions); + setSessionsRaw(result.sessions); + } + }, [persistSessions]); + // ── Provider CRUD helpers ── const addProvider = useCallback((provider: ProviderConfig) => { setProviders(prev => [...prev, provider]); @@ -1071,6 +1134,8 @@ export function useAIState() { updateMessageById, persistContextCompaction, cleanupOrphanedSessions, + seedWorkspaceActiveSessionFromMembers, + handoffDissolvedWorkspaceScope, }), [ providers, setProviders, @@ -1124,5 +1189,7 @@ export function useAIState() { updateMessageById, persistContextCompaction, cleanupOrphanedSessions, + seedWorkspaceActiveSessionFromMembers, + handoffDissolvedWorkspaceScope, ]); } diff --git a/application/state/useTerminalSidePanelLayoutState.ts b/application/state/useTerminalSidePanelLayoutState.ts index 463f35473..3c62500fd 100644 --- a/application/state/useTerminalSidePanelLayoutState.ts +++ b/application/state/useTerminalSidePanelLayoutState.ts @@ -123,6 +123,7 @@ export function useTerminalSidePanelLayoutState() { setSidePanelOpenTabs, sidePanelOpenTabsRef, sidePanelLayouts, + setSidePanelLayouts, sidePanelLayoutsRef, focusPane, splitPane, diff --git a/components/AIChatSidePanel.mountRetention.test.ts b/components/AIChatSidePanel.mountRetention.test.ts index 507cdea18..f6bbcc537 100644 --- a/components/AIChatSidePanel.mountRetention.test.ts +++ b/components/AIChatSidePanel.mountRetention.test.ts @@ -252,3 +252,51 @@ test('AI side panel skips re-render when only a sibling scope session object cha false, ); }); + +test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => { + const visibleMemberChat = session({ + id: 'chat-visible', + scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] }, + }); + const hiddenFocusedChat = session({ + id: 'chat-hidden', + scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] }, + }); + const prev = baseProps({ + isVisible: true, + scopeType: 'workspace', + scopeTargetId: 'ws-1', + focusedSessionId: 'terminal-a', + terminalSessions: [ + { + sessionId: 'terminal-a', + hostId: 'host-a', + hostname: 'a.example', + label: 'A', + connected: true, + }, + { + sessionId: 'terminal-b', + hostId: 'host-b', + hostname: 'b.example', + label: 'B', + connected: true, + }, + ], + activeSessionIdMap: { + 'terminal:terminal-a': 'chat-hidden', + 'terminal:terminal-b': 'chat-visible', + }, + sessions: [visibleMemberChat, hiddenFocusedChat], + }); + const streamedVisible = session({ + id: 'chat-visible', + scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] }, + messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }], + }); + + assert.equal( + aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }), + false, + ); +}); diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 13d8489f4..4f862008b 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -1621,19 +1621,45 @@ export function aiChatSidePanelPropsAreEqual( // resume whose stored scope.targetId is an older terminal. // Fuzzy history still receives the full list; drawer open forces re-render // via isVisible / other prop paths when the user actually needs it. - const selectedSessionId = resolveInheritedAIActiveSessionId({ - scopeType: prev.scopeType, - scopeTargetId: prev.scopeTargetId, - activeSessionIdMap: prev.activeSessionIdMap, - memberTerminalIds: (prev.terminalSessions ?? []).map((session) => session.sessionId), - preferredTerminalId: prev.focusedSessionId, - }) ?? resolveInheritedAIActiveSessionId({ - scopeType: next.scopeType, - scopeTargetId: next.scopeTargetId, - activeSessionIdMap: next.activeSessionIdMap, - memberTerminalIds: (next.terminalSessions ?? []).map((session) => session.sessionId), - preferredTerminalId: next.focusedSessionId, - }); + // Keep visibleSessionIds in sync with the live panel so inheritance that + // skips non-history chats does not leave memo pinned to a different id. + const resolveSelectedSessionId = ( + props: AIChatSidePanelProps, + ): string | null => { + const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`; + const memberTerminalIds = (props.terminalSessions ?? []) + .map((session) => session.sessionId) + .filter((sessionId): sessionId is string => Boolean(sessionId)); + const workspaceMemberTerminalIds = props.scopeType === 'workspace' + ? new Set(memberTerminalIds) + : undefined; + const activeTerminalSessionIds = new Set(); + for (const [sessionScopeKey, sessionId] of Object.entries(props.activeSessionIdMap) as Array<[string, string | null]>) { + if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue; + if (sessionScopeKey === scopeKey) continue; + activeTerminalSessionIds.add(sessionId); + } + const visibleSessionIds = new Set( + getScopedHistorySessions( + props.sessions, + props.scopeType, + props.scopeTargetId, + props.scopeHostIds, + activeTerminalSessionIds, + workspaceMemberTerminalIds, + ).map((session) => session.id), + ); + return resolveInheritedAIActiveSessionId({ + scopeType: props.scopeType, + scopeTargetId: props.scopeTargetId, + activeSessionIdMap: props.activeSessionIdMap, + memberTerminalIds, + preferredTerminalId: props.focusedSessionId, + visibleSessionIds, + }); + }; + const selectedSessionId = resolveSelectedSessionId(prev) + ?? resolveSelectedSessionId(next); if (!exactScopeAISessionsEqual( prev.sessions, next.sessions, diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 0c3742c7d..463ff73cc 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -268,6 +268,7 @@ const TerminalLayerInner: React.FC = ({ setSidePanelOpenTabs, sidePanelOpenTabsRef, sidePanelLayouts, + setSidePanelLayouts, sidePanelLayoutsRef, focusPane: focusSidePanelPaneForTab, splitPane: splitSidePanelPaneForTab, @@ -2136,6 +2137,7 @@ const TerminalLayerInner: React.FC = ({ setSystemMountedTabIds, setThemeMountedTabIds, setSidePanelOpenTabs, + setSidePanelLayouts, setSidePanelWidth, setSftpFollowTerminalCwd, setSftpHostForTab, diff --git a/components/terminalLayer/TerminalLayerSupport.tsx b/components/terminalLayer/TerminalLayerSupport.tsx index 2b342ed04..76da438a9 100644 --- a/components/terminalLayer/TerminalLayerSupport.tsx +++ b/components/terminalLayer/TerminalLayerSupport.tsx @@ -279,6 +279,7 @@ interface AIChatPanelsHostProps { interface AIStateMaintenanceHostProps { validAIScopeTargetIds: Set; + workspaces: Workspace[]; } const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -295,6 +296,7 @@ AIStateProvider.displayName = 'AIStateProvider'; const AIStateMaintenanceHostInner: React.FC = ({ validAIScopeTargetIds, + workspaces, }) => { const aiConfig = useContext(AIConfigContext); @@ -302,11 +304,72 @@ const AIStateMaintenanceHostInner: React.FC = ({ throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider'); } - const { cleanupOrphanedSessions } = aiConfig; + const { + cleanupOrphanedSessions, + seedWorkspaceActiveSessionFromMembers, + handoffDissolvedWorkspaceScope, + } = aiConfig; + const previousWorkspacesRef = useRef(workspaces); + const previousSessionWorkspaceRef = useRef( + new Map(workspaces.flatMap((workspace) => ( + collectSessionIds(workspace.root).map((sessionId) => [sessionId, workspace.id] as const) + ))), + ); useEffect(() => { + const previousWorkspaces = previousWorkspacesRef.current; + const previousIds = new Set(previousWorkspaces.map((workspace) => workspace.id)); + const nextIds = new Set(workspaces.map((workspace) => workspace.id)); + const previousSessionWorkspace = previousSessionWorkspaceRef.current; + + for (const workspace of workspaces) { + if (previousIds.has(workspace.id)) continue; + const memberTerminalIds = collectSessionIds(workspace.root); + seedWorkspaceActiveSessionFromMembers({ + workspaceId: workspace.id, + memberTerminalIds, + preferredTerminalId: workspace.focusedSessionId, + }); + } + + for (const workspace of workspaces) { + for (const sessionId of collectSessionIds(workspace.root)) { + const previousWorkspaceId = previousSessionWorkspace.get(sessionId); + if (previousWorkspaceId === workspace.id) continue; + // Member newly joined this workspace — seed only fills an empty map. + // Prefer the workspace focused pane so we don't pin the joiner's chat + // ahead of the pane the user is already looking at. + seedWorkspaceActiveSessionFromMembers({ + workspaceId: workspace.id, + memberTerminalIds: collectSessionIds(workspace.root), + preferredTerminalId: workspace.focusedSessionId, + }); + break; + } + } + + for (const workspace of previousWorkspaces) { + if (nextIds.has(workspace.id)) continue; + const memberTerminalIds = collectSessionIds(workspace.root); + handoffDissolvedWorkspaceScope({ + workspaceId: workspace.id, + terminalIds: memberTerminalIds.filter((sessionId) => validAIScopeTargetIds.has(sessionId)), + preferredTerminalId: workspace.focusedSessionId, + }); + } + + previousWorkspacesRef.current = workspaces; + previousSessionWorkspaceRef.current = new Map(workspaces.flatMap((workspace) => ( + collectSessionIds(workspace.root).map((sessionId) => [sessionId, workspace.id] as const) + ))); cleanupOrphanedSessions(validAIScopeTargetIds); - }, [cleanupOrphanedSessions, validAIScopeTargetIds]); + }, [ + cleanupOrphanedSessions, + handoffDissolvedWorkspaceScope, + seedWorkspaceActiveSessionFromMembers, + validAIScopeTargetIds, + workspaces, + ]); return null; }; @@ -316,15 +379,20 @@ AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost'; interface AISidePanelStateRootProps { validAIScopeTargetIds: Set; + workspaces: Workspace[]; children: React.ReactNode; } const AISidePanelStateRootInner: React.FC = ({ validAIScopeTargetIds, + workspaces, children, }) => ( - + {children} ); diff --git a/components/terminalLayer/TerminalLayerTabBridge.tsx b/components/terminalLayer/TerminalLayerTabBridge.tsx index 7f135bd73..dc6bb2665 100644 --- a/components/terminalLayer/TerminalLayerTabBridge.tsx +++ b/components/terminalLayer/TerminalLayerTabBridge.tsx @@ -370,6 +370,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef }) setSystemMountedTabIds: s.setSystemMountedTabIds, setThemeMountedTabIds: s.setThemeMountedTabIds, setSidePanelOpenTabs: s.setSidePanelOpenTabs, + setSidePanelLayouts: s.setSidePanelLayouts, setTimeout, setWorkspaceArea, sidePanelPosition: s.sidePanelPosition, diff --git a/components/terminalLayer/terminalLayerSidePanelSlots.tsx b/components/terminalLayer/terminalLayerSidePanelSlots.tsx index 7f1b0215e..0f51fba31 100644 --- a/components/terminalLayer/terminalLayerSidePanelSlots.tsx +++ b/components/terminalLayer/terminalLayerSidePanelSlots.tsx @@ -520,6 +520,7 @@ function SidePanelAiSlotInner({ onOpenVaultSnippetFromChat, validAIScopeTargetIds, } = ctx; + const workspaces = ctx.workspaces as Workspace[]; // Gate notes subscription while AI is hidden so note edits do not re-render // retained AI mounts (panel still mounts for fast reopen). @@ -529,15 +530,27 @@ function SidePanelAiSlotInner({ isVisible ? getNotesSnapshot : getEmptyNotesSnapshot, ); - if (mountedAiTabIds.length === 0) return null; const activeLayout = activeTabId ? (ctx.sidePanelLayouts as Map).get(activeTabId) : null; - if ( + const hideVisibleShell = ( AI_PANEL_FORCE_HIDE_SHELL && isVisible && (!activeLayout || collectSidePanelPanes(activeLayout.root).length <= 1) - ) return null; + ); + + // Keep the AI state root mounted even with zero panel hosts so workspace + // merge/detach can seed and hand off scoped chats before orphan cleanup. + if (mountedAiTabIds.length === 0 || hideVisibleShell) { + return ( + + {null} + + ); + } // Only the visible AI panel needs vault catalogs for artifact navigation. // Hidden retained panels keep session state without re-binding huge hosts/notes. @@ -547,7 +560,10 @@ function SidePanelAiSlotInner({ : EMPTY_VAULT_NOTES; return ( - + [session.id, session.workspaceId])), + ); + + useEffect(() => { + const previousWorkspaces = previousWorkspacesRef.current; + const previousIds = new Set(previousWorkspaces.map((workspace: { id: string }) => workspace.id)); + const nextIds = new Set(workspaces.map((workspace: { id: string }) => workspace.id)); + const previousSessionWorkspace = previousSessionWorkspaceRef.current; + const remaps: SidePanelTabRemap[] = []; + + for (const workspace of workspaces) { + if (previousIds.has(workspace.id)) continue; + remaps.push({ + kind: 'promote', + fromTabIds: collectSessionIds(workspace.root), + toTabId: workspace.id, + preferredFromTabId: workspace.focusedSessionId, + }); + } + + for (const session of sessions) { + const previousWorkspaceId = previousSessionWorkspace.get(session.id); + if (!session.workspaceId || session.workspaceId === previousWorkspaceId) continue; + // Orphan (or other workspace) joined an existing workspace tab. + if (previousIds.has(session.workspaceId)) { + const workspace = workspaces.find((entry: { id: string }) => entry.id === session.workspaceId); + const focusedSessionId = workspace?.focusedSessionId; + remaps.push({ + kind: 'promote', + fromTabIds: focusedSessionId + ? [focusedSessionId, session.id] + : [session.id], + toTabId: session.workspaceId, + preferredFromTabId: focusedSessionId ?? session.id, + }); + } + } + + for (const workspace of previousWorkspaces) { + if (nextIds.has(workspace.id)) continue; + const memberTerminalIds = collectSessionIds(workspace.root) + .filter((sessionId: string) => validAIScopeTargetIds.has(sessionId)); + remaps.push({ + kind: 'demote', + fromTabId: workspace.id, + toTabIds: memberTerminalIds, + preferredToTabId: ( + memberTerminalIds.includes(workspace.focusedSessionId) + ? workspace.focusedSessionId + : memberTerminalIds[0] + ), + }); + } + + previousWorkspacesRef.current = workspaces; + previousSessionWorkspaceRef.current = new Map( + sessions.map((session: { id: string; workspaceId?: string }) => [session.id, session.workspaceId]), + ); + if (remaps.length === 0) return; + + setSidePanelOpenTabs((prev: Map) => { + let next = prev; + for (const remap of remaps) { + next = remapSidePanelTabMap(next, remap); + } + return next; + }); + // Copy split trees with the open-tool map so reconcile does not replace a + // multi-pane layout with a fresh single-tool root on the destination tab. + setSidePanelLayouts((prev: Map) => { + let next = prev; + for (const remap of remaps) { + next = remapSidePanelTabMap(next, remap); + } + return next; + }); + // SFTP portal mounts from sftpHostForTab keys — keep host/path/upload maps + // aligned with the remapped open-tool tab ids. + setSftpHostForTab((prev: Map) => { + let next = prev; + for (const remap of remaps) { + next = remapSidePanelTabMap(next, remap); + } + return next; + }); + setSftpInitialLocationForTab((prev: Map) => { + let next = prev; + for (const remap of remaps) { + next = remapSidePanelTabMap(next, remap); + } + return next; + }); + setSftpPendingUploadsForTab((prev: Map) => { + let next = prev; + for (const remap of remaps) { + next = remapSidePanelTabMap(next, remap); + } + return next; + }); + setAiMountedTabIds((prev: string[]) => { + let next = prev; + for (const remap of remaps) { + next = remapMountedSidePanelTabIds(next, remap); + } + return next; + }); + setNotesMountedTabIds((prev: string[]) => { + let next = prev; + for (const remap of remaps) { + next = remapMountedSidePanelTabIds(next, remap); + } + return next; + }); + setScriptsMountedTabIds((prev: string[]) => { + let next = prev; + for (const remap of remaps) { + next = remapMountedSidePanelTabIds(next, remap); + } + return next; + }); + setSystemMountedTabIds((prev: string[]) => { + let next = prev; + for (const remap of remaps) { + next = remapMountedSidePanelTabIds(next, remap); + } + return next; + }); + setThemeMountedTabIds((prev: string[]) => { + let next = prev; + for (const remap of remaps) { + next = remapMountedSidePanelTabIds(next, remap); + } + return next; + }); + }, [ + sessions, + setAiMountedTabIds, + setNotesMountedTabIds, + setScriptsMountedTabIds, + setSftpHostForTab, + setSftpInitialLocationForTab, + setSftpPendingUploadsForTab, + setSidePanelLayouts, + setSidePanelOpenTabs, + setSystemMountedTabIds, + setThemeMountedTabIds, + validAIScopeTargetIds, + workspaces, + ]); const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false; const sidePanelShellWidth = getTerminalSidePanelShellWidth({ diff --git a/domain/workspaceAiScopeHandoff.test.ts b/domain/workspaceAiScopeHandoff.test.ts new file mode 100644 index 000000000..1bb950f95 --- /dev/null +++ b/domain/workspaceAiScopeHandoff.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + handoffDissolvedWorkspaceAIScope, + seedWorkspaceAIActiveSessionFromMembers, +} from './workspaceAiScopeHandoff.ts'; + +test('seedWorkspaceAIActiveSessionFromMembers writes focused terminal chat onto a new workspace', () => { + const next = seedWorkspaceAIActiveSessionFromMembers({ + activeSessionIdMap: { + 'terminal:term-a': 'chat-a', + 'terminal:term-b': 'chat-b', + }, + workspaceId: 'ws-1', + memberTerminalIds: ['term-a', 'term-b'], + preferredTerminalId: 'term-a', + }); + + assert.deepEqual(next, { + 'terminal:term-a': 'chat-a', + 'terminal:term-b': 'chat-b', + 'workspace:ws-1': 'chat-a', + }); +}); + +test('seedWorkspaceAIActiveSessionFromMembers leaves an existing workspace active alone', () => { + assert.equal( + seedWorkspaceAIActiveSessionFromMembers({ + activeSessionIdMap: { + 'workspace:ws-1': 'chat-ws', + 'terminal:term-a': 'chat-a', + }, + workspaceId: 'ws-1', + memberTerminalIds: ['term-a'], + preferredTerminalId: 'term-a', + }), + null, + ); +}); + +test('handoffDissolvedWorkspaceAIScope copies active chat and remints workspace sessions', () => { + const result = handoffDissolvedWorkspaceAIScope({ + activeSessionIdMap: { + 'workspace:ws-1': 'chat-ws', + 'terminal:term-b': 'chat-b', + }, + sessions: [ + { + id: 'chat-ws', + scope: { type: 'workspace', targetId: 'ws-1', hostIds: ['host-a'] }, + updatedAt: 1, + }, + { + id: 'chat-b', + scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] }, + updatedAt: 1, + }, + ], + workspaceId: 'ws-1', + terminalIds: ['term-a', 'term-b'], + preferredTerminalId: 'term-a', + }); + + assert.equal(result.changed, true); + assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-ws'); + assert.equal(result.sessions[0]?.scope.type, 'terminal'); + assert.equal(result.sessions[0]?.scope.targetId, 'term-a'); + assert.equal(result.sessions[1]?.scope.targetId, 'term-b'); +}); + +test('handoffDissolvedWorkspaceAIScope is a no-op without a preferred terminal', () => { + const sessions = [ + { + id: 'chat-ws', + scope: { type: 'workspace', targetId: 'ws-1' }, + updatedAt: 1, + }, + ]; + const result = handoffDissolvedWorkspaceAIScope({ + activeSessionIdMap: { 'workspace:ws-1': 'chat-ws' }, + sessions, + workspaceId: 'ws-1', + terminalIds: [], + }); + + assert.equal(result.changed, false); + assert.equal(result.sessions, sessions); +}); diff --git a/domain/workspaceAiScopeHandoff.ts b/domain/workspaceAiScopeHandoff.ts new file mode 100644 index 000000000..9a49530a4 --- /dev/null +++ b/domain/workspaceAiScopeHandoff.ts @@ -0,0 +1,119 @@ +import { buildAIScopeKey } from './aiSessionsForScope'; +import { resolveInheritedAIActiveSessionId } from './aiWorkspaceScopeInherit'; + +export type AIActiveSessionIdMap = Readonly>; + +export type AISessionScopeHandoffLike = { + id: string; + scope: { + type: string; + targetId?: string; + hostIds?: string[]; + }; + updatedAt?: number; +}; + +/** + * Seed a brand-new workspace scope from member terminal maps (focused first) + * so the first paint does not wait on a visible-panel write-back. + */ +export function seedWorkspaceAIActiveSessionFromMembers(input: { + activeSessionIdMap: AIActiveSessionIdMap; + workspaceId: string; + memberTerminalIds: readonly string[]; + preferredTerminalId?: string | null; +}): Record | null { + const workspaceKey = buildAIScopeKey('workspace', input.workspaceId); + const existing = input.activeSessionIdMap[workspaceKey]; + if (typeof existing === 'string' && existing.length > 0) { + return null; + } + + const inherited = resolveInheritedAIActiveSessionId({ + scopeType: 'workspace', + scopeTargetId: input.workspaceId, + activeSessionIdMap: input.activeSessionIdMap, + memberTerminalIds: input.memberTerminalIds, + preferredTerminalId: input.preferredTerminalId, + }); + if (!inherited) return null; + + return { + ...(input.activeSessionIdMap as Record), + [workspaceKey]: inherited, + }; +} + +/** + * When a workspace tab dissolves, copy its active chat onto the preferred + * surviving terminal before the workspace scope key is pruned. Also remint + * workspace-scoped chat records onto that terminal so history matching and + * cleanup keep them visible. + */ +export function handoffDissolvedWorkspaceAIScope(input: { + activeSessionIdMap: AIActiveSessionIdMap; + sessions: readonly T[]; + workspaceId: string; + terminalIds: readonly string[]; + preferredTerminalId?: string | null; +}): { + activeSessionIdMap: Record; + sessions: T[]; + changed: boolean; +} { + const preferredTerminalId = ( + input.preferredTerminalId + && input.terminalIds.includes(input.preferredTerminalId) + ? input.preferredTerminalId + : input.terminalIds.find(Boolean) + ) ?? null; + + const workspaceKey = buildAIScopeKey('workspace', input.workspaceId); + const workspaceActive = input.activeSessionIdMap[workspaceKey]; + const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0; + + let nextMap: Record = { + ...(input.activeSessionIdMap as Record), + }; + let mapChanged = false; + + if (preferredTerminalId && hasWorkspaceActive) { + const terminalKey = buildAIScopeKey('terminal', preferredTerminalId); + if (nextMap[terminalKey] !== workspaceActive) { + nextMap = { ...nextMap, [terminalKey]: workspaceActive }; + mapChanged = true; + } + } + + let sessionsChanged = false; + const nextSessions = input.sessions.map((session) => { + if (session.scope.type !== 'workspace' || session.scope.targetId !== input.workspaceId) { + return session; + } + if (!preferredTerminalId) return session; + sessionsChanged = true; + return { + ...session, + scope: { + ...session.scope, + type: 'terminal', + targetId: preferredTerminalId, + }, + updatedAt: Date.now(), + }; + }); + + if (!mapChanged && !sessionsChanged) { + return { + activeSessionIdMap: input.activeSessionIdMap as Record, + sessions: input.sessions as T[], + changed: false, + }; + } + + return { + activeSessionIdMap: nextMap, + sessions: sessionsChanged ? nextSessions : input.sessions as T[], + changed: true, + }; +} diff --git a/domain/workspaceSidePanelTabRemap.test.ts b/domain/workspaceSidePanelTabRemap.test.ts new file mode 100644 index 000000000..9ba293070 --- /dev/null +++ b/domain/workspaceSidePanelTabRemap.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + remapMountedSidePanelTabIds, + remapSidePanelTabMap, +} from './workspaceSidePanelTabRemap.ts'; + +test('promote copies preferred member open tool onto the new workspace tab', () => { + const source = new Map([ + ['term-a', 'ai'], + ['term-b', 'sftp'], + ]); + + assert.deepEqual( + [...remapSidePanelTabMap(source, { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + })], + [ + ['term-a', 'ai'], + ['term-b', 'sftp'], + ['ws-1', 'ai'], + ], + ); +}); + +test('promote copies split layouts onto the new workspace tab', () => { + const layoutA = { focusedPaneId: 'pane-a', root: { type: 'pane', id: 'pane-a', tool: 'ai' } }; + const layoutB = { focusedPaneId: 'pane-b', root: { type: 'pane', id: 'pane-b', tool: 'sftp' } }; + const source = new Map([ + ['term-a', layoutA], + ['term-b', layoutB], + ]); + + const next = remapSidePanelTabMap(source, { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }); + + assert.equal(next.get('ws-1'), layoutA); + assert.equal(next.get('term-a'), layoutA); +}); + +test('promote is a no-op when the workspace tab already has an open tool', () => { + const source = new Map([ + ['term-a', 'ai'], + ['ws-1', 'notes'], + ]); + + assert.equal( + remapSidePanelTabMap(source, { + kind: 'promote', + fromTabIds: ['term-a'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }), + source, + ); +}); + +test('demote copies workspace open tool onto the preferred orphan tab', () => { + const source = new Map([ + ['ws-1', 'ai'], + ['term-b', 'sftp'], + ]); + + assert.deepEqual( + [...remapSidePanelTabMap(source, { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + })], + [ + ['ws-1', 'ai'], + ['term-b', 'sftp'], + ['term-a', 'ai'], + ], + ); +}); + +test('demote overwrites a stale preferred member entry kept from merge', () => { + const source = new Map([ + ['ws-1', 'notes'], + ['term-a', 'ai'], + ]); + + assert.deepEqual( + [...remapSidePanelTabMap(source, { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + })], + [ + ['ws-1', 'notes'], + ['term-a', 'notes'], + ], + ); +}); + +test('mounted tab ids gain the destination tab when the source was mounted', () => { + assert.deepEqual( + remapMountedSidePanelTabIds(['term-a'], { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }), + ['term-a', 'ws-1'], + ); + + assert.deepEqual( + remapMountedSidePanelTabIds(['ws-1'], { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + }), + ['ws-1', 'term-a'], + ); +}); diff --git a/domain/workspaceSidePanelTabRemap.ts b/domain/workspaceSidePanelTabRemap.ts new file mode 100644 index 000000000..6b1bc8206 --- /dev/null +++ b/domain/workspaceSidePanelTabRemap.ts @@ -0,0 +1,111 @@ +/** + * Side-panel chrome is keyed by the top-level work tab id. Merging orphans + * into a workspace (or dissolving a workspace back to orphans) changes that + * id, so open-tool / layout / mount maps must be remapped or the panel looks + * like it was cleared even though the underlying chat still exists. + */ + +export type SidePanelTabPromote = { + kind: 'promote'; + fromTabIds: readonly string[]; + toTabId: string; + preferredFromTabId?: string | null; +}; + +export type SidePanelTabDemote = { + kind: 'demote'; + fromTabId: string; + toTabIds: readonly string[]; + preferredToTabId?: string | null; +}; + +export type SidePanelTabRemap = SidePanelTabPromote | SidePanelTabDemote; + +function pickPreferredSourceId( + candidates: readonly string[], + preferredId: string | null | undefined, + hasValue: (tabId: string) => boolean, +): string | null { + if (preferredId && candidates.includes(preferredId) && hasValue(preferredId)) { + return preferredId; + } + for (const tabId of candidates) { + if (tabId && hasValue(tabId)) return tabId; + } + return null; +} + +/** + * Copy an open side-panel entry across a tab-id change without deleting the + * source key. Keeping the source lets a later detach restore the orphan tab's + * open state even if demote never runs. + */ +export function remapSidePanelTabMap( + source: ReadonlyMap, + remap: SidePanelTabRemap, +): Map { + if (remap.kind === 'promote') { + if (!remap.toTabId || source.has(remap.toTabId)) { + return source instanceof Map ? source : new Map(source); + } + const fromId = pickPreferredSourceId( + remap.fromTabIds, + remap.preferredFromTabId, + (tabId) => source.has(tabId), + ); + if (!fromId) { + return source instanceof Map ? source : new Map(source); + } + const next = new Map(source); + next.set(remap.toTabId, source.get(fromId) as T); + return next; + } + + if (!remap.fromTabId || !source.has(remap.fromTabId)) { + return source instanceof Map ? source : new Map(source); + } + // After merge we keep member keys around; the workspace tab is still the + // live chrome the user was editing. Always overwrite the preferred survivor + // so dissolve does not leave a stale pre-merge member entry in place. + const preferredTo = remap.preferredToTabId + && remap.toTabIds.includes(remap.preferredToTabId) + ? remap.preferredToTabId + : remap.toTabIds.find(Boolean); + if (!preferredTo) { + return source instanceof Map ? source : new Map(source); + } + const next = new Map(source); + next.set(preferredTo, source.get(remap.fromTabId) as T); + return next; +} + +export function remapMountedSidePanelTabIds( + mountedTabIds: readonly string[], + remap: SidePanelTabRemap, +): string[] { + if (remap.kind === 'promote') { + if (!remap.toTabId || mountedTabIds.includes(remap.toTabId)) { + return [...mountedTabIds]; + } + const fromId = pickPreferredSourceId( + remap.fromTabIds, + remap.preferredFromTabId, + (tabId) => mountedTabIds.includes(tabId), + ); + if (!fromId) return [...mountedTabIds]; + return [...mountedTabIds, remap.toTabId]; + } + + if (!remap.fromTabId || !mountedTabIds.includes(remap.fromTabId)) { + return [...mountedTabIds]; + } + const preferredTo = remap.preferredToTabId + && remap.toTabIds.includes(remap.preferredToTabId) + ? remap.preferredToTabId + : remap.toTabIds.find((tabId) => tabId && !mountedTabIds.includes(tabId)) + ?? remap.toTabIds[0]; + if (!preferredTo || mountedTabIds.includes(preferredTo)) { + return [...mountedTabIds]; + } + return [...mountedTabIds, preferredTo]; +}