diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index fd1569d7caf..8e98dd9d40d 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/pagination' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' -import { getSessionMessages, listSessions } from '@/hermes' +import { getSessionMessages, listAllProfileSessions } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' @@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . setRefreshing(true) try { - const sessions = (await listSessions(30, 1)).sessions - const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) + const sessions = (await listAllProfileSessions(30, 1)).sessions + const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile))) const nextArtifacts: ArtifactRecord[] = [] results.forEach((result, index) => { 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 4d7ebf946ce..3d51ab8f8bb 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o label: r.export, onSelect: () => { triggerHaptic('selection') - void exportSession(sessionId, { title }) + void exportSession(sessionId, { profile, title }) } }, { diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 3872d24d5f9..2e3a45d771e 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { KbdGroup } from '@/components/ui/kbd' -import { getHermesConfigRecord, listSessions } from '@/hermes' +import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { @@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0 } -type SessionRow = Awaited>['sessions'][number] +type SessionRow = Awaited>['sessions'][number] const toSessionEntry = (session: SessionRow): SessionEntry => ({ id: session.id, @@ -218,13 +218,13 @@ export function CommandPalette() { const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listSessions(200, 1, 'exclude'), + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), enabled: open }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listSessions(200, 0, 'only'), + queryFn: () => listAllProfileSessions(200, 0, 'only'), enabled: open }) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0da26639544..ec966c54cfb 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -521,7 +521,9 @@ export function DesktopController() { return } - const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile + const storedProfile = $sessions + .get() + .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile for (let index = 0; index < Math.max(1, attempts); index += 1) { try { 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 313a5357004..eba2fb93389 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' +import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' @@ -209,6 +209,46 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) } +function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { + return session.id === storedSessionId || session._lineage_root_id === storedSessionId +} + +function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { + const lineage = session._lineage_root_id ?? session.id + + setSessions(prev => [ + session, + ...prev.filter(existing => { + if (sessionMatchesStoredId(existing, storedSessionId)) { + return false + } + + return (existing._lineage_root_id ?? existing.id) !== lineage + }) + ]) +} + +async function resolveStoredSession(storedSessionId: string): Promise { + const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (cached) { + return cached + } + + try { + const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all') + const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (resolved) { + upsertResolvedSession(resolved, storedSessionId) + } + + return resolved + } catch { + return undefined + } +} + function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial< Pick > | null { @@ -457,8 +497,13 @@ export function useSessionActions({ // Swap the single live gateway to this session's profile before any // gateway call (no-op when it's already on that profile / single-profile). - const storedForProfile = $sessions.get().find(session => session.id === storedSessionId) + const storedForProfile = await resolveStoredSession(storedSessionId) const sessionProfile = storedForProfile?.profile + + if (resumeRequestRef.current !== requestId) { + return + } + await ensureGatewayProfile(sessionProfile) const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) @@ -513,7 +558,7 @@ export function useSessionActions({ setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) - const stored = $sessions.get().find(session => session.id === storedSessionId) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) if (stored) { setCurrentUsage(current => ({ @@ -762,7 +807,7 @@ export function useSessionActions({ async (storedSessionId: string) => { clearNotifications() - const removed = $sessions.get().find(s => s.id === storedSessionId) + const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) const wasSelected = selectedStoredSessionId === storedSessionId const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() @@ -771,7 +816,7 @@ export function useSessionActions({ // live tip after compression. Drop both so the pin can't linger. const removedPinId = removed ? sessionPinId(removed) : storedSessionId - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) // Keep $sessionsTotal in sync so the sidebar's "Load N more" footer // doesn't keep claiming the removed row is still on the server. setSessionsTotal(prev => Math.max(0, prev - 1)) @@ -806,7 +851,7 @@ export function useSessionActions({ setFreshDraftReady(false) setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId - const stored = $sessions.get().find(session => session.id === storedSessionId) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) if (stored) { setCurrentUsage(current => ({ @@ -845,7 +890,7 @@ export function useSessionActions({ async (storedSessionId: string) => { clearNotifications() - const archived = $sessions.get().find(s => s.id === storedSessionId) + const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) const wasSelected = selectedStoredSessionId === storedSessionId const previousPinned = $pinnedSessionIds.get() // Pins are keyed on the durable lineage-root id; the stored id may be the @@ -853,7 +898,7 @@ export function useSessionActions({ const archivedPinId = archived ? sessionPinId(archived) : storedSessionId // Soft-hide: drop from the sidebar immediately, keep the data. - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) // Archived sessions are hidden by the listSessions(min_messages=1) query // on the next refresh, so they count as "removed" for the load-more // footer math. @@ -870,12 +915,12 @@ export function useSessionActions({ // in flight and briefly reinsert the still-unarchived backend row. Win // that race after the mutation succeeds so right-click → Archive does // not appear to do nothing until the next full refresh. - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId)) notify({ durationMs: 2_000, kind: 'success', message: copy.archived }) } catch (err) { if (archived) { - setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)]) + setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))]) setSessionsTotal(prev => prev + 1) } diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index 2e043ff0ef3..f644ded929c 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Tip } from '@/components/ui/tooltip' -import { deleteSession, listSessions, setSessionArchived } from '@/hermes' +import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' @@ -43,14 +43,14 @@ export function SessionsSettings() { setLoading(true) try { - const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only') + const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only') setLocalSessions(result.sessions) } catch (err) { notifyError(err, s.failedLoad) } finally { setLoading(false) } - }, []) + }, [s.failedLoad]) useEffect(() => { void load() diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx index 048fa32a208..67012d9a3f0 100644 --- a/apps/desktop/src/components/session-picker.tsx +++ b/apps/desktop/src/components/session-picker.tsx @@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import { useEffect, useMemo, useState } from 'react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { listSessions } from '@/hermes' +import { listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { Check, MessageCircle } from '@/lib/icons' @@ -35,7 +35,7 @@ export function SessionPickerDialog({ const sessionsQuery = useQuery({ enabled: open, - queryFn: () => listSessions(200, 1, 'exclude'), + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), queryKey: ['session-picker', 'sessions'] }) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 0dcf58b3640..290f6aac96d 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listAllProfileSessions, listSessions } from './hermes' +import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes' const emptySessionsResponse = { limit: 0, @@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => { }) ) }) + + it('tags cross-profile message reads for Electron routing and backend lookup', async () => { + api.mockResolvedValue({ messages: [], session_id: 'session-1' }) + + await getSessionMessages('session-1', 'xiaoxuxu') + + expect(api).toHaveBeenCalledWith({ + path: '/api/sessions/session-1/messages?profile=xiaoxuxu', + profile: 'xiaoxuxu' + }) + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index da3247a36a9..147c296cd6b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -54,10 +54,10 @@ export type { AnalyticsSkillEntry, AnalyticsSkillsSummary, AnalyticsTotals, - BackendUpdateCheckResponse, AudioSpeakResponse, AudioTranscriptionResponse, AuxiliaryModelsResponse, + BackendUpdateCheckResponse, ConfigFieldSchema, ConfigSchemaResponse, CronJob, @@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' return window.hermesDesktop.api({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts index b32a705b7eb..8aa31c695cb 100644 --- a/apps/desktop/src/lib/session-export.ts +++ b/apps/desktop/src/lib/session-export.ts @@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications' interface ExportSessionParams { sessionId: string + profile?: string | null title?: string | null session?: SessionInfo } @@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit