Merge pull request #71162 from NousResearch/bb/session-link-titles

feat(desktop): resolve @session links to titles you can click
This commit is contained in:
brooklyn! 2026-07-25 14:03:39 -05:00 committed by GitHub
commit 5349c7c280
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 843 additions and 34 deletions

View file

@ -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 `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
}
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

View file

@ -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' }
])
})
})

View file

@ -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<HermesRefType, string[]> = {
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'
}) => (
<svg
className="size-3 shrink-0 opacity-80"
className={className}
fill="none"
stroke="currentColor"
strokeLinecap="round"
@ -314,12 +317,8 @@ function shortLabel(type: HermesRefType, id: string): string {
}
}
// `@session:<profile>/<id>` — 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' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : segment.type === 'image' ? null : (
) : segment.type === 'image' ? null : segment.type === 'session' ? (
<SessionRefChip key={`m-${index}-${segment.id}`} label={segment.label} value={segment.id} />
) : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
@ -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:<profile>/<id>` 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 <DirectiveChip id={value} label={resolved} onClick={() => 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 (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
event.stopPropagation()
openSessionRef(value)
}}
title={value}
>
<DirectiveIcon className="mr-1 inline size-[0.82em] align-[-0.08em] opacity-70" type="session" />
{resolved}
</a>
)
}
/** 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 }) => (
<span
className={DIRECTIVE_CHIP_CLASS}
data-directive-id={id}
data-directive-type={type}
data-slot="aui_directive-chip"
title={id}
>
<DirectiveIcon type={type} />
<span className="truncate">{label}</span>
</span>
)
onClick?: () => void
}> = ({ type, label, id, onClick }) => {
const body = (
<>
<DirectiveIcon type={type} />
<span className="truncate">{label}</span>
</>
)
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 ? (
<button {...props} onClick={onClick} type="button">
{body}
</button>
) : (
<span {...props}>{body}</span>
)
}

View file

@ -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> = {}): 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(<MarkdownTextContent isRunning={false} text="Context lives in @session:work/20260101_abc123 today." />)
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(<MarkdownTextContent isRunning={false} text="See @session:work/20260101_abc123 for context." />)
const link = await screen.findByTitle('work/20260101_abc123')
expect(link.textContent).toBe('20260101…')
})
it('leaves a ref inside inline code as literal text', () => {
render(<MarkdownTextContent isRunning={false} text="Write `@session:work/20260101_abc123` to link a chat." />)
expect(screen.getByText('@session:work/20260101_abc123')).toBeTruthy()
expect(screen.queryByTitle('work/20260101_abc123')).toBeNull()
})
})

View file

@ -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 <PreviewAttachment source="explicit-link" target={previewTarget} />
}
const sessionRef = sessionRefFromMarkdownHref(href)
if (sessionRef) {
return <SessionRefLink value={sessionRef} />
}
const target = href ? normalizeExternalUrl(href) : href
if (!target || !/^https?:\/\//i.test(target)) {

View file

@ -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(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
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(<DirectiveContent text="pick up @session:work/20260101_abc123 please" />)
const chip = screen.getByTitle('work/20260101_abc123')
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
})
})

View file

@ -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('')

View file

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

View file

@ -0,0 +1,143 @@
/**
* 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 { parseSessionRefValue, sessionRefCacheKey, sessionRefFallbackLabel } from '@/lib/session-refs'
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>>()
/** 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()
}

View file

@ -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/')
})
})

View file

@ -0,0 +1,118 @@
/**
* Pure helpers for `@session:<profile>/<id>` 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 = /(?<![\w/])(?<!]\()@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const TRAILING_PUNCTUATION_RE = /[,.;:!?)\]}]+$/
function unwrapQuotes(raw: string): null | string {
if (raw.length < 2) {
return null
}
const head = raw[0]
const tail = raw[raw.length - 1]
if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) {
return raw.slice(1, -1)
}
return null
}
/** Splits a matched value into the reference itself and any prose punctuation
* that the greedy `\S+` branch swallowed. Quoted values are already fenced. */
export function splitSessionRefValue(raw: string): { trailing: string; value: string } {
const quoted = unwrapQuotes(raw)
if (quoted !== null) {
return { trailing: '', value: quoted }
}
const value = raw.replace(TRAILING_PUNCTUATION_RE, '')
return { trailing: raw.slice(value.length), value }
}
/** Session ids never contain a slash, so a slash unambiguously means
* `<profile>/<id>` 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:<profile>/<id>` 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}`
})
}

View file

@ -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:[<profile>/]<id>` 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)
# =========================================================================

View file

@ -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 "