diff --git a/apps/desktop/src/lib/session-link-title.test.ts b/apps/desktop/src/lib/session-link-title.test.ts new file mode 100644 index 000000000000..b6fe39cf8f9a --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getSession } from '@/hermes' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { + __resetSessionLinkTitleCache, + fetchSessionLinkTitle, + lookupLocalSessionTitle, + parseSessionRefValue, + sessionRefCacheKey, + sessionRefFallbackLabel +} from './session-link-title' + +vi.mock('@/hermes', () => ({ + getSession: vi.fn() +})) + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + ended_at: null, + id: '20260101_abc123', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + profile: 'default', + source: 'cli', + started_at: 1_000, + title: 'Research notes', + tool_call_count: 0, + ...overrides + } +} + +afterEach(() => { + __resetSessionLinkTitleCache() + $sessions.set([]) + vi.mocked(getSession).mockReset() +}) + +describe('parseSessionRefValue', () => { + it('splits profile and session id', () => { + expect(parseSessionRefValue('work/20260101_abc123')).toEqual({ + profile: 'work', + sessionId: '20260101_abc123' + }) + }) + + it('treats bare values as session ids', () => { + expect(parseSessionRefValue('20260101_abc123')).toEqual({ sessionId: '20260101_abc123' }) + }) +}) + +describe('sessionRefFallbackLabel', () => { + it('truncates long ids', () => { + expect(sessionRefFallbackLabel('default/20260610_120000_abcdef')).toBe('20260610…') + }) + + it('leaves short ids alone', () => { + expect(sessionRefFallbackLabel('work/abc123')).toBe('abc123') + }) +}) + +describe('lookupLocalSessionTitle', () => { + it('reads from the in-memory session list', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Branch plan' })]) + + expect(lookupLocalSessionTitle('work/20260101_abc123')).toBe('Branch plan') + }) + + it('matches the lineage root so a compressed chat still resolves', () => { + $sessions.set([makeSession({ _lineage_root_id: '20260101_abc123', id: '20260102_tip', title: 'Compressed chat' })]) + + expect(lookupLocalSessionTitle('20260101_abc123')).toBe('Compressed chat') + }) + + it('ignores a same-id row owned by another profile', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Work chat' })]) + + expect(lookupLocalSessionTitle('personal/20260101_abc123')).toBe('') + }) + + it('returns empty for an untitled row so the caller can fall back to the id', () => { + $sessions.set([makeSession({ preview: null, title: null })]) + + expect(lookupLocalSessionTitle('default/20260101_abc123')).toBe('') + }) +}) + +describe('fetchSessionLinkTitle', () => { + it('dedupes concurrent lookups', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ title: 'From API' })) + + const value = 'default/20260101_abc123' + const [first, second] = await Promise.all([fetchSessionLinkTitle(value), fetchSessionLinkTitle(value)]) + + expect(first).toBe('From API') + expect(second).toBe('From API') + expect(getSession).toHaveBeenCalledTimes(1) + expect(getSession).toHaveBeenCalledWith('20260101_abc123', 'default') + }) + + it('uses the local sidebar row before calling the API', async () => { + $sessions.set([makeSession({ title: 'Cached title' })]) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Cached title') + expect(getSession).not.toHaveBeenCalled() + }) + + it('keeps separate cache entries per profile', async () => { + vi.mocked(getSession).mockImplementation(async (id, profile) => + makeSession({ id, profile: profile ?? 'default', title: profile === 'work' ? 'Work chat' : 'Home chat' }) + ) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Home chat') + await expect(fetchSessionLinkTitle('work/20260101_abc123')).resolves.toBe('Work chat') + expect(sessionRefCacheKey('default/20260101_abc123')).not.toBe(sessionRefCacheKey('work/20260101_abc123')) + }) + + it('falls back to the preview when the session has no title', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ preview: 'Summarize this repo', title: null })) + + await expect(fetchSessionLinkTitle('20260101_abc123')).resolves.toBe('Summarize this repo') + }) + + it('resolves empty when the id is not on this backend', async () => { + vi.mocked(getSession).mockRejectedValue(new Error('Session not found')) + + await expect(fetchSessionLinkTitle('default/missing')).resolves.toBe('') + }) + + it('resolves empty when the desktop bridge is unavailable', async () => { + vi.mocked(getSession).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'api')") + }) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('') + }) +}) diff --git a/apps/desktop/src/lib/session-link-title.ts b/apps/desktop/src/lib/session-link-title.ts new file mode 100644 index 000000000000..fc86a112f2e1 --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.ts @@ -0,0 +1,175 @@ +/** + * Resolves `@session:/` reference values to the session's title. + * + * Same shape as the external-link title resolver (`external-link.tsx`): a + * process-lifetime cache, in-flight dedupe, and subscribers so every chip for + * the same session repaints off one lookup. The sidebar list answers most + * lookups for free; only an unknown id costs a REST round-trip. + */ +import { useEffect, useMemo, useState } from 'react' + +import { getSession } from '@/hermes' +import { $sessions, sessionMatchesStoredId } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +const titleCache = new Map() +const titleInflight = new Map>() +const titleSubs = new Map void>>() + +/** Session ids never contain a slash, so a slash unambiguously means + * `/` — mirrors the same split in `tools/session_search_tool.py`. */ +export function parseSessionRefValue(value: string): { profile?: string; sessionId: string } { + const trimmed = value.trim() + const slash = trimmed.indexOf('/') + + if (slash === -1) { + return { sessionId: trimmed } + } + + const profile = trimmed.slice(0, slash).trim() + const sessionId = trimmed.slice(slash + 1).trim() + + return sessionId ? { profile: profile || undefined, sessionId } : { sessionId: trimmed } +} + +export function sessionRefCacheKey(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + return sessionId ? `${profile ?? ''}/${sessionId}` : '' +} + +/** Chip label before (or without) a resolved title — a short, still-identifying id. */ +export function sessionRefFallbackLabel(value: string): string { + const { sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return value + } + + return sessionId.length > 10 ? `${sessionId.slice(0, 8)}…` : sessionId +} + +/** Deliberately not `sessionTitle()` from chat-runtime: its "Untitled session" + * fallback is a worse chip label than the short id, so an untitled row + * resolves to empty and the caller's fallback wins. */ +function sessionRowTitle(row: SessionInfo): string { + return row.title?.trim() || row.preview?.trim() || '' +} + +function profileMatches(sessionProfile: null | string | undefined, target?: string): boolean { + if (!target) { + return true + } + + return ((sessionProfile ?? '').trim() || 'default') === (target.trim() || 'default') +} + +export function lookupLocalSessionTitle(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return '' + } + + const row = $sessions + .get() + .find(session => sessionMatchesStoredId(session, sessionId) && profileMatches(session.profile, profile)) + + return row ? sessionRowTitle(row) : '' +} + +/** REST lookup that can't throw: the bridge is absent outside Electron, and a + * session id that isn't on this backend 404s. Both mean "no title". */ +function requestSessionRow(sessionId: string, profile?: string): Promise { + try { + return Promise.resolve(getSession(sessionId, profile ?? null)).catch(() => null) + } catch { + return Promise.resolve(null) + } +} + +export function fetchSessionLinkTitle(value: string): Promise { + const key = sessionRefCacheKey(value) + + if (!key) { + return Promise.resolve('') + } + + const cached = titleCache.get(key) + + if (cached !== undefined) { + return Promise.resolve(cached) + } + + const inflight = titleInflight.get(key) + + if (inflight) { + return inflight + } + + const local = lookupLocalSessionTitle(value) + + if (local) { + titleCache.set(key, local) + + return Promise.resolve(local) + } + + const { profile, sessionId } = parseSessionRefValue(value) + + const promise = requestSessionRow(sessionId, profile) + .then(row => (row ? sessionRowTitle(row) : '')) + .then(title => { + titleCache.set(key, title) + titleInflight.delete(key) + titleSubs.get(key)?.forEach(notify => notify(title)) + + return title + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useSessionLinkTitle(value: string, fallbackLabel?: string): string { + const key = useMemo(() => sessionRefCacheKey(value), [value]) + const fallback = fallbackLabel?.trim() || sessionRefFallbackLabel(value) + const [title, setTitle] = useState(() => (key ? titleCache.get(key) || lookupLocalSessionTitle(value) : '')) + + useEffect(() => { + if (!key) { + return + } + + const known = titleCache.get(key) || lookupLocalSessionTitle(value) + + setTitle(known) + + if (known) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(resolved: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchSessionLinkTitle(value) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, value]) + + return title || fallback +} + +export function __resetSessionLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +}