diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts index 04de2bdc7a..75865a8289 100644 --- a/apps/desktop/electron/connection-apply.ts +++ b/apps/desktop/electron/connection-apply.ts @@ -61,10 +61,4 @@ async function resolveTerminalConnectionForSender(webContentsId, getTarget, ensu ) } - -export { - applyConnectionChange, - commitConnectionFailure, - resolveTerminalConnection, - resolveTerminalConnectionForSender -} +export { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection, resolveTerminalConnectionForSender } diff --git a/apps/desktop/electron/desktop-remote-route.test.ts b/apps/desktop/electron/desktop-remote-route.test.ts index ce02163417..f68b10d3d6 100644 --- a/apps/desktop/electron/desktop-remote-route.test.ts +++ b/apps/desktop/electron/desktop-remote-route.test.ts @@ -232,7 +232,6 @@ test('URL route fails closed for different token, headers, kind, or Cloud org', } }) - test('profile remote wins over a registry-backed global SSH route', () => { const route = resolveDesktopRemoteRoute({ config: { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c9dd763765..8e77be07d8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -356,10 +356,7 @@ import { import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below' -import { - registrySshScopeForWindowRoute, - WindowConnectionRouteRegistry -} from './window-connection-route' +import { registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route' import { installWindowRendererLifecycle } from './window-renderer-lifecycle' import { createWindowRevealController } from './window-reveal' import { @@ -9522,14 +9519,10 @@ async function teardownSshConnection(profile) { // SSH connection — so if the active profile resolves to a NON-SSH backend, the // terminal must NOT fall through to a global SSH host. function activeSshTerminalTarget(webContentsId?: number) { - const windowRoute = - typeof webContentsId === 'number' ? windowConnectionRoutes.get(webContentsId) : null + const windowRoute = typeof webContentsId === 'number' ? windowConnectionRoutes.get(webContentsId) : null if (windowRoute?.registryScoped && windowRoute.connectionId) { - const scope = registrySshScopeForWindowRoute( - windowRoute, - readDesktopConnectionsRegistry() - ) + const scope = registrySshScopeForWindowRoute(windowRoute, readDesktopConnectionsRegistry()) if (!scope) { return null @@ -9579,10 +9572,7 @@ async function ensureTerminalBackend(webContentsId: number) { // Loopback reach for the browser pane. Scoped to the SSH connection that // authorized it: a different host (or none) must never inherit live forwards // into somebody else's machine. -const previewReachByWebContents = new Map< - number, - { registry: PreviewReachRegistry; scope: string } ->() +const previewReachByWebContents = new Map() async function resetPreviewReach(webContentsId?: number) { if (typeof webContentsId === 'number') { @@ -15300,9 +15290,7 @@ ipcMain.handle('hermes:stop-find-in-page', event => { // The renderer can't know whether a loopback URL is reachable — only main // knows which transport backs this gateway. Ask before loading one. -ipcMain.handle('hermes:preview:reach', async (event, url) => - reachablePreviewUrl(event.sender.id, String(url || '')) -) +ipcMain.handle('hermes:preview:reach', async (event, url) => reachablePreviewUrl(event.sender.id, String(url || ''))) ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => { if (!(await openPreviewInBrowser(url))) { diff --git a/apps/desktop/electron/terminal-ipc.test.ts b/apps/desktop/electron/terminal-ipc.test.ts index 6b80d9ae06..3973b3e8c2 100644 --- a/apps/desktop/electron/terminal-ipc.test.ts +++ b/apps/desktop/electron/terminal-ipc.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { - resolveTerminalConnection, - resolveTerminalConnectionForSender -} from './connection-apply' +import { resolveTerminalConnection, resolveTerminalConnectionForSender } from './connection-apply' const ssh = { host: 'registry-box.test', @@ -49,6 +46,7 @@ test('terminal start re-reads the SSH target after backend startup', async () => ssh, scope: 'connection:registry-ssh' } + let ready = false const resolved = await resolveTerminalConnection( diff --git a/apps/desktop/electron/terminal-ipc.ts b/apps/desktop/electron/terminal-ipc.ts index 6cf91cfd08..0028cf45c6 100644 --- a/apps/desktop/electron/terminal-ipc.ts +++ b/apps/desktop/electron/terminal-ipc.ts @@ -292,11 +292,8 @@ export function registerTerminalIpc({ const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - const sshTarget = await resolveTerminalConnectionForSender( - event.sender.id, - activeSshTerminalTarget, - ensureBackend - ) + const sshTarget = await resolveTerminalConnectionForSender(event.sender.id, activeSshTerminalTarget, ensureBackend) + const remote = Boolean(sshTarget) const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null diff --git a/apps/desktop/electron/window-connection-route.test.ts b/apps/desktop/electron/window-connection-route.test.ts index b0f9f7da70..17a4a488f4 100644 --- a/apps/desktop/electron/window-connection-route.test.ts +++ b/apps/desktop/electron/window-connection-route.test.ts @@ -95,10 +95,7 @@ test('routes a non-primary SSH connection independently from another window', () registryScoped: true }) - assert.equal( - registrySshScopeForWindowRoute(routes.get(11), registry), - 'conn:source-b::worker' - ) + assert.equal(registrySshScopeForWindowRoute(routes.get(11), registry), 'conn:source-b::worker') assert.equal(registrySshScopeForWindowRoute(routes.get(22), registry), null) }) @@ -123,4 +120,3 @@ test('uses the canonical default profile scope when a registry SSH route has no 'conn:source-b::default' ) }) - diff --git a/apps/desktop/electron/window-connection-route.ts b/apps/desktop/electron/window-connection-route.ts index 9c55e5af1d..7c0a83cee5 100644 --- a/apps/desktop/electron/window-connection-route.ts +++ b/apps/desktop/electron/window-connection-route.ts @@ -13,10 +13,8 @@ export function normalizeWindowConnectionRoute(value: unknown): WindowConnection const input = value as Record const connectionId = typeof input.connectionId === 'string' ? input.connectionId.trim() : '' - const profile = - typeof input.profile === 'string' && input.profile.trim() - ? input.profile.trim() - : undefined + + const profile = typeof input.profile === 'string' && input.profile.trim() ? input.profile.trim() : undefined return { connectionId: connectionId || null, @@ -50,10 +48,12 @@ export class WindowConnectionRouteRegistry { if (!route) { this.routes.delete(webContentsId) + return null } this.routes.set(webContentsId, route) + return route } diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index eed4329893..0241b7f8b8 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -580,8 +580,7 @@ export function useGatewayBoot({ bootCompleted = true } catch (err) { const mayPublishFailure = - !cancelled && - (switchToken === null ? !$gatewaySwitching.get() : isCurrentGatewaySwitch(switchToken)) + !cancelled && (switchToken === null ? !$gatewaySwitching.get() : isCurrentGatewaySwitch(switchToken)) if (mayPublishFailure) { const message = err instanceof Error ? err.message : String(err) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index 03e4891d3e..ac6f24b016 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -390,13 +390,13 @@ describe('useRouteResume', () => { expect(resumeSession).toHaveBeenCalledWith('session-2', true) }) - it("does not re-resume the old session when the new profile gateway opens before /new commits (#68594)", () => { + it('does not re-resume the old session when the new profile gateway opens before /new commits (#68594)', () => { const resumeSession = vi.fn(async () => undefined) const startFreshSessionDraft = vi.fn() - const activeSessionIdRef: MutableRefObject = { current: "runtime-a" } + const activeSessionIdRef: MutableRefObject = { current: 'runtime-a' } const creatingSessionRef = { current: false } - const runtimeIdByStoredSessionIdRef = { current: new Map([["session-a", "runtime-a"]]) } - const selectedStoredSessionIdRef: MutableRefObject = { current: "session-a" } + const runtimeIdByStoredSessionIdRef = { current: new Map([['session-a', 'runtime-a']]) } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-a' } const { rerender } = render( { expect($resumeExhaustedSessionId.get()).toBeNull() }) }) - diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index a20997708c..8924d8ed9a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -23,7 +23,13 @@ import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/ import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' import { $pinnedSessionIds } from '@/store/layout' import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayProfile } from '@/store/profile' -import { $projectScope, $projectTree, $removedSessionIds, $sessionMutationsInFlight, ALL_PROJECTS } from '@/store/projects' +import { + $projectScope, + $projectTree, + $removedSessionIds, + $sessionMutationsInFlight, + ALL_PROJECTS +} from '@/store/projects' import { $activeSessionId, $activeSessionStoredIdRotation, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index e39d74c3de..7b1da93549 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -817,7 +817,8 @@ export function useSessionActions({ // gateway can be untagged, so retain the captured ambient connection too. // Either way, route by the composite (connection, profile), never by a // same-named profile alone. - const sessionOwner: SessionOwnerScope = ownerRoute || + const sessionOwner: SessionOwnerScope = + ownerRoute || (resolvedConnectionId ? { connectionId: resolvedConnectionId, @@ -2094,6 +2095,7 @@ export function useSessionActions({ const archived = listed?.session const stampedProfile = archived?.profile?.trim() const profile = stampedProfile || (await resolveSessionProfile(storedSessionId)) + if ( listed && !stampedProfile && @@ -2101,8 +2103,10 @@ export function useSessionActions({ $profiles.get().filter(item => item.name.trim()).length > 1 ) { notifyError(new Error('Session ownership could not be resolved'), copy.archiveFailed) + return } + const wasSelected = selectedStoredSessionId === storedSessionId const previousPinned = $pinnedSessionIds.get() // Pins are keyed on the durable lineage-root id; the stored id may be the diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index f396446417..255e85189b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -224,128 +224,131 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg }, [profileScope]) /** Refresh every sidebar session slice without committing an obsolete profile response. */ - const refreshSessions = useCallback(async (shouldPublish: () => boolean = () => true) => { - const sessionProfile = sidebarProfileForScope(profileScope) - const activationEpoch = gatewayActivationEpoch() + const refreshSessions = useCallback( + async (shouldPublish: () => boolean = () => true) => { + const sessionProfile = sidebarProfileForScope(profileScope) + const activationEpoch = gatewayActivationEpoch() - if (!shouldPublish() || sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { - return - } - - const requestId = refreshSessionsRequestRef.current + 1 - refreshSessionsRequestRef.current = requestId - // The loading flag exists to drive the initial skeletons (they only render - // while the list is empty). Turn-complete / reconnect refreshes over a - // populated list used to flip it true→false anyway, churning every - // $sessionsLoading subscriber twice per turn for no visible change. - const showLoading = $sessions.get().length === 0 - - if (showLoading && shouldPublish()) { - setSessionsLoading(true) - } - - try { - const limit = $sessionsLimit.get() - - // Require at least one message so abandoned/empty "Untitled" drafts (one - // was created per TUI/desktop launch before the lazy-create fix) don't - // clutter the sidebar. - // Unified cross-profile list (served read-only off each profile's - // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". - // Scope every sidebar slice to the active profile (not always 'all') so a profile - // with few recent sessions isn't windowed out of the cross-profile - // recency page and never inherits another profile's cron or messaging - // sections. ALL_PROFILES remains the explicit unified view. - // Batched: one request opens each profile DB once and returns all three - // source-scoped slices, instead of three separate listAllProfileSessions - // calls that each reopened + re-counted every profile DB per refresh. - const result = await listSidebarSessions({ - recentsProfile: sessionProfile, - recentsLimit: limit, - recentsExclude: SIDEBAR_EXCLUDED_SOURCES, - cronLimit: CRON_SECTION_LIMIT, - messagingLimit: MESSAGING_SECTION_LIMIT, - messagingExclude: MESSAGING_EXCLUDED_SOURCES - }) - - if ( - shouldPublish() && - refreshSessionsRequestRef.current === requestId && - sidebarProfileForScope(profileScopeRef.current) === sessionProfile && - gatewayActivationEpoch() === activationEpoch - ) { - const recents = result.recents - - // Drop rows the user just deleted/archived: a refresh can race an - // in-flight mutation and the backend page still carries the doomed row. - // Honoring the optimistic tombstone keeps the removal from flashing back - // (the tombstone self-clears once projects.tree confirms the delete). - const incoming = dropTombstoned(recents.sessions) - - // Signature-gate the swap (same pattern as cron/messaging): a refresh - // that returns content-identical rows must keep the previous array - // identity, or every sidebar memo keyed on $sessions recomputes and the - // whole list re-renders once per turn/broadcast for nothing. - setSessions(prev => { - const next = mergeSessionPage(prev, incoming, sessionsToKeep()) - - return sameCronSignature(prev, next) ? prev : next - }) - // "Is there another page?" instead of an exact total: the backend - // reports which profiles filled their window, which costs nothing on - // top of the rows it already read (the old exact totals ran a COUNT(*) - // per profile DB on every refresh). Reference-stable when unchanged so - // the sidebar's group memos don't recompute per refresh. - setSessionProfilesTruncated(prev => { - const next = recents.profiles_truncated ?? {} - const prevKeys = Object.keys(prev) - - return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key]) - ? prev - : next - }) - // Same identity gate: these totals only move when a session bills, and - // a fresh object every refresh would repaint every profile header. - setSessionProfilesUsage(prev => { - const next = recents.profiles_usage ?? {} - const prevKeys = Object.keys(prev) - - return prevKeys.length === Object.keys(next).length && - prevKeys.every( - key => prev[key]?.tokens === next[key]?.tokens && prev[key]?.cost_usd === next[key]?.cost_usd - ) - ? prev - : next - }) - - // Cron section: latest N cron sessions (kept so a pinned cron run still - // resolves via sessionByAnyId), signature-gated like above. - setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions)) - - // Messaging sections: drop any non-messaging source the broad exclude - // didn't catch (custom sources stay in local recents), then split per - // platform in the UI. - const messagingRows = dropTombstoned(result.messaging.sessions.filter(s => isMessagingSource(s.source))) - - setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows)) - // Hit the cap → at least one platform may have more on disk than loaded. - setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) + if (!shouldPublish() || sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { + return } - } finally { - // Request identity preserves the zero-argument refresh contract across a - // failed activation epoch; an explicit owner predicate is stronger and - // must never release a newer switch's loading barrier. - if (showLoading && shouldPublish() && refreshSessionsRequestRef.current === requestId) { - setSessionsLoading(false) - } - } - // Cron *jobs* are a distinct API (getCronJobs), not a session slice. - if (shouldPublish() && sidebarProfileForScope(profileScopeRef.current) === sessionProfile) { - void refreshCronJobs() - } - }, [profileScope, refreshCronJobs]) + const requestId = refreshSessionsRequestRef.current + 1 + refreshSessionsRequestRef.current = requestId + // The loading flag exists to drive the initial skeletons (they only render + // while the list is empty). Turn-complete / reconnect refreshes over a + // populated list used to flip it true→false anyway, churning every + // $sessionsLoading subscriber twice per turn for no visible change. + const showLoading = $sessions.get().length === 0 + + if (showLoading && shouldPublish()) { + setSessionsLoading(true) + } + + try { + const limit = $sessionsLimit.get() + + // Require at least one message so abandoned/empty "Untitled" drafts (one + // was created per TUI/desktop launch before the lazy-create fix) don't + // clutter the sidebar. + // Unified cross-profile list (served read-only off each profile's + // state.db; no per-profile backend is spawned). Single-profile users get + // the same rows tagged profile="default". + // Scope every sidebar slice to the active profile (not always 'all') so a profile + // with few recent sessions isn't windowed out of the cross-profile + // recency page and never inherits another profile's cron or messaging + // sections. ALL_PROFILES remains the explicit unified view. + // Batched: one request opens each profile DB once and returns all three + // source-scoped slices, instead of three separate listAllProfileSessions + // calls that each reopened + re-counted every profile DB per refresh. + const result = await listSidebarSessions({ + recentsProfile: sessionProfile, + recentsLimit: limit, + recentsExclude: SIDEBAR_EXCLUDED_SOURCES, + cronLimit: CRON_SECTION_LIMIT, + messagingLimit: MESSAGING_SECTION_LIMIT, + messagingExclude: MESSAGING_EXCLUDED_SOURCES + }) + + if ( + shouldPublish() && + refreshSessionsRequestRef.current === requestId && + sidebarProfileForScope(profileScopeRef.current) === sessionProfile && + gatewayActivationEpoch() === activationEpoch + ) { + const recents = result.recents + + // Drop rows the user just deleted/archived: a refresh can race an + // in-flight mutation and the backend page still carries the doomed row. + // Honoring the optimistic tombstone keeps the removal from flashing back + // (the tombstone self-clears once projects.tree confirms the delete). + const incoming = dropTombstoned(recents.sessions) + + // Signature-gate the swap (same pattern as cron/messaging): a refresh + // that returns content-identical rows must keep the previous array + // identity, or every sidebar memo keyed on $sessions recomputes and the + // whole list re-renders once per turn/broadcast for nothing. + setSessions(prev => { + const next = mergeSessionPage(prev, incoming, sessionsToKeep()) + + return sameCronSignature(prev, next) ? prev : next + }) + // "Is there another page?" instead of an exact total: the backend + // reports which profiles filled their window, which costs nothing on + // top of the rows it already read (the old exact totals ran a COUNT(*) + // per profile DB on every refresh). Reference-stable when unchanged so + // the sidebar's group memos don't recompute per refresh. + setSessionProfilesTruncated(prev => { + const next = recents.profiles_truncated ?? {} + const prevKeys = Object.keys(prev) + + return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key]) + ? prev + : next + }) + // Same identity gate: these totals only move when a session bills, and + // a fresh object every refresh would repaint every profile header. + setSessionProfilesUsage(prev => { + const next = recents.profiles_usage ?? {} + const prevKeys = Object.keys(prev) + + return prevKeys.length === Object.keys(next).length && + prevKeys.every( + key => prev[key]?.tokens === next[key]?.tokens && prev[key]?.cost_usd === next[key]?.cost_usd + ) + ? prev + : next + }) + + // Cron section: latest N cron sessions (kept so a pinned cron run still + // resolves via sessionByAnyId), signature-gated like above. + setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions)) + + // Messaging sections: drop any non-messaging source the broad exclude + // didn't catch (custom sources stay in local recents), then split per + // platform in the UI. + const messagingRows = dropTombstoned(result.messaging.sessions.filter(s => isMessagingSource(s.source))) + + setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows)) + // Hit the cap → at least one platform may have more on disk than loaded. + setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) + } + } finally { + // Request identity preserves the zero-argument refresh contract across a + // failed activation epoch; an explicit owner predicate is stronger and + // must never release a newer switch's loading barrier. + if (showLoading && shouldPublish() && refreshSessionsRequestRef.current === requestId) { + setSessionsLoading(false) + } + } + + // Cron *jobs* are a distinct API (getCronJobs), not a session slice. + if (shouldPublish() && sidebarProfileForScope(profileScopeRef.current) === sessionProfile) { + void refreshCronJobs() + } + }, + [profileScope, refreshCronJobs] + ) const loadMoreSessions = useCallback(async () => { bumpSessionsLimit() diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 62c1799c1b..318072588b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -415,11 +415,13 @@ declare global { write: (id: string, data: string) => Promise } reachPreviewUrl?: (url: string) => Promise - setActiveConnectionRoute?: (route: { - connectionId?: null | string - profile?: string - registryScoped?: boolean - } | null) => void + setActiveConnectionRoute?: ( + route: { + connectionId?: null | string + profile?: string + registryScoped?: boolean + } | null + ) => void onClosePreviewRequested?: (callback: () => void) => () => void onPreviewNav?: (callback: (command: 'back' | 'forward' | 'reload') => void) => () => void onOpenFolderRequested?: (callback: () => void) => () => void diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 74a5735719..ed4cf6cf09 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -625,9 +625,8 @@ export const host = { // the deleted profile. const ambientConnectionId = route ? null : String(activeGatewayConnectionId() || '').trim() - const ambientRemoteConnectionId = ambientConnectionId && ambientConnectionId !== 'local' - ? ambientConnectionId - : null + const ambientRemoteConnectionId = + ambientConnectionId && ambientConnectionId !== 'local' ? ambientConnectionId : null if (!name) { throw new Error('deleteProfile: profile name required') diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 7ffd3d7a60..27d246e72a 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -224,7 +224,12 @@ export function setPrimaryGatewayConnection(connection: Pick ({ HermesGateway: class { connectionState = 'closed' - eventHandler: ((event: { payload?: Record; session_id?: string; type: string }) => void) | null = null + eventHandler: ((event: { payload?: Record; session_id?: string; type: string }) => void) | null = + null stateHandler: ((state: string) => void) | null = null connect = vi.fn(async () => { this.connectionState = 'open' @@ -36,15 +37,18 @@ vi.mock('@/hermes', () => ({ return method === 'prompt.submit' && promptAckStatus ? { status: promptAckStatus } : { method, params } }) close = vi.fn() - emit = (event: { payload?: Record; session_id?: string; type: string }) => this.eventHandler?.(event) + emit = (event: { payload?: Record; session_id?: string; type: string }) => + this.eventHandler?.(event) emitState = (state: string) => this.stateHandler?.(state) - onEvent = vi.fn((handler: (event: { payload?: Record; session_id?: string; type: string }) => void) => { - this.eventHandler = handler + onEvent = vi.fn( + (handler: (event: { payload?: Record; session_id?: string; type: string }) => void) => { + this.eventHandler = handler - return () => { - this.eventHandler = null + return () => { + this.eventHandler = null + } } - }) + ) onState = vi.fn((handler: (state: string) => void) => { this.stateHandler = handler @@ -338,12 +342,10 @@ describe('requestForSessionProfile', () => { installDesktop() const ambient = vi.fn(async () => ({ ambient: true })) - await requestForSessionProfile( - { connectionId: 'local', profile: 'default' }, - ambient as never, - 'prompt.submit', - { session_id: 'rt-bot-chat', text: 'research this' } - ) + await requestForSessionProfile({ connectionId: 'local', profile: 'default' }, ambient as never, 'prompt.submit', { + session_id: 'rt-bot-chat', + text: 'research this' + }) // prompt.submit ACKs immediately while the model keeps running. Releasing // the request-scoped socket here detaches the runtime session; the backend @@ -375,23 +377,24 @@ describe('requestForSessionProfile', () => { vi.useRealTimers() }) - it.each(['queued', 'redirected', 'future-nonterminal'])('retains a routed socket for non-terminal ACK status %s', async status => { - const primary = makePrimary() - setPrimaryGateway(primary as never, 'default') - installDesktop() - const ambient = vi.fn(async () => ({ ambient: true })) + it.each(['queued', 'redirected', 'future-nonterminal'])( + 'retains a routed socket for non-terminal ACK status %s', + async status => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + installDesktop() + const ambient = vi.fn(async () => ({ ambient: true })) - promptAckStatus = status + promptAckStatus = status - await requestForSessionProfile( - 'loki', - ambient as never, - 'prompt.submit', - { session_id: `rt-${status}`, text: 'continue' } - ) + await requestForSessionProfile('loki', ambient as never, 'prompt.submit', { + session_id: `rt-${status}`, + text: 'continue' + }) - expect(secondaryGateways[0].close).not.toHaveBeenCalled() - }) + expect(secondaryGateways[0].close).not.toHaveBeenCalled() + } + ) it.each(['complete', 'completed', 'error'])('releases a routed socket for terminal ACK status %s', async status => { const primary = makePrimary() @@ -401,12 +404,10 @@ describe('requestForSessionProfile', () => { promptAckStatus = status - await requestForSessionProfile( - 'loki', - ambient as never, - 'prompt.submit', - { session_id: `rt-${status}`, text: 'finish' } - ) + await requestForSessionProfile('loki', ambient as never, 'prompt.submit', { + session_id: `rt-${status}`, + text: 'finish' + }) expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 5ce6295783..69d6710df3 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -228,9 +228,7 @@ export type NewChatWorkspaceTarget = null | string | undefined export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir -export async function syncConfiguredDefaultProjectDir( - shouldPublish: () => boolean = () => true -): Promise { +export async function syncConfiguredDefaultProjectDir(shouldPublish: () => boolean = () => true): Promise { const settings = window.hermesDesktop?.settings?.getDefaultProjectDir if (!settings) {