diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 71491b87496d..21f0286c9f82 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -15,6 +15,7 @@ import { type SlashChipKind, slashIconElement } from '@/components/assistant-ui/directive-text' +import { sessionRefFallbackLabel } from '@/lib/session-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' @@ -59,7 +60,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - return `${directiveIconSvg(kind)}${escapeHtml(displayLabel || refLabel(id))}` + const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id)) + + return `${directiveIconSvg(kind)}${escapeHtml(label)}` } export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { @@ -74,7 +77,7 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = displayLabel || refLabel(id) + label.textContent = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id)) chip.append(directiveIconElement(kind), label) return chip diff --git a/apps/desktop/src/components/assistant-ui/directive-text.test.ts b/apps/desktop/src/components/assistant-ui/directive-text.test.ts index 60c89f18b1e4..36ab54d743e7 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/directive-text.test.ts @@ -36,4 +36,14 @@ describe('hermesDirectiveFormatter.parse', () => { { kind: 'text', text: ' the entry point' } ]) }) + + it('parses session links with profile/id values', () => { + const segments = hermesDirectiveFormatter.parse('see @session:work/20260101_abc123 next') + + expect(segments).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'mention', type: 'session', label: '20260101…', id: 'work/20260101_abc123' }, + { kind: 'text', text: ' next' } + ]) + }) }) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index ffa63a7b8877..6ea2580f16b8 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -7,7 +7,11 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/chat/zoomable-image' import { extractEmbeddedImages } from '@/lib/embedded-images' +import { triggerHaptic } from '@/lib/haptics' import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media' +import { useSessionLinkTitle } from '@/lib/session-link-title' +import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs' +import { cn } from '@/lib/utils' const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const type HermesRefType = (typeof HERMES_REF_TYPES)[number] @@ -40,11 +44,7 @@ const ICON_PATHS: Record = { tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'], line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'], terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'], - session: [ - 'M8 9h8', - 'M8 13h6', - 'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z' - ] + session: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227'] } const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'] @@ -123,9 +123,12 @@ export function slashIconElement(kind: SlashChipKind) { return iconElementFromPaths(SLASH_ICON_PATHS[kind]) } -const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( +const DirectiveIcon: FC<{ type: string; className?: string }> = ({ + type, + className = 'size-3 shrink-0 opacity-80' +}) => ( /` — show a short id; the composer chip carries the - // friendly title, but once sent the wire form only has the id. if (type === 'session') { - const sid = id.split('/').filter(Boolean).pop() || id - - return sid.length > 10 ? `${sid.slice(0, 8)}…` : sid + return sessionRefFallbackLabel(id) } const tail = id.split(/[\\/]/).filter(Boolean).pop() @@ -368,7 +367,9 @@ export function DirectiveContent({ text }: { text: string }) { {segments.map((segment, index) => segment.kind === 'text' ? ( {segment.text} - ) : segment.type === 'image' ? null : ( + ) : segment.type === 'image' ? null : segment.type === 'session' ? ( + + ) : ( ) )} @@ -451,19 +452,87 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { ) } +/** Opens the referenced session as a tab — same as middle-clicking its sidebar + * row. The tile store loads on click, not at import: the composer's rich + * editor pulls this module in, and a static import would boot the profile + * store (and its REST routing) along with it. */ +function openSessionRef(value: string) { + const { sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return + } + + triggerHaptic('selection') + void import('@/store/session-states').then(({ openSessionTile }) => openSessionTile(sessionId, 'center')) +} + +/** A `@session:/` reference in the user transcript (directive + * segments), rendered as a chip like the other composer refs. Clicking it + * opens the session as a tab. */ +export const SessionRefChip: FC<{ + label?: string + value: string +}> = ({ label, value }) => { + const resolved = useSessionLinkTitle(value, label) + + return openSessionRef(value)} type="session" /> +} + +/** A `@session:` reference in assistant markdown (`#session/` links rewritten + * in `preprocessMarkdown`). Reads as an ordinary inline link — the agent wrote + * it mid-sentence — with the funnel icon leading the resolved title. */ +export const SessionRefLink: FC<{ + label?: string + value: string +}> = ({ label, value }) => { + const resolved = useSessionLinkTitle(value, label) + + return ( + { + event.preventDefault() + event.stopPropagation() + openSessionRef(value) + }} + title={value} + > + + {resolved} + + ) +} + +/** Inert by default; `onClick` promotes the chip to a real button (session + * refs, which open the session they name). */ const DirectiveChip: FC<{ type: string label: string id: string -}> = ({ type, label, id }) => ( - - - {label} - -) + onClick?: () => void +}> = ({ type, label, id, onClick }) => { + const body = ( + <> + + {label} + + ) + + const props = { + className: cn(DIRECTIVE_CHIP_CLASS, onClick && 'cursor-pointer transition-colors hover:text-foreground'), + 'data-directive-id': id, + 'data-directive-type': type, + 'data-slot': 'aui_directive-chip', + title: id + } + + return onClick ? ( + + ) : ( + {body} + ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx new file mode 100644 index 000000000000..6c7b02cf7af2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { __resetSessionLinkTitleCache } from '@/lib/session-link-title' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { MarkdownTextContent } from './markdown-text' + +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: 'work', + source: 'cli', + started_at: 1_000, + title: 'Branch plan', + tool_call_count: 0, + ...overrides + } +} + +afterEach(() => { + cleanup() + $sessions.set([]) + __resetSessionLinkTitleCache() +}) + +// End-to-end for the agent-authored path: a bare ref in assistant markdown has +// to survive preprocessMarkdown -> Streamdown -> MarkdownLink and come out as +// an inline link titled after the session, not as literal text. +describe('MarkdownTextContent session refs', () => { + it('renders an agent-written @session ref as a link showing the session title', async () => { + $sessions.set([makeSession()]) + + render() + + const link = await screen.findByTitle('work/20260101_abc123') + + expect(link.tagName).toBe('A') + expect(link.textContent).toBe('Branch plan') + expect(screen.queryByText(/@session:/)).toBeNull() + }) + + it('falls back to a short id when the session is unknown', async () => { + render() + + const link = await screen.findByTitle('work/20260101_abc123') + + expect(link.textContent).toBe('20260101…') + }) + + it('leaves a ref inside inline code as literal text', () => { + render() + + expect(screen.getByText('@session:work/20260101_abc123')).toBeTruthy() + expect(screen.queryByTitle('work/20260101_abc123')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f5d78f531334..7684a4c4240c 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -30,8 +30,10 @@ import { resolveMediaDisplaySrc } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' +import { sessionRefFromMarkdownHref } from '@/lib/session-refs' import { cn } from '@/lib/utils' +import { SessionRefLink } from './directive-text' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' // Math rendering plugin (KaTeX). Configured once at module scope — the @@ -233,6 +235,12 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a return } + const sessionRef = sessionRefFromMarkdownHref(href) + + if (sessionRef) { + return + } + const target = href ? normalizeExternalUrl(href) : href if (!target || !/^https?:\/\//i.test(target)) { diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx new file mode 100644 index 000000000000..de3674f529ca --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { __resetSessionLinkTitleCache } from '@/lib/session-link-title' + +import { DirectiveContent } from './directive-text' +import { MarkdownTextContent } from './markdown-text' + +const openSessionTile = vi.fn() + +vi.mock('@/store/session-states', () => ({ + openSessionTile: (...args: unknown[]) => openSessionTile(...args) +})) + +afterEach(() => { + cleanup() + openSessionTile.mockClear() + __resetSessionLinkTitleCache() +}) + +// Both surfaces render a session ref differently — an inline link in agent +// prose, a chip in the user's own message — but either one opens the session +// it names, the way its sidebar row would. +describe('session refs open the session', () => { + it('opens the session from an agent-written link', async () => { + render() + + fireEvent.click(await screen.findByTitle('work/20260101_abc123')) + + await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center')) + }) + + it('opens the session from a chip in the user transcript', async () => { + render() + + const chip = screen.getByTitle('work/20260101_abc123') + + expect(chip.tagName).toBe('BUTTON') + fireEvent.click(chip) + + await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center')) + }) +}) diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts index 25213e7ab305..c4a8731f4782 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -2,6 +2,7 @@ import { normalizeMathDelimiters } from '@assistant-ui/react-streamdown' import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' import { stripPreviewTargets } from '@/lib/preview-targets' +import { linkifySessionRefs } from '@/lib/session-refs' const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi @@ -149,8 +150,10 @@ function normalizeVisibleProse(text: string): string { .map(part => part.startsWith('`') ? part - : autoLinkRawUrls( - part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + : linkifySessionRefs( + autoLinkRawUrls( + part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + ) ) ) .join('') 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..f04daeca3301 --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.test.ts @@ -0,0 +1,119 @@ +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 +} from './session-link-title' +import { sessionRefCacheKey } from './session-refs' + +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('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..27aff91002fb --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.ts @@ -0,0 +1,143 @@ +/** + * 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 { parseSessionRefValue, sessionRefCacheKey, sessionRefFallbackLabel } from '@/lib/session-refs' +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>>() + +/** 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() +} diff --git a/apps/desktop/src/lib/session-refs.test.ts b/apps/desktop/src/lib/session-refs.test.ts new file mode 100644 index 000000000000..5d4b1371a646 --- /dev/null +++ b/apps/desktop/src/lib/session-refs.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' + +import { preprocessMarkdown } from './markdown-preprocess' +import { + linkifySessionRefs, + parseSessionRefValue, + sessionMarkdownHref, + sessionRefCacheKey, + sessionRefFallbackLabel, + sessionRefFromMarkdownHref, + splitSessionRefValue +} from './session-refs' + +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('sessionRefCacheKey', () => { + it('separates the same id across profiles', () => { + expect(sessionRefCacheKey('work/abc')).not.toBe(sessionRefCacheKey('home/abc')) + }) + + it('is empty for a valueless ref', () => { + expect(sessionRefCacheKey(' ')).toBe('') + }) +}) + +describe('splitSessionRefValue', () => { + it('peels prose punctuation off a bare value', () => { + expect(splitSessionRefValue('default/abc123.')).toEqual({ trailing: '.', value: 'default/abc123' }) + }) + + it('leaves a quoted value fenced', () => { + expect(splitSessionRefValue('`my session`')).toEqual({ trailing: '', value: 'my session' }) + }) +}) + +describe('session markdown hrefs', () => { + it('round-trips a value through the fragment href', () => { + const href = sessionMarkdownHref('work/20260101_abc123') + + expect(href).toBe('#session/work%2F20260101_abc123') + expect(sessionRefFromMarkdownHref(href)).toBe('work/20260101_abc123') + }) + + it('ignores hrefs that are not session fragments', () => { + expect(sessionRefFromMarkdownHref('#preview/foo')).toBeNull() + expect(sessionRefFromMarkdownHref('https://example.com')).toBeNull() + expect(sessionRefFromMarkdownHref(undefined)).toBeNull() + }) +}) + +describe('linkifySessionRefs', () => { + it('rewrites a bare ref into a session link', () => { + expect(linkifySessionRefs('see @session:work/20260101_abc123 next')).toBe( + 'see [20260101…](#session/work%2F20260101_abc123) next' + ) + }) + + it('keeps trailing prose punctuation outside the link', () => { + expect(linkifySessionRefs('see @session:default/abc123.')).toBe('see [abc123](#session/default%2Fabc123).') + }) + + it('leaves text without a ref untouched', () => { + const text = 'no references here' + + expect(linkifySessionRefs(text)).toBe(text) + }) + + it('does not match an email-like or path-embedded @session', () => { + expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc') + expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc') + }) + + it('leaves a ref a model already wrapped in a markdown link alone', () => { + const text = '[that chat](@session:work/abc123)' + + expect(linkifySessionRefs(text)).toBe(text) + }) +}) + +// The agent-authored path: assistant markdown runs through preprocessMarkdown +// before Streamdown, so a bare ref must survive as a link — and must NOT be +// rewritten inside code, where it is being discussed rather than referenced. +describe('preprocessMarkdown session refs', () => { + it('linkifies a ref in prose', () => { + expect(preprocessMarkdown('Context is in @session:work/20260101_abc123 there.')).toContain( + '[20260101…](#session/work%2F20260101_abc123)' + ) + }) + + it('leaves refs inside inline code alone', () => { + const out = preprocessMarkdown('Type `@session:work/20260101_abc123` to link a chat.') + + expect(out).toContain('`@session:work/20260101_abc123`') + expect(out).not.toContain('#session/') + }) + + it('leaves refs inside a fenced block alone', () => { + const out = preprocessMarkdown(['```text', '@session:work/20260101_abc123', '```'].join('\n')) + + expect(out).toContain('@session:work/20260101_abc123') + expect(out).not.toContain('#session/') + }) +}) diff --git a/apps/desktop/src/lib/session-refs.ts b/apps/desktop/src/lib/session-refs.ts new file mode 100644 index 000000000000..3ec9fbc62be5 --- /dev/null +++ b/apps/desktop/src/lib/session-refs.ts @@ -0,0 +1,118 @@ +/** + * Pure helpers for `@session:/` references. + * + * Kept free of React/store/API imports so the markdown preprocessor (a hot + * per-flush path) can use them without pulling in the resolver; the stateful + * title lookup lives in `session-link-title.ts`. + */ + +/** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value, + * or one fenced in backticks/quotes so a value with spaces survives. The + * lookbehinds keep `foo@session:` and URL paths from matching, and skip a ref + * a model already wrapped in a markdown link — rewriting that would nest. */ +export const SESSION_REF_RE = /(?/` — same split as `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 +} + +/** A fragment href, matching the `#preview/` convention in `preview-targets.ts` + * — no custom URL scheme to survive markdown sanitization. */ +export function sessionMarkdownHref(value: string): string { + return `#session/${encodeURIComponent(value)}` +} + +export function sessionRefFromMarkdownHref(href?: string): null | string { + if (!href?.startsWith('#session/')) { + return null + } + + try { + return decodeURIComponent(href.slice('#session/'.length)) || null + } catch { + return null + } +} + +/** + * Rewrites bare `@session:/` tokens into markdown links so an + * agent-authored reference reaches `MarkdownLink` and renders as a chip. + * Callers must exclude code spans/fences — `preprocessMarkdown` already does. + */ +export function linkifySessionRefs(text: string): string { + if (!text.includes('@session:')) { + return text + } + + return text.replace(SESSION_REF_RE, (match, raw: string) => { + const { trailing, value } = splitSessionRefValue(raw) + + if (!parseSessionRefValue(value).sessionId) { + return match + } + + const label = sessionRefFallbackLabel(value).replace(/[[\]\\]/g, '\\$&') + + return `[${label}](${sessionMarkdownHref(value)})${trailing}` + }) +} diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 3509055ecdb8..bd538ebd4cba 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -20,6 +20,7 @@ from tools.session_search_tool import ( _is_compacted_message, _is_compression_ended, _resolve_to_parent, + _session_link, session_search, ) @@ -492,6 +493,69 @@ class TestReadShape: assert len(result["messages"]) == 30 # head 20 + tail 10 +# ========================================================================= +# Session links — the value the agent writes to point the user at a session +# ========================================================================= + +def _linked_session_id(link: str) -> str: + """Recover the session id from an `@session:[/]` value.""" + assert link.startswith("@session:"), link + value = link[len("@session:"):] + + return value.rsplit("/", 1)[-1] + + +class TestSessionLink: + def test_link_carries_the_named_profile(self): + assert _session_link("s_oldest", "work") == "@session:work/s_oldest" + + def test_link_falls_back_to_a_bare_id_when_the_profile_is_unknown(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", + lambda: "custom", + ) + + assert _session_link("s_oldest") == "@session:s_oldest" + + def test_link_survives_a_failure_to_resolve_the_profile(self, monkeypatch): + def boom(): + raise RuntimeError("no profile") + + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom) + + assert _session_link("s_oldest") == "@session:s_oldest" + + def test_read_result_links_to_the_session_it_read(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(session_id="s_oldest", db=db)) + + assert _linked_session_id(result["link"]) == result["session_id"] + + def test_every_discovery_result_links_to_its_own_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=5, db=db)) + + assert result["results"] + for entry in result["results"]: + assert _linked_session_id(entry["link"]) == entry["session_id"] + + def test_every_browse_result_links_to_its_own_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(db=db)) + + assert result["results"] + for entry in result["results"]: + assert _linked_session_id(entry["link"]) == entry["session_id"] + + def test_link_splits_the_way_the_tool_parses_it_back(self): + """The agent hands its own link back as session_id; the value has to + survive the profile/id partition in session_search.""" + value = _session_link("s_middle", "work")[len("@session:"):] + profile, _, session_id = value.partition("/") + + assert (profile, session_id) == ("work", "s_middle") + + # ========================================================================= # Cross-profile read — `profile` swaps in another profile's DB (read-only) # ========================================================================= diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index bc09e82511d9..e305467f0542 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -291,6 +291,28 @@ def _resolve_profile_db(profile: str): return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True) +def _session_link(session_id: str, profile: str = None) -> str: + """The reference the agent writes to point the user at a session. + + Same value the desktop composer emits when a session is dragged into a + message, so the desktop renders it as a link carrying the session's title. + The profile segment is omitted when we can't name it confidently — a bare + id still resolves, it just can't disambiguate across profiles. + """ + name = (profile or "").strip() + if not name: + try: + from hermes_cli.profiles import get_active_profile_name + + resolved = get_active_profile_name() + name = "" if resolved == "custom" else resolved + except Exception: + logging.debug("get_active_profile_name failed for session link", exc_info=True) + name = "" + + return f"@session:{name}/{session_id}" if name else f"@session:{session_id}" + + def _locate_session_db(session_id: str): """Scan every profile's ``state.db`` (read-only) for a session id. @@ -335,7 +357,7 @@ def _locate_session_db(session_id: str): return None, None -def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: +def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str: """Read shape: dump a whole session by id (head + tail when large). Serves the linked-session case — the user dropped an @session reference and @@ -366,6 +388,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: "success": True, "mode": "read", "session_id": session_id, + "link": _session_link(session_id, link_profile), "session_meta": { "when": _format_timestamp(meta.get("started_at")), "source": meta.get("source"), @@ -384,7 +407,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: return json.dumps(response, ensure_ascii=False) -def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: +def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str: """Return metadata for the most recent sessions (no LLM calls, no FTS5).""" try: sessions = db.list_sessions_rich( @@ -405,6 +428,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str continue results.append({ "session_id": sid, + "link": _session_link(sid, link_profile), "title": s.get("title") or None, "source": s.get("source", ""), "started_at": s.get("started_at", ""), @@ -630,6 +654,7 @@ def _discover( limit: int, sort: Optional[str], current_session_id: str = None, + link_profile: str = None, ) -> str: """Discovery shape: FTS5 + anchored window + bookends per hit. Single call.""" role_list = role_filter if role_filter else ["user", "assistant"] @@ -764,6 +789,9 @@ def _discover( entry["parent_session_id"] = lineage_root results.append(entry) + for entry in results: + entry["link"] = _session_link(entry["session_id"], link_profile) + _final_payload = { "success": True, "mode": "discover", @@ -848,7 +876,7 @@ def session_search( # Read shape: a session_id with no anchor → dump the whole session. if isinstance(session_id, str) and session_id.strip(): sid = session_id.strip() - result = _read_session(db, sid) + result = _read_session(db, sid, link_profile=profile) if json.loads(result).get("success"): return result @@ -858,7 +886,7 @@ def session_search( located, owner = _locate_session_db(sid) if located is not None: try: - found = json.loads(_read_session(located, sid)) + found = json.loads(_read_session(located, sid, link_profile=owner)) finally: located.close() if found.get("success"): @@ -876,7 +904,7 @@ def session_search( # Browse shape: no query → recent sessions. if not query or not isinstance(query, str) or not query.strip(): - return _list_recent_sessions(db, limit, current_session_id) + return _list_recent_sessions(db, limit, current_session_id, link_profile=profile) # Parse role_filter role_list: Optional[List[str]] = None @@ -897,6 +925,7 @@ def session_search( limit=limit, sort=sort_norm, current_session_id=current_session_id, + link_profile=profile, ) @@ -962,6 +991,16 @@ SESSION_SEARCH_SCHEMA = { " session_search()\n" " Returns recent sessions chronologically: titles, previews, timestamps. " "Use when the user asks \"what was I working on\" without naming a topic.\n\n" + "LINKING THE USER TO A SESSION\n\n" + " When you refer the user to a session, write its `link` value inline in " + "your reply — every result carries one, e.g. " + "`@session:default/20260722_204335_d62c16`. Copy it verbatim; do not " + "reformat it as a markdown link or wrap it in backticks. Hermes renders " + "it as a link showing the session's title, so the link IS the title: " + "use it as a noun mid-sentence (\"that's @session:default/... — want me " + "to pick it up?\"), never alone on its own line, and never alongside the " + "title, id, or date spelled out — that shows the user the same session " + "twice.\n\n" "FTS5 SYNTAX\n\n" " AND is the default — multi-word queries require all terms. Use OR explicitly " "for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match "