feat(desktop): add session link title resolver

Resolve @session:<profile>/<id> reference values to the session's title:
the in-memory sidebar list answers most lookups, and an unknown id falls
back to GET /api/sessions/{id}. Cache, in-flight dedupe, and subscriber
fan-out mirror the external-link title resolver.

An untitled row resolves to empty rather than "Untitled session" so the
caller's short-id fallback stays the chip label.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 22:16:49 -05:00
parent e0dfcf275a
commit cbad98e04e
2 changed files with 319 additions and 0 deletions

View file

@ -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> = {}): 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('')
})
})

View file

@ -0,0 +1,175 @@
/**
* Resolves `@session:<profile>/<id>` 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<string, string>()
const titleInflight = new Map<string, Promise<string>>()
const titleSubs = new Map<string, Set<(value: string) => void>>()
/** Session ids never contain a slash, so a slash unambiguously means
* `<profile>/<id>` 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<null | SessionInfo> {
try {
return Promise.resolve(getSession(sessionId, profile ?? null)).catch(() => null)
} catch {
return Promise.resolve(null)
}
}
export function fetchSessionLinkTitle(value: string): Promise<string> {
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()
}