/** * One door for "open this session" — every surface (sidebar, ⌘K, notifications, * session switcher, refs, cron/artifacts) goes through here so a chat that's * already a tile (or the main tab) is JUMPED TO instead of yanked into main. * * Intents: * - `in-place` (sidebar click / Enter) — focus existing tile/main if on * screen; else load into main (same as the left sessions sidebar). * - `stack` (⌘K, notifications — anything that opens a chat from outside the * workspace) — like `tab`, but may spend main or an open blank draft tab * when either is empty. * - `tab` (⌘/⌃-click / ⌘-Enter / session refs) — focus if already on screen, * else open as a stacked session tab (never steals main from under you). * - `window` (⇧⌘-click) — pop into its own window; falls back to `tab` when * the bridge has no session-window support. */ import type { WorkspaceMode } from '@/contrib/types' import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' import type { SessionProfileRoute } from '@/store/session-request-router' import { focusedSessionNeedsRoute, focusOpenSession, openSessionTile, reuseBlankDraftTile, setSessionTileWorkspaceScope } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' import { $workspaceIsPage, sessionRoute } from './routes' export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void export interface OpenSessionWorkspaceScope { ownerRoute?: SessionProfileRoute workspaceMode: WorkspaceMode workspaceOwnerKey?: string workspaceTabTitle?: string } /** * Is the main tab holding a conversation worth preserving? * * A loaded chat may still be mid-turn, so replacing it with something else * throws away work the user can see. A blank draft has nothing to lose, which * is what lets the sidebar "+" and a `stack` open take the cheaper main path * instead of stacking a tab nobody asked for. */ export function mainChatOccupied(activeSessionId: null | string, selectedStoredSessionId: null | string): boolean { return Boolean(activeSessionId || selectedStoredSessionId) } /** Read modifiers the way session rows do — meta OR ctrl for tab, +shift for * window. `base` is what an unmodified select means for the caller: the * sidebar spends main (`in-place`), a palette-style open doesn't (`stack`). */ export function openSessionIntentFromModifiers( event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }, base: OpenSessionIntent = 'in-place' ): OpenSessionIntent { if (!event) { return base } const mod = Boolean(event.metaKey || event.ctrlKey) if (mod && event.shiftKey) { return 'window' } if (mod) { return 'tab' } return base } /** * @param navigate Required for `in-place` (route into main when not on screen). * `tab` / `window` ignore it — pass a no-op when you don't have a router handle. */ export function openSession( storedSessionId: string, navigate: OpenSessionNavigate, intent: OpenSessionIntent = 'in-place', workspaceScope: OpenSessionWorkspaceScope = { workspaceMode: 'sessions' } ): void { if (!storedSessionId) { return } // Any explicit open/focus means the user has seen the finished-turn marker. // Must run BEFORE the focus short-circuits below: clicking a session that is // already on screen (open tile, or the main session) would otherwise return // at focusOpenSession and never clear its unread dot. markSessionRead(storedSessionId) setSessionTileWorkspaceScope(storedSessionId, workspaceScope) const botWorkspaceScope = workspaceScope.workspaceMode === 'bots' ? workspaceScope : undefined let resolved: OpenSessionIntent = intent if (resolved === 'window') { if (canOpenSessionWindow()) { void openSessionInNewWindow(storedSessionId) return } // No pop-out support → treat like a new tab. resolved = 'tab' } if (resolved === 'main') { // Canonical relationship chats explicitly own the main workspace. Route // even when the session is already open as a tile; resumeSession removes // that redundant tile when the main surface binds. navigate(sessionRoute(storedSessionId)) return } // A `stack` open arrives from outside the workspace, so unlike a sidebar // click it can't assume main is spendable: it behaves like `tab`, except main // IS fair game while it's only a blank draft, and an already-open blank draft // tab is spent before a new one is stacked. let spendBlankDraft = false if (resolved === 'stack') { spendBlankDraft = mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get()) resolved = spendBlankDraft ? 'tab' : 'in-place' } if (resolved === 'tab') { // Already on screen? Front it. openSessionTile would no-op on main without // focusing, or try to relocate an existing tile — neither is right for a // soft "open beside" link. const focused = focusOpenSession(storedSessionId, workspaceScope) if (focused) { return } // Nothing to jump to, but an open tab may still be an empty "New session" — // that's the tab the user would have typed into, so spend it rather than // stacking a second blank one beside it. if ( spendBlankDraft && (botWorkspaceScope ? reuseBlankDraftTile(storedSessionId, botWorkspaceScope) : reuseBlankDraftTile(storedSessionId)) ) { return } if (botWorkspaceScope) { openSessionTile(storedSessionId, 'center', undefined, undefined, botWorkspaceScope) } else { openSessionTile(storedSessionId, 'center') } return } // Already on screen (open tile, or the main session)? Jump to its tab; // otherwise load it into main. From a full page (artifacts, skills, …) a // `'main'` hit still has to route back: fronting the workspace tab alone // leaves the page showing. if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId, workspaceScope), $workspaceIsPage.get())) { navigate(sessionRoute(storedSessionId)) } }