From e67e4604dba174e1dd150ed3a36d4840dd597c87 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 01:07:40 -0500 Subject: [PATCH 1/3] desktop: shared openSession door for focus-or-route Centralize open-in-place / tab / window so every surface can jump to an already-open tile or main tab instead of forcing main. --- apps/desktop/src/app/open-session.test.ts | 116 ++++++++++++++++++++++ apps/desktop/src/app/open-session.ts | 94 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 apps/desktop/src/app/open-session.test.ts create mode 100644 apps/desktop/src/app/open-session.ts diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts new file mode 100644 index 00000000000..5345cd8b24b --- /dev/null +++ b/apps/desktop/src/app/open-session.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts new file mode 100644 index 00000000000..a9781b63535 --- /dev/null +++ b/apps/desktop/src/app/open-session.ts @@ -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)) + } +} From 319bcedfeae9119f559be2b7118d21015c84b072 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 01:07:40 -0500 Subject: [PATCH 2/3] desktop: open sessions where they already are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications, ⌘K, switcher, session picker, cron/command-center, artifacts, and @session refs all go through openSession. Plain click/Enter focuses the existing tile or main; ⌘-click/⌘-Enter opens a new tab; ⇧⌘ still pops a window. --- apps/desktop/src/app/artifacts/index.tsx | 6 +-- .../app/chat/sidebar/session-actions-menu.tsx | 12 +++-- .../src/app/chat/sidebar/session-row.tsx | 12 ++--- .../desktop/src/app/command-palette/index.tsx | 52 +++++++++++++++++-- .../contrib/hooks/use-desktop-integrations.ts | 11 ++-- apps/desktop/src/app/contrib/wiring.tsx | 21 +++----- apps/desktop/src/app/hooks/use-keybinds.ts | 3 +- apps/desktop/src/app/session-switcher.tsx | 4 +- .../assistant-ui/directive-text.tsx | 11 ++-- .../assistant-ui/session-ref-open.test.tsx | 15 +++--- 10 files changed, 94 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index bdcf5d103e2..6660b15f595 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -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)} /> ))} diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index fa4c453ea63..73f741a9051 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -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') } }) ] diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index c2c80200473..a720c869d70 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -3,6 +3,7 @@ import type * as React from 'react' import { ProfileTag } from '@/app/chat/profile-tag' import { startSessionDrag } from '@/app/chat/session-drag' +import { openSession } from '@/app/open-session' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -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 } diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 50c28148cab..43b5e0b733f 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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(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() { { + // 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)} > diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index ed22e2cc7eb..315b53530b9 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -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 + ) } }) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 4f957f88ea1..17fec6d2677 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 }) { /> )} - + openSession(sessionId, navigate)} /> navigateToWorkspacePage(navigate, path)} - onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + onOpenSession={sessionId => openSession(sessionId, navigate)} /> )} @@ -1022,7 +1013,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { navigate(sessionRoute(sessionId))} + onOpenSession={sessionId => openSession(sessionId, navigate)} /> )} diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 66d74e9411f..e1f73b03344 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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) } } diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx index e7b2411e78f..6bf2fc4f2a3 100644 --- a/apps/desktop/src/app/session-switcher.tsx +++ b/apps/desktop/src/app/session-switcher.tsx @@ -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( diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index b8e8ce66eca..94edc90963d 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -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:/` reference in the user transcript (directive diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx index 1dad2f730eb..d8fe7136aac 100644 --- a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -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>()), - 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() 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')) }) }) From b6c7df6cb695e3c4fb5267b9be3e08f601f7c2b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 01:13:29 -0500 Subject: [PATCH 3/3] fix(desktop): sort session-row imports for eslint --- apps/desktop/src/app/chat/sidebar/session-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index a720c869d70..051f8893d7a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -3,8 +3,8 @@ import type * as React from 'react' import { ProfileTag } from '@/app/chat/profile-tag' import { startSessionDrag } from '@/app/chat/session-drag' -import { openSession } from '@/app/open-session' 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'