From cbad98e04eafaf8389b2ffb6f05341fef2ed4964 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 22:16:49 -0500 Subject: [PATCH 1/6] feat(desktop): add session link title resolver Resolve @session:/ 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. --- .../src/lib/session-link-title.test.ts | 144 ++++++++++++++ apps/desktop/src/lib/session-link-title.ts | 175 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 apps/desktop/src/lib/session-link-title.test.ts create mode 100644 apps/desktop/src/lib/session-link-title.ts diff --git a/apps/desktop/src/lib/session-link-title.test.ts b/apps/desktop/src/lib/session-link-title.test.ts new file mode 100644 index 000000000000..b6fe39cf8f9a --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getSession } from '@/hermes' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { + __resetSessionLinkTitleCache, + fetchSessionLinkTitle, + lookupLocalSessionTitle, + parseSessionRefValue, + sessionRefCacheKey, + sessionRefFallbackLabel +} from './session-link-title' + +vi.mock('@/hermes', () => ({ + getSession: vi.fn() +})) + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + ended_at: null, + id: '20260101_abc123', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + profile: 'default', + source: 'cli', + started_at: 1_000, + title: 'Research notes', + tool_call_count: 0, + ...overrides + } +} + +afterEach(() => { + __resetSessionLinkTitleCache() + $sessions.set([]) + vi.mocked(getSession).mockReset() +}) + +describe('parseSessionRefValue', () => { + it('splits profile and session id', () => { + expect(parseSessionRefValue('work/20260101_abc123')).toEqual({ + profile: 'work', + sessionId: '20260101_abc123' + }) + }) + + it('treats bare values as session ids', () => { + expect(parseSessionRefValue('20260101_abc123')).toEqual({ sessionId: '20260101_abc123' }) + }) +}) + +describe('sessionRefFallbackLabel', () => { + it('truncates long ids', () => { + expect(sessionRefFallbackLabel('default/20260610_120000_abcdef')).toBe('20260610…') + }) + + it('leaves short ids alone', () => { + expect(sessionRefFallbackLabel('work/abc123')).toBe('abc123') + }) +}) + +describe('lookupLocalSessionTitle', () => { + it('reads from the in-memory session list', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Branch plan' })]) + + expect(lookupLocalSessionTitle('work/20260101_abc123')).toBe('Branch plan') + }) + + it('matches the lineage root so a compressed chat still resolves', () => { + $sessions.set([makeSession({ _lineage_root_id: '20260101_abc123', id: '20260102_tip', title: 'Compressed chat' })]) + + expect(lookupLocalSessionTitle('20260101_abc123')).toBe('Compressed chat') + }) + + it('ignores a same-id row owned by another profile', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Work chat' })]) + + expect(lookupLocalSessionTitle('personal/20260101_abc123')).toBe('') + }) + + it('returns empty for an untitled row so the caller can fall back to the id', () => { + $sessions.set([makeSession({ preview: null, title: null })]) + + expect(lookupLocalSessionTitle('default/20260101_abc123')).toBe('') + }) +}) + +describe('fetchSessionLinkTitle', () => { + it('dedupes concurrent lookups', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ title: 'From API' })) + + const value = 'default/20260101_abc123' + const [first, second] = await Promise.all([fetchSessionLinkTitle(value), fetchSessionLinkTitle(value)]) + + expect(first).toBe('From API') + expect(second).toBe('From API') + expect(getSession).toHaveBeenCalledTimes(1) + expect(getSession).toHaveBeenCalledWith('20260101_abc123', 'default') + }) + + it('uses the local sidebar row before calling the API', async () => { + $sessions.set([makeSession({ title: 'Cached title' })]) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Cached title') + expect(getSession).not.toHaveBeenCalled() + }) + + it('keeps separate cache entries per profile', async () => { + vi.mocked(getSession).mockImplementation(async (id, profile) => + makeSession({ id, profile: profile ?? 'default', title: profile === 'work' ? 'Work chat' : 'Home chat' }) + ) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Home chat') + await expect(fetchSessionLinkTitle('work/20260101_abc123')).resolves.toBe('Work chat') + expect(sessionRefCacheKey('default/20260101_abc123')).not.toBe(sessionRefCacheKey('work/20260101_abc123')) + }) + + it('falls back to the preview when the session has no title', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ preview: 'Summarize this repo', title: null })) + + await expect(fetchSessionLinkTitle('20260101_abc123')).resolves.toBe('Summarize this repo') + }) + + it('resolves empty when the id is not on this backend', async () => { + vi.mocked(getSession).mockRejectedValue(new Error('Session not found')) + + await expect(fetchSessionLinkTitle('default/missing')).resolves.toBe('') + }) + + it('resolves empty when the desktop bridge is unavailable', async () => { + vi.mocked(getSession).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'api')") + }) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('') + }) +}) diff --git a/apps/desktop/src/lib/session-link-title.ts b/apps/desktop/src/lib/session-link-title.ts new file mode 100644 index 000000000000..fc86a112f2e1 --- /dev/null +++ b/apps/desktop/src/lib/session-link-title.ts @@ -0,0 +1,175 @@ +/** + * Resolves `@session:/` reference values to the session's title. + * + * Same shape as the external-link title resolver (`external-link.tsx`): a + * process-lifetime cache, in-flight dedupe, and subscribers so every chip for + * the same session repaints off one lookup. The sidebar list answers most + * lookups for free; only an unknown id costs a REST round-trip. + */ +import { useEffect, useMemo, useState } from 'react' + +import { getSession } from '@/hermes' +import { $sessions, sessionMatchesStoredId } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +const titleCache = new Map() +const titleInflight = new Map>() +const titleSubs = new Map void>>() + +/** Session ids never contain a slash, so a slash unambiguously means + * `/` — mirrors the same split in `tools/session_search_tool.py`. */ +export function parseSessionRefValue(value: string): { profile?: string; sessionId: string } { + const trimmed = value.trim() + const slash = trimmed.indexOf('/') + + if (slash === -1) { + return { sessionId: trimmed } + } + + const profile = trimmed.slice(0, slash).trim() + const sessionId = trimmed.slice(slash + 1).trim() + + return sessionId ? { profile: profile || undefined, sessionId } : { sessionId: trimmed } +} + +export function sessionRefCacheKey(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + return sessionId ? `${profile ?? ''}/${sessionId}` : '' +} + +/** Chip label before (or without) a resolved title — a short, still-identifying id. */ +export function sessionRefFallbackLabel(value: string): string { + const { sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return value + } + + return sessionId.length > 10 ? `${sessionId.slice(0, 8)}…` : sessionId +} + +/** Deliberately not `sessionTitle()` from chat-runtime: its "Untitled session" + * fallback is a worse chip label than the short id, so an untitled row + * resolves to empty and the caller's fallback wins. */ +function sessionRowTitle(row: SessionInfo): string { + return row.title?.trim() || row.preview?.trim() || '' +} + +function profileMatches(sessionProfile: null | string | undefined, target?: string): boolean { + if (!target) { + return true + } + + return ((sessionProfile ?? '').trim() || 'default') === (target.trim() || 'default') +} + +export function lookupLocalSessionTitle(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return '' + } + + const row = $sessions + .get() + .find(session => sessionMatchesStoredId(session, sessionId) && profileMatches(session.profile, profile)) + + return row ? sessionRowTitle(row) : '' +} + +/** REST lookup that can't throw: the bridge is absent outside Electron, and a + * session id that isn't on this backend 404s. Both mean "no title". */ +function requestSessionRow(sessionId: string, profile?: string): Promise { + try { + return Promise.resolve(getSession(sessionId, profile ?? null)).catch(() => null) + } catch { + return Promise.resolve(null) + } +} + +export function fetchSessionLinkTitle(value: string): Promise { + const key = sessionRefCacheKey(value) + + if (!key) { + return Promise.resolve('') + } + + const cached = titleCache.get(key) + + if (cached !== undefined) { + return Promise.resolve(cached) + } + + const inflight = titleInflight.get(key) + + if (inflight) { + return inflight + } + + const local = lookupLocalSessionTitle(value) + + if (local) { + titleCache.set(key, local) + + return Promise.resolve(local) + } + + const { profile, sessionId } = parseSessionRefValue(value) + + const promise = requestSessionRow(sessionId, profile) + .then(row => (row ? sessionRowTitle(row) : '')) + .then(title => { + titleCache.set(key, title) + titleInflight.delete(key) + titleSubs.get(key)?.forEach(notify => notify(title)) + + return title + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useSessionLinkTitle(value: string, fallbackLabel?: string): string { + const key = useMemo(() => sessionRefCacheKey(value), [value]) + const fallback = fallbackLabel?.trim() || sessionRefFallbackLabel(value) + const [title, setTitle] = useState(() => (key ? titleCache.get(key) || lookupLocalSessionTitle(value) : '')) + + useEffect(() => { + if (!key) { + return + } + + const known = titleCache.get(key) || lookupLocalSessionTitle(value) + + setTitle(known) + + if (known) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(resolved: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchSessionLinkTitle(value) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, value]) + + return title || fallback +} + +export function __resetSessionLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} From dfbc9dbb1528e506fec2aea9898ad2d3e00486c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 22:16:49 -0500 Subject: [PATCH 2/6] feat(desktop): show resolved titles on @session chips Route session refs in the transcript through the title resolver so a dropped session reads as its title instead of a truncated id, and use Tabler's funnel for the session chip icon. --- .../src/app/chat/composer/rich-editor.ts | 7 +++-- .../assistant-ui/directive-text.test.ts | 10 +++++++ .../assistant-ui/directive-text.tsx | 26 +++++++++++-------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 71491b87496d..e3ebe3dffdfd 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-link-title' 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..e1a83f1c4c0a 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -8,6 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/chat/zoomable-image' import { extractEmbeddedImages } from '@/lib/embedded-images' import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media' +import { sessionRefFallbackLabel, useSessionLinkTitle } from '@/lib/session-link-title' const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const type HermesRefType = (typeof HERMES_REF_TYPES)[number] @@ -40,11 +41,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'] @@ -314,12 +311,8 @@ function shortLabel(type: HermesRefType, id: string): string { } } - // `@session:/` — 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 +361,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,6 +446,15 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { ) } +const SessionDirectiveChip: FC<{ + label: string + id: string +}> = ({ label, id }) => { + const resolved = useSessionLinkTitle(id, label) + + return +} + const DirectiveChip: FC<{ type: string label: string From 92439be3516b3091a43709645ddd9d1e92fadde5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 22:52:06 -0500 Subject: [PATCH 3/6] feat(desktop): render agent-written @session links as chips Assistant text goes through the markdown renderer, not DirectiveContent, so a session reference an agent wrote came out as literal text. Rewrite bare refs into `#session/` links during markdown preprocessing and dispatch that href to the shared chip in MarkdownLink, alongside the existing media and preview hrefs. Preprocessing already skips code fences and inline code, so a ref being discussed in code stays literal. The pure parsing/href helpers move to session-refs.ts to keep the resolver's React and API imports out of the per-flush preprocess path. --- .../src/app/chat/composer/rich-editor.ts | 2 +- .../assistant-ui/directive-text.tsx | 20 +-- .../markdown-text.session.test.tsx | 66 ++++++++++ .../components/assistant-ui/markdown-text.tsx | 8 ++ apps/desktop/src/lib/markdown-preprocess.ts | 7 +- .../src/lib/session-link-title.test.ts | 29 +---- apps/desktop/src/lib/session-link-title.ts | 34 +---- apps/desktop/src/lib/session-refs.test.ts | 118 ++++++++++++++++++ apps/desktop/src/lib/session-refs.ts | 117 +++++++++++++++++ 9 files changed, 330 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx create mode 100644 apps/desktop/src/lib/session-refs.test.ts create mode 100644 apps/desktop/src/lib/session-refs.ts diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index e3ebe3dffdfd..21f0286c9f82 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -15,7 +15,7 @@ import { type SlashChipKind, slashIconElement } from '@/components/assistant-ui/directive-text' -import { sessionRefFallbackLabel } from '@/lib/session-link-title' +import { sessionRefFallbackLabel } from '@/lib/session-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index e1a83f1c4c0a..6788365da154 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -8,7 +8,8 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/chat/zoomable-image' import { extractEmbeddedImages } from '@/lib/embedded-images' import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media' -import { sessionRefFallbackLabel, useSessionLinkTitle } from '@/lib/session-link-title' +import { useSessionLinkTitle } from '@/lib/session-link-title' +import { sessionRefFallbackLabel } from '@/lib/session-refs' const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const type HermesRefType = (typeof HERMES_REF_TYPES)[number] @@ -362,7 +363,7 @@ export function DirectiveContent({ text }: { text: string }) { segment.kind === 'text' ? ( {segment.text} ) : segment.type === 'image' ? null : segment.type === 'session' ? ( - + ) : ( ) @@ -446,13 +447,16 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { ) } -const SessionDirectiveChip: FC<{ - label: string - id: string -}> = ({ label, id }) => { - const resolved = useSessionLinkTitle(id, label) +/** A `@session:/` reference, rendered as the session's title once + * resolved. Shared by the user transcript (directive segments) and assistant + * markdown (`#session/` links rewritten in `preprocessMarkdown`). */ +export const SessionRefChip: FC<{ + label?: string + value: string +}> = ({ label, value }) => { + const resolved = useSessionLinkTitle(value, label) - return + return } const DirectiveChip: FC<{ 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..e96ecc1acb60 --- /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 +// the session chip, not as literal text or a dead anchor. +describe('MarkdownTextContent session refs', () => { + it('renders an agent-written @session ref as a chip showing the session title', async () => { + $sessions.set([makeSession()]) + + render() + + const chip = await screen.findByTitle('work/20260101_abc123') + + expect(chip.dataset.directiveType).toBe('session') + expect(chip.textContent).toBe('Branch plan') + expect(screen.queryByText(/@session:/)).toBeNull() + }) + + it('falls back to a short id when the session is unknown', async () => { + render() + + const chip = await screen.findByTitle('work/20260101_abc123') + + expect(chip.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..61983dd4c8f4 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 { SessionRefChip } 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/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 index b6fe39cf8f9a..f04daeca3301 100644 --- a/apps/desktop/src/lib/session-link-title.test.ts +++ b/apps/desktop/src/lib/session-link-title.test.ts @@ -7,11 +7,9 @@ import type { SessionInfo } from '@/types/hermes' import { __resetSessionLinkTitleCache, fetchSessionLinkTitle, - lookupLocalSessionTitle, - parseSessionRefValue, - sessionRefCacheKey, - sessionRefFallbackLabel + lookupLocalSessionTitle } from './session-link-title' +import { sessionRefCacheKey } from './session-refs' vi.mock('@/hermes', () => ({ getSession: vi.fn() @@ -43,29 +41,6 @@ afterEach(() => { 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' })]) diff --git a/apps/desktop/src/lib/session-link-title.ts b/apps/desktop/src/lib/session-link-title.ts index fc86a112f2e1..27aff91002fb 100644 --- a/apps/desktop/src/lib/session-link-title.ts +++ b/apps/desktop/src/lib/session-link-title.ts @@ -9,6 +9,7 @@ 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' @@ -16,39 +17,6 @@ const titleCache = new Map() const titleInflight = new Map>() const titleSubs = new Map void>>() -/** Session ids never contain a slash, so a slash unambiguously means - * `/` — mirrors the same split in `tools/session_search_tool.py`. */ -export function parseSessionRefValue(value: string): { profile?: string; sessionId: string } { - const trimmed = value.trim() - const slash = trimmed.indexOf('/') - - if (slash === -1) { - return { sessionId: trimmed } - } - - const profile = trimmed.slice(0, slash).trim() - const sessionId = trimmed.slice(slash + 1).trim() - - return sessionId ? { profile: profile || undefined, sessionId } : { sessionId: trimmed } -} - -export function sessionRefCacheKey(value: string): string { - const { profile, sessionId } = parseSessionRefValue(value) - - return sessionId ? `${profile ?? ''}/${sessionId}` : '' -} - -/** Chip label before (or without) a resolved title — a short, still-identifying id. */ -export function sessionRefFallbackLabel(value: string): string { - const { sessionId } = parseSessionRefValue(value) - - if (!sessionId) { - return value - } - - return sessionId.length > 10 ? `${sessionId.slice(0, 8)}…` : sessionId -} - /** Deliberately not `sessionTitle()` from chat-runtime: its "Untitled session" * fallback is a worse chip label than the short id, so an untitled row * resolves to empty and the caller's fallback wins. */ 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..0028150ef3af --- /dev/null +++ b/apps/desktop/src/lib/session-refs.test.ts @@ -0,0 +1,118 @@ +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') + }) +}) + +// 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..e672c1b6f05e --- /dev/null +++ b/apps/desktop/src/lib/session-refs.ts @@ -0,0 +1,117 @@ +/** + * 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 + * lookbehind keeps `foo@session:` and URL paths from matching. */ +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}` + }) +} From 99ab0362912bd4f40360e85295bd4f92b2a1fd37 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 23:30:25 -0500 Subject: [PATCH 4/6] feat(session-search): give the agent a link to hand back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to link to a session, the agent had no way to know the @session reference syntax exists — every mention in the tool schema described consuming a link the user dropped, never writing one — so it answered with the title and timestamp as prose and the desktop had nothing to render. Every result now carries a ready-to-copy `link`, and the schema says to write it inline instead of restating the title around it. The profile segment is omitted when the active profile can't be named confidently; a bare id still resolves. Also skip linkifying a ref a model already wrapped in a markdown link, which would otherwise rewrite into a nested link. --- apps/desktop/src/lib/session-refs.test.ts | 6 +++ apps/desktop/src/lib/session-refs.ts | 5 +- tests/tools/test_session_search.py | 64 +++++++++++++++++++++++ tools/session_search_tool.py | 47 +++++++++++++++-- 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/session-refs.test.ts b/apps/desktop/src/lib/session-refs.test.ts index 0028150ef3af..5d4b1371a646 100644 --- a/apps/desktop/src/lib/session-refs.test.ts +++ b/apps/desktop/src/lib/session-refs.test.ts @@ -90,6 +90,12 @@ describe('linkifySessionRefs', () => { 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 diff --git a/apps/desktop/src/lib/session-refs.ts b/apps/desktop/src/lib/session-refs.ts index e672c1b6f05e..3ec9fbc62be5 100644 --- a/apps/desktop/src/lib/session-refs.ts +++ b/apps/desktop/src/lib/session-refs.ts @@ -8,8 +8,9 @@ /** 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 - * lookbehind keeps `foo@session:` and URL paths from matching. */ -export const SESSION_REF_RE = /(? 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..2b2d99c359d6 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,14 @@ 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 the session's title, so there is no need to also spell out the " + "title, date, or id around it — write \"picked up in @session:... \" " + "rather than restating the metadata.\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 " From 9edbc68d0f4d3ff144de7319b4e8e4ae4078cb38 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 12:09:36 -0500 Subject: [PATCH 5/6] feat(desktop): open the session a @session ref names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent-written ref rendered as a chip that went nowhere on click. It now renders as an ordinary inline link — the agent wrote it mid-sentence, so it should read like one — with the funnel icon leading the resolved title. Clicking either surface (that link, or the chip in the user's own message) opens the session as a tab, the way its sidebar row does. The tile store loads on click rather than at import: the composer's rich editor pulls this module in, so a static import would boot the profile store and its REST routing along with every transcript render. --- .../assistant-ui/directive-text.tsx | 99 +++++++++++++++---- .../markdown-text.session.test.tsx | 14 +-- .../components/assistant-ui/markdown-text.tsx | 4 +- .../assistant-ui/session-ref-open.test.tsx | 43 ++++++++ 4 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 6788365da154..6ea2580f16b8 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -7,9 +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 { sessionRefFallbackLabel } from '@/lib/session-refs' +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] @@ -121,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' +}) => ( = ({ id, label }) => { ) } -/** A `@session:/` reference, rendered as the session's title once - * resolved. Shared by the user transcript (directive segments) and assistant - * markdown (`#session/` links rewritten in `preprocessMarkdown`). */ +/** 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 + 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 index e96ecc1acb60..6c7b02cf7af2 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx @@ -35,26 +35,26 @@ afterEach(() => { // End-to-end for the agent-authored path: a bare ref in assistant markdown has // to survive preprocessMarkdown -> Streamdown -> MarkdownLink and come out as -// the session chip, not as literal text or a dead anchor. +// an inline link titled after the session, not as literal text. describe('MarkdownTextContent session refs', () => { - it('renders an agent-written @session ref as a chip showing the session title', async () => { + it('renders an agent-written @session ref as a link showing the session title', async () => { $sessions.set([makeSession()]) render() - const chip = await screen.findByTitle('work/20260101_abc123') + const link = await screen.findByTitle('work/20260101_abc123') - expect(chip.dataset.directiveType).toBe('session') - expect(chip.textContent).toBe('Branch plan') + 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 chip = await screen.findByTitle('work/20260101_abc123') + const link = await screen.findByTitle('work/20260101_abc123') - expect(chip.textContent).toBe('20260101…') + expect(link.textContent).toBe('20260101…') }) it('leaves a ref inside inline code as literal text', () => { diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 61983dd4c8f4..7684a4c4240c 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -33,7 +33,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { sessionRefFromMarkdownHref } from '@/lib/session-refs' import { cn } from '@/lib/utils' -import { SessionRefChip } from './directive-text' +import { SessionRefLink } from './directive-text' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' // Math rendering plugin (KaTeX). Configured once at module scope — the @@ -238,7 +238,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a const sessionRef = sessionRefFromMarkdownHref(href) if (sessionRef) { - return + return } const target = href ? normalizeExternalUrl(href) : href 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')) + }) +}) From efe449166658d6892f6725836a017bf7f30537b8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 12:09:36 -0500 Subject: [PATCH 6/6] fix(session-search): stop the agent restating a linked session's title The old wording ("no need to also spell out the title") left the model free to write the link on its own line and then repeat the title in the sentence, showing the user the same session twice. Say plainly that the link IS the title and belongs mid-sentence as a noun. --- tools/session_search_tool.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 2b2d99c359d6..e305467f0542 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -996,9 +996,11 @@ SESSION_SEARCH_SCHEMA = { "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 the session's title, so there is no need to also spell out the " - "title, date, or id around it — write \"picked up in @session:... \" " - "rather than restating the metadata.\n\n" + "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 "