mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
e0dfcf275a
commit
cbad98e04e
2 changed files with 319 additions and 0 deletions
144
apps/desktop/src/lib/session-link-title.test.ts
Normal file
144
apps/desktop/src/lib/session-link-title.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
175
apps/desktop/src/lib/session-link-title.ts
Normal file
175
apps/desktop/src/lib/session-link-title.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue