Files
aiturk-hermes-ide/apps/desktop/src/app/open-session.ts
T

172 lines
6.1 KiB
TypeScript

/**
* 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))
}
}