From 98c294126bafb2ba5660a2dc817c97e7dc8c95bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 20:59:23 -0500 Subject: [PATCH 1/3] feat(desktop): open new sessions in compact windows Add the Electron IPC bridge and rebindable shortcut for opening an unkeyed scratch window on the new-session draft. --- apps/desktop/electron/main.cjs | 127 ++++++++++-------- apps/desktop/electron/preload.cjs | 1 + apps/desktop/electron/session-windows.cjs | 13 +- .../desktop/electron/session-windows.test.cjs | 6 + apps/desktop/src/app/hooks/use-keybinds.ts | 2 + apps/desktop/src/global.d.ts | 2 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/keybinds/actions.ts | 1 + apps/desktop/src/store/windows.test.ts | 46 ++++++- apps/desktop/src/store/windows.ts | 56 ++++++-- 11 files changed, 182 insertions(+), 74 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 98b32f0532c..101cd0801f7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5083,65 +5083,75 @@ function focusWindow(win) { win.focus() } +function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { + const icon = getAppIconPath() + const win = new BrowserWindow({ + width: SESSION_WINDOW_MIN_WIDTH, + height: SESSION_WINDOW_MIN_HEIGHT, + minWidth: SESSION_WINDOW_MIN_WIDTH, + minHeight: SESSION_WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), + icon, + // Don't show until the renderer's first themed paint is ready. macOS + // `vibrancy` ignores `backgroundColor` and paints a translucent OS + // material (which follows the OS appearance, not the app theme), so a + // dark-themed app on a light-mode Mac flashes white until the renderer + // covers it. ready-to-show fires after the boot-time paint in + // themes/context.tsx, so the window appears already themed. + show: false, + backgroundColor: getWindowBackgroundColor(), + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + webviewTag: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.show() + }) + + win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) + win.on('enter-full-screen', () => sendWindowStateChanged(true)) + win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) + win.on('leave-full-screen', () => sendWindowStateChanged(false)) + + wireCommonWindowHandlers(win) + + win.loadURL( + buildSessionWindowUrl(sessionId, { + devServer: DEV_SERVER, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), + watch, + newSession + }) + ) + + return win +} + // Open (or focus) a standalone window for a single chat session. function createSessionWindow(sessionId, { watch = false } = {}) { - return sessionWindows.openOrFocus(sessionId, () => { - const icon = getAppIconPath() - const win = new BrowserWindow({ - width: SESSION_WINDOW_MIN_WIDTH, - height: SESSION_WINDOW_MIN_HEIGHT, - minWidth: SESSION_WINDOW_MIN_WIDTH, - minHeight: SESSION_WINDOW_MIN_HEIGHT, - title: 'Hermes', - titleBarStyle: 'hidden', - titleBarOverlay: getTitleBarOverlayOptions(), - trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, - vibrancy: IS_MAC ? 'sidebar' : undefined, - opacity: windowOpacity(), - icon, - // Don't show until the renderer's first themed paint is ready. macOS - // `vibrancy` ignores `backgroundColor` and paints a translucent OS - // material (which follows the OS appearance, not the app theme), so a - // dark-themed app on a light-mode Mac flashes white until the renderer - // covers it. ready-to-show fires after the boot-time paint in - // themes/context.tsx, so the window appears already themed. - show: false, - backgroundColor: getWindowBackgroundColor(), - webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), - contextIsolation: true, - webviewTag: true, - sandbox: true, - nodeIntegration: false, - devTools: true - } - }) + return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) +} - if (IS_MAC) { - win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) - } - - win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() - }) - - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) - win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) - win.on('leave-full-screen', () => sendWindowStateChanged(false)) - - wireCommonWindowHandlers(win) - - win.loadURL( - buildSessionWindowUrl(sessionId, { - devServer: DEV_SERVER, - rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), - watch - }) - ) - - return win - }) +// Open a fresh compact window on the new-session draft (#/). Not registry-keyed: +// like ⌘N in a browser, every press opens a new window — and a draft window that +// later converts to a real session must not get refocused as if it were blank. +function createNewSessionWindow() { + return spawnSecondaryWindow({ newSession: true }) } function createWindow() { @@ -5328,6 +5338,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: true } }) +ipcMain.handle('hermes:window:openNewSession', async () => { + createNewSessionWindow() + + return { ok: true } +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 544037e7869..413abd77b32 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), + openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 172ca16c757..929bf3ea9ae 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // flag MUST sit in the query string BEFORE the '#': anything after the '#' is // treated as the route by HashRouter and would break routeSessionId(). The // renderer reads the flag from window.location.search to suppress the install / -// onboarding overlays and the global session sidebar. `watch=1` marks a -// spectator window (e.g. a running subagent's session): the renderer resumes -// it lazily so the gateway never builds an agent just to stream into it. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) { - const query = `?win=secondary${watch ? '&watch=1' : ''}` - const route = `#/${encodeURIComponent(sessionId)}` +// onboarding overlays and the global session sidebar. `new=1` marks the compact +// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's +// session): the renderer resumes it lazily so the gateway never builds an agent +// just to stream into it. +function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { + const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` + const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index a668b0ac082..8261809dbf3 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') }) +test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => { + const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true }) + + assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/') +}) + test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 1f02dccfec3..891c834c520 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -37,6 +37,7 @@ import { switcherActive, switcherJustClosed } from '@/store/session-switcher' +import { openNewSessionInNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus } from '../chat/composer/focus' @@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, + 'session.newWindow': () => void openNewSessionInNewWindow(), 'session.next': () => stepSession(1), 'session.prev': () => stepSession(-1), ...sessionSlotHandlers, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 8c20dcffaac..c615ad2d61a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -24,6 +24,8 @@ declare global { // a spectator window (lazy resume — no agent build) for live-streaming // a running subagent's session. openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> + // Open (or focus) a compact secondary window on the new-session draft. + openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7ac3ee35ad5..44c738da1b3 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -189,6 +189,7 @@ export const en: Translations = { 'nav.cron': 'Open scheduled jobs', 'nav.agents': 'Open agents', 'session.new': 'New session', + 'session.newWindow': 'New session in window', 'session.next': 'Next session', 'session.prev': 'Previous session', 'session.slot.1': 'Switch to recent session 1', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 7fac54c0f7d..2f3d22230a2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -185,6 +185,7 @@ export const zh: Translations = { 'nav.cron': '打开定时任务', 'nav.agents': '打开智能体', 'session.new': '新建会话', + 'session.newWindow': '在新窗口中新建会话', 'session.next': '下一个会话', 'session.prev': '上一个会话', 'session.slot.1': '切换到最近会话 1', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 7c4a83f61aa..38eab936f09 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Session ────────────────────────────────────────────────────────────── { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] }, + { id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] }, // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts. { id: 'session.next', category: 'session', defaults: ['ctrl+tab'] }, diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 50c42dbf3af..28ae3cc39c9 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { canOpenSessionWindow, openSessionInNewWindow } from './windows' +import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop @@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({ notifyError: (...args: unknown[]) => notifyError(...args) })) -function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) { +function installBridge( + openSessionWindow?: Window['hermesDesktop']['openSessionWindow'], + openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow'] +) { desktopWindow.hermesDesktop = { - ...(openSessionWindow ? { openSessionWindow } : {}) + ...(openSessionWindow ? { openSessionWindow } : {}), + ...(openNewSessionWindow ? { openNewSessionWindow } : {}) } as unknown as Window['hermesDesktop'] } @@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => { expect(notifyError).toHaveBeenCalledTimes(1) }) }) + +describe('openNewSessionInNewWindow', () => { + it('no-ops gracefully when the bridge is absent (web fallback)', async () => { + delete desktopWindow.hermesDesktop + + await openNewSessionInNewWindow() + + expect(notifyError).not.toHaveBeenCalled() + }) + + it('no-ops when openNewSessionWindow is missing', async () => { + installBridge(vi.fn().mockResolvedValue({ ok: true })) + + await openNewSessionInNewWindow() + + expect(notifyError).not.toHaveBeenCalled() + }) + + it('invokes the bridge', async () => { + const openNew = vi.fn().mockResolvedValue({ ok: true }) + installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew) + + await openNewSessionInNewWindow() + + expect(openNew).toHaveBeenCalledTimes(1) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('notifies on an ok:false result', async () => { + installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' })) + + await openNewSessionInNewWindow() + + expect(notifyError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 461c6343823..c5b36ca6855 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -6,6 +6,7 @@ import { notifyError } from './notifications' // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. const SECONDARY_WINDOW_FLAG = 'secondary' +const NEW_SESSION_WINDOW_FLAG = '1' let secondaryWindowCache: boolean | null = null @@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean { return result } +let newSessionWindowCache: boolean | null = null + +export function isNewSessionWindow(): boolean { + if (newSessionWindowCache !== null) { + return newSessionWindowCache + } + + let result = false + + try { + result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG + } catch { + result = false + } + + newSessionWindowCache = result + + return result +} + let watchWindowCache: boolean | null = null // A "watch" window spectates a session that is being driven elsewhere (a @@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean { return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function' } +type WindowOpenResult = { ok: boolean; error?: string } | undefined + +// Run a window-open bridge call, surfacing any failure as a toast. Shared by the +// session pop-out and the new-session pop-out. +async function openWindow(call: () => Promise, failMessage: string): Promise { + try { + const result = await call() + + if (!result?.ok) { + notifyError(new Error(result?.error || 'unknown error'), failMessage) + } + } catch (err) { + notifyError(err, failMessage) + } +} + // Open (or focus) a standalone OS window for a single chat session. No-ops // gracefully outside Electron so callers can wire it unconditionally. // `watch: true` opens a spectator window (lazy resume, live-mirror stream). @@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: return } - try { - const result = await window.hermesDesktop.openSessionWindow(sessionId, opts) - - if (!result?.ok) { - notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window') - } - } catch (err) { - notifyError(err, 'Could not open chat in a new window') - } + await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window') +} + +// Open a fresh compact window on the new-session draft. +export async function openNewSessionInNewWindow(): Promise { + if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') { + return + } + + await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window') } From 0f75e9904a8f9838a706a0cf84b01bc4576b368a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 20:59:34 -0500 Subject: [PATCH 2/3] feat(desktop): trim scratch window chrome Hide nonessential Hermes chrome in the new-session pop-out while preserving native window controls and stable first-message positioning. --- apps/desktop/src/app/chat/index.tsx | 8 ++++++-- apps/desktop/src/app/shell/app-shell.tsx | 11 +++++++--- .../components/assistant-ui/thread-list.tsx | 20 +++++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index f890a5bfe6c..c9f525653e7 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -42,6 +42,7 @@ import { $sessions, sessionPinId } from '@/store/session' +import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -122,7 +123,7 @@ function ChatHeader({ // A brand-new session has no session to pin/delete/rename, so the header is // just a dead "New session" label + chevron. Drop it (and its border) // entirely until there's a real session to act on. - if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) { + if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { return null } @@ -302,7 +303,10 @@ export function ChatView({ // waiting for the resume effect (which paints a frame later) to clear them. const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId - const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty + // The compact new-session pop-out skips the wordmark/tagline intro — it's a + // scratch window, not the full-height empty state. + const showIntro = + !isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty // Session is still loading if the route references a session we haven't // resumed yet. Once `activeSessionId` is set (runtime has resumed), the diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 8e548734496..ade1f8a3c3c 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -16,7 +16,7 @@ import { } from '@/store/layout' import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' +import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' @@ -80,6 +80,7 @@ export function AppShell({ const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen + const hideTitlebarControls = isNewSessionWindow() const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero // on macOS, where window controls sit on the left and are reported via @@ -162,7 +163,9 @@ export function AppShell({ } as CSSProperties } > - + {!hideTitlebarControls && ( + + )}
@@ -183,7 +186,9 @@ export function AppShell({ the panes' z-20 resize handles, keeping every pane resizable. */} {mainOverlays} - + {/* The compact pop-out drops the statusbar — it's a scratch window, not + the full shell. */} + {!isSecondaryWindow() && }
{overlays} diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx index 397ed2aa9bb..0a4be83961a 100644 --- a/apps/desktop/src/components/assistant-ui/thread-list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx @@ -1,5 +1,6 @@ import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' import { + type CSSProperties, type ComponentProps, type FC, memo, @@ -21,6 +22,7 @@ import { resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll' +import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { MessageRenderBoundary } from './message-render-boundary' @@ -132,6 +134,13 @@ const ThreadMessageListInner: FC = ({ const hiddenCount = firstVisible const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef(null) + const newSessionWindow = isNewSessionWindow() + const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)' + const threadContentTopPad = newSessionWindow + ? 'pt-[calc(var(--titlebar-height)+0.75rem)]' + : isSecondaryWindow() + ? 'pt-6' + : 'pt-[calc(var(--titlebar-height)+1.5rem)]' useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => () => resetThreadScroll(), []) @@ -235,7 +244,12 @@ const ThreadMessageListInner: FC = ({ return (
= ({
) : (
} > From 67233d1c2ad5e6770eec6684fa32cba3ce7c0f60 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 20:59:39 -0500 Subject: [PATCH 3/3] fix(desktop): sync new sessions across windows Broadcast session-list mutations from scratch windows so the main sidebar refreshes without manual reloads. --- apps/desktop/src/app/desktop-controller.tsx | 12 +++++++++ .../app/session/hooks/use-message-stream.ts | 4 +++ .../app/session/hooks/use-session-actions.ts | 4 +++ apps/desktop/src/store/session-sync.ts | 25 +++++++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 apps/desktop/src/store/session-sync.ts diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index f585cfec579..5ff162a2ca4 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -77,6 +77,7 @@ import { setSessionsLoading, setSessionsTotal } from '../store/session' +import { onSessionsChanged } from '../store/session-sync' import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' import { isSecondaryWindow } from '../store/windows' @@ -464,6 +465,17 @@ export function DesktopController() { void refreshSessions() }, [refreshSessions]) + // Another window mutated the shared session list (e.g. a chat started in the + // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so + // only real windows bother. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) + }, [refreshSessions]) + // ALL-profiles view pages one profile at a time: fetch that profile's next // page and merge it in place, leaving every other profile's rows untouched. const loadMoreSessionsForProfile = useCallback(async (profile: string) => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index c4718b88ac3..c07222c6890 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -47,6 +47,7 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' +import { broadcastSessionsChanged } from '@/store/session-sync' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' @@ -641,6 +642,9 @@ export function useMessageStream({ }) void refreshSessions().catch(() => undefined) + // Sync the freshly-titled row to other windows (e.g. main, when the turn + // ran in the pop-out). + broadcastSessionsChanged() if (compactedTurnRef.current.delete(sessionId)) { shouldHydrate = false diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 9ce2ff1a8ff..50b6bb0d270 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -42,6 +42,7 @@ import { setYoloActive, workspaceCwdForNewSession } from '@/store/session' +import { broadcastSessionsChanged } from '@/store/session-sync' import { reportBackendContract } from '@/store/updates' import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes' @@ -472,6 +473,9 @@ export function useSessionActions({ // server later returns its own preview/title and supersedes this. upsertOptimisticSession(created, stored, null, preview?.trim() || null) navigate(sessionRoute(stored), { replace: true }) + // Other windows (e.g. the main window when this is the pop-out) can't + // see this session until they re-pull the shared list. + broadcastSessionsChanged() } setFreshDraftReady(false) diff --git a/apps/desktop/src/store/session-sync.ts b/apps/desktop/src/store/session-sync.ts new file mode 100644 index 00000000000..66bf6d08202 --- /dev/null +++ b/apps/desktop/src/store/session-sync.ts @@ -0,0 +1,25 @@ +// Cross-window session-list sync. Each desktop window is its own renderer +// process with its own gateway socket and session store, so a mutation in one +// (e.g. a new chat started in the compact pop-out) never reaches another +// window. This bus pings every window to re-pull the shared session list; the +// data already lives in the backend, the other window just doesn't know to look. +const CHANNEL = 'hermes:sessions' + +const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL) + +// A window that mutated the session list (created / titled a chat) tells the +// others to refresh. A BroadcastChannel never delivers to its own poster, so the +// caller refreshes locally as it already does. +export function broadcastSessionsChanged(): void { + channel?.postMessage(1) +} + +export function onSessionsChanged(handler: () => void): () => void { + if (!channel) { + return () => {} + } + + channel.addEventListener('message', handler) + + return () => channel.removeEventListener('message', handler) +}