Merge pull request #73147 from NousResearch/bb/session-open-in-place

desktop: open sessions where they already are
This commit is contained in:
brooklyn! 2026-07-28 01:20:53 -05:00 committed by GitHub
commit 4289a934b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 304 additions and 53 deletions

View file

@ -37,8 +37,8 @@ import { notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { openSession } from '../open-session'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
@ -280,7 +280,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const cellCtx: CellCtx = {
onOpen: openArtifact,
onOpenChat: sessionId => navigate(sessionRoute(sessionId))
onOpenChat: sessionId => openSession(sessionId, navigate)
}
return (
@ -345,7 +345,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
onOpenChat={sessionId => openSession(sessionId, navigate)}
/>
))}
</div>

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { openSession } from '@/app/open-session'
import {
closeAllTreeTabs,
closeOtherTreeTabs,
@ -37,8 +38,8 @@ import {
setSessions
} from '@/store/session'
import { $sessionColorOverrides, setSessionColorOverride } from '@/store/session-color'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $sessionTiles } from '@/store/session-states'
import { canOpenSessionWindow } from '@/store/windows'
import type { SessionTitleResponse } from '../../types'
@ -169,8 +170,9 @@ function useSessionActions({
onSelect: () => {
triggerHaptic('selection')
// Stack into the MAIN zone as a tab (center dock; the strip
// sticky-shows on gain) — the door to the tab bar.
openSessionTile(sessionId, 'center')
// sticky-shows on gain) — the door to the tab bar. Focuses first
// if the session is already on screen.
openSession(sessionId, () => undefined, 'tab')
}
})
]
@ -183,7 +185,7 @@ function useSessionActions({
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
openSession(sessionId, () => undefined, 'window')
}
})
]

View file

@ -4,6 +4,7 @@ import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { openSession } from '@/app/open-session'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@ -14,8 +15,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $attentionSessionIds } from '@/store/session-states'
import { SessionStatusDot } from '../session-status-dot'
@ -174,18 +174,18 @@ export function SidebarSessionRow({
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
openSession(session.id, () => undefined, 'tab')
}
}}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
// ⇧⌘-click → pop into its own window (needs standalone windows).
if (mod && event.shiftKey && canOpenSessionWindow()) {
if (mod && event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
openSession(session.id, () => undefined, 'window')
return
}
@ -195,7 +195,7 @@ export function SidebarSessionRow({
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
openSession(session.id, () => undefined, 'tab')
return
}

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
@ -66,6 +66,7 @@ import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import { openSession, openSessionIntentFromModifiers } from '../open-session'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@ -75,7 +76,6 @@ import {
navigateToWorkspacePage,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE,
STARMAP_ROUTE
@ -99,6 +99,12 @@ interface PaletteItem {
keepOpen?: boolean
keywords?: string[]
label: string
/**
* When set, /-select (or -Enter) opens a new tab and -select pops a
* window matching sidebar session rows. Plain select stays in-place.
* Receives the last selector event so the modifiers can be read.
*/
runWithEvent?: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => void
/** Action to run when selected. Mutually exclusive with `to`. */
run?: () => void
/** Open a nested palette page (VS Code-style "choose X → options"). */
@ -307,6 +313,20 @@ export function CommandPalette() {
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
// cmdk's onSelect doesn't forward the triggering event — keep the last
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
ctrlKey: false,
metaKey: false,
shiftKey: false
})
const noteSelectMods = (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => {
lastSelectMods.current = {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey
}
}
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
@ -357,6 +377,15 @@ export function CommandPalette() {
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
// Sessions: plain select = open in-place (focus existing tile/main, else main);
// ⌘/⌃-select / ⌘-Enter = new tab; ⇧⌘ = own window. Same door as the sidebar.
const goSession = useCallback(
(sessionId: string) => (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
openSession(sessionId, navigate, openSessionIntentFromModifiers(event))
},
[navigate]
)
// Step up one nested page (or back to the root list), clearing the filter so
// the parent page doesn't reopen mid-search.
const goBack = useCallback(() => {
@ -625,7 +654,7 @@ export function CommandPalette() {
id: `goto-${directId}`,
keywords: ['session', 'id', 'go to', directId],
label: `${t.commandCenter.goToSession} ${directId}`,
run: go(sessionRoute(directId))
runWithEvent: goSession(directId)
}
]
})
@ -713,7 +742,7 @@ export function CommandPalette() {
...(session.git_branch ? [session.git_branch] : [])
],
label: session.title,
run: go(sessionRoute(session.id))
runWithEvent: goSession(session.id)
}))
})
}
@ -768,6 +797,7 @@ export function CommandPalette() {
availableThemes,
configFieldLabel,
go,
goSession,
mcpServers,
mode,
resolvedMode,
@ -872,7 +902,14 @@ export function CommandPalette() {
return
}
item.run?.()
if (item.runWithEvent) {
item.runWithEvent(lastSelectMods.current)
} else {
item.run?.()
}
// Clear stashed modifiers so a plain Enter after a ⌘-click isn't sticky.
lastSelectMods.current = { ctrlKey: false, metaKey: false, shiftKey: false }
if (!item.keepOpen) {
closeCommandPalette()
@ -909,6 +946,10 @@ export function CommandPalette() {
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
@ -962,6 +1003,7 @@ export function CommandPalette() {
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onMouseDown={noteSelectMods}
onSelect={() => handleSelect(item)}
value={paletteValue(item)}
>

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { openSession } from '@/app/open-session'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
@ -123,12 +124,16 @@ export function useDesktopIntegrations({
}
}, [resumeExhaustedSessionId])
// Native-notification click -> jump to the session (runtime id translated to
// the stored id the chat route is keyed by); action buttons resolve in place.
// Native-notification click -> jump to the session WHERE IT ALREADY IS (open
// tile / main) instead of forcing main. Runtime id is translated to the
// stored id the chat route is keyed by; action buttons resolve in place.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
openSession(
storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current),
navigate
)
}
})

View file

@ -53,7 +53,6 @@ import {
setBusy,
setMessages
} from '@/store/session'
import { focusedSessionNeedsRoute, focusOpenSession } from '@/store/session-states'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -66,6 +65,7 @@ import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useKeybinds } from '../hooks/use-keybinds'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { openSession } from '../open-session'
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
@ -73,11 +73,9 @@ import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
import {
$workspaceIsPage,
CRON_ROUTE,
navigateToWorkspacePage,
routeSessionId,
sessionRoute,
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
@ -820,15 +818,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onRemoveAttachment: id => void composer.removeAttachment(id),
onRestoreToMessage: restoreToMessage,
// 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, so clicking the ACTIVE session was a no-op and
// the user had to bounce off another row to get back to the chat.
onResumeSession: sessionId => {
if (focusedSessionNeedsRoute(focusOpenSession(sessionId), $workspaceIsPage.get())) {
navigate(sessionRoute(sessionId))
}
},
// otherwise load it into main. Same door every other session link uses.
onResumeSession: sessionId => openSession(sessionId, navigate),
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onSubmit: submitText,
@ -965,7 +956,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
/>
)}
<ModelPickerOverlay gateway={gateway || undefined} onSelect={selectModel} profile={activeGatewayProfile} />
<SessionPickerOverlay onResume={resumeSession} />
<SessionPickerOverlay onResume={sessionId => openSession(sessionId, navigate)} />
<ModelVisibilityOverlay
gateway={gateway || undefined}
onOpenProviders={openProviderSettings}
@ -1007,7 +998,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigateToWorkspacePage(navigate, path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
onOpenSession={sessionId => openSession(sessionId, navigate)}
/>
</Suspense>
)}
@ -1022,7 +1013,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
onOpenSession={sessionId => openSession(sessionId, navigate)}
/>
</Suspense>
)}

View file

@ -53,6 +53,7 @@ import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { openSession } from '../open-session'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@ -103,7 +104,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
const goToSession = (sessionId: null | string) => {
if (sessionId) {
navigate(sessionRoute(sessionId))
openSession(sessionId, navigate)
}
}

View file

@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const focusOpenSession = vi.fn()
const openSessionTile = vi.fn()
const openSessionInNewWindow = vi.fn()
const canOpenSessionWindow = vi.fn(() => true)
const workspaceIsPageGet = vi.fn(() => false)
vi.mock('@/store/session-states', () => ({
focusedSessionNeedsRoute: (focused: 'main' | 'tile' | null, workspaceIsPage: boolean) =>
!focused || (focused === 'main' && workspaceIsPage),
focusOpenSession: (...args: unknown[]) => focusOpenSession(...args),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
}))
vi.mock('@/store/windows', () => ({
canOpenSessionWindow: () => canOpenSessionWindow(),
openSessionInNewWindow: (...args: unknown[]) => openSessionInNewWindow(...args)
}))
vi.mock('./routes', () => ({
$workspaceIsPage: { get: () => workspaceIsPageGet() },
sessionRoute: (id: string) => `/c/${encodeURIComponent(id)}`
}))
import { openSession, openSessionIntentFromModifiers } from './open-session'
describe('openSessionIntentFromModifiers', () => {
it('defaults to in-place', () => {
expect(openSessionIntentFromModifiers()).toBe('in-place')
expect(openSessionIntentFromModifiers(null)).toBe('in-place')
expect(openSessionIntentFromModifiers({})).toBe('in-place')
})
it('reads ⌘/⌃ as tab and ⇧+mod as window', () => {
expect(openSessionIntentFromModifiers({ metaKey: true })).toBe('tab')
expect(openSessionIntentFromModifiers({ ctrlKey: true })).toBe('tab')
expect(openSessionIntentFromModifiers({ metaKey: true, shiftKey: true })).toBe('window')
expect(openSessionIntentFromModifiers({ shiftKey: true })).toBe('in-place')
})
})
describe('openSession', () => {
const navigate = vi.fn()
beforeEach(() => {
navigate.mockClear()
focusOpenSession.mockReset()
openSessionTile.mockReset()
openSessionInNewWindow.mockReset()
canOpenSessionWindow.mockReturnValue(true)
workspaceIsPageGet.mockReturnValue(false)
})
it('in-place focuses an existing tile and does not navigate', () => {
focusOpenSession.mockReturnValue('tile')
openSession('s1', navigate)
expect(focusOpenSession).toHaveBeenCalledWith('s1')
expect(navigate).not.toHaveBeenCalled()
expect(openSessionTile).not.toHaveBeenCalled()
})
it('in-place focuses main when already selected and not on a page', () => {
focusOpenSession.mockReturnValue('main')
openSession('s1', navigate)
expect(navigate).not.toHaveBeenCalled()
})
it('in-place routes when the main session is covered by a page', () => {
focusOpenSession.mockReturnValue('main')
workspaceIsPageGet.mockReturnValue(true)
openSession('s1', navigate)
expect(navigate).toHaveBeenCalledWith('/c/s1')
})
it('in-place routes when the session is not on screen', () => {
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate)
expect(navigate).toHaveBeenCalledWith('/c/s1')
})
it('tab focuses an existing open session instead of stacking another', () => {
focusOpenSession.mockReturnValue('tile')
openSession('s1', navigate, 'tab')
expect(focusOpenSession).toHaveBeenCalledWith('s1')
expect(openSessionTile).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})
it('tab opens a stacked session tile when not on screen', () => {
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'tab')
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
expect(navigate).not.toHaveBeenCalled()
})
it('window pops out when the bridge supports it', () => {
openSession('s1', navigate, 'window')
expect(openSessionInNewWindow).toHaveBeenCalledWith('s1')
expect(openSessionTile).not.toHaveBeenCalled()
})
it('window falls back to a tab when pop-out is unavailable', () => {
canOpenSessionWindow.mockReturnValue(false)
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'window')
expect(openSessionInNewWindow).not.toHaveBeenCalled()
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
})
it('no-ops on an empty id', () => {
openSession('', navigate)
expect(navigate).not.toHaveBeenCalled()
expect(focusOpenSession).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,94 @@
/**
* 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` (click / Enter) focus existing tile/main if on screen; else
* load into main (same as the left sessions sidebar).
* - `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 {
focusedSessionNeedsRoute,
focusOpenSession,
openSessionTile
} from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $workspaceIsPage, sessionRoute } from './routes'
export type OpenSessionIntent = 'in-place' | 'tab' | 'window'
export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void
/** Read modifiers the way session rows do — meta OR ctrl for tab, +shift for window. */
export function openSessionIntentFromModifiers(
event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
): OpenSessionIntent {
if (!event) {
return 'in-place'
}
const mod = Boolean(event.metaKey || event.ctrlKey)
if (mod && event.shiftKey) {
return 'window'
}
if (mod) {
return 'tab'
}
return 'in-place'
}
/**
* @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'
): void {
if (!storedSessionId) {
return
}
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 === '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.
if (focusOpenSession(storedSessionId)) {
return
}
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), $workspaceIsPage.get())) {
navigate(sessionRoute(storedSessionId))
}
}

View file

@ -10,7 +10,7 @@ import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
import { sessionRoute } from './routes'
import { openSession } from './open-session'
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
@ -39,7 +39,7 @@ export function SessionSwitcher() {
const pick = (sessionId: string) => {
closeSwitcher()
navigate(sessionRoute(sessionId))
openSession(sessionId, navigate)
}
return createPortal(

View file

@ -483,10 +483,10 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
)
}
/** Opens the referenced session as a tab same as middle-clicking its sidebar
* row. The tile store loads on click, not at import: the composer's rich
* editor pulls this module in, and a static import would boot the profile
* store (and its REST routing) along with it. */
/** Opens the referenced session the way a sidebar -click would: jump to it if
* it's already a tile/main, otherwise open a stacked tab (never steals main
* from under the chat you're reading). Lazy-imports so the composer's rich
* editor can pull this module in without booting the profile/REST stack. */
function openSessionRef(value: string) {
const { sessionId } = parseSessionRefValue(value)
@ -495,7 +495,8 @@ function openSessionRef(value: string) {
}
triggerHaptic('selection')
void import('@/store/session-states').then(({ openSessionTile }) => openSessionTile(sessionId, 'center'))
// navigate is unused for the `tab` intent (focus-or-tile only).
void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab'))
}
/** A `@session:<profile>/<id>` reference in the user transcript (directive

View file

@ -6,29 +6,28 @@ import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
import { DirectiveContent } from './directive-text'
import { MarkdownTextContent } from './markdown-text'
const openSessionTile = vi.fn()
const openSession = vi.fn()
vi.mock('@/store/session-states', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
vi.mock('@/app/open-session', () => ({
openSession: (...args: unknown[]) => openSession(...args)
}))
afterEach(() => {
cleanup()
openSessionTile.mockClear()
openSession.mockClear()
__resetSessionLinkTitleCache()
})
// Both surfaces render a session ref differently — an inline link in agent
// prose, a chip in the user's own message — but either one opens the session
// it names, the way its sidebar row would.
// it names via the shared door (focus if on screen, else a stacked tab).
describe('session refs open the session', () => {
it('opens the session from an agent-written link', async () => {
render(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
fireEvent.click(await screen.findByTitle('work/20260101_abc123'))
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
it('opens the session from a chip in the user transcript', async () => {
@ -39,6 +38,6 @@ describe('session refs open the session', () => {
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
})