diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 78c7b792..73224aa7 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -1,7 +1,7 @@ import '@assistant-ui/react-markdown/styles/dot.css' -import type { ComponentPropsWithoutRef, MouseEvent } from 'react' -import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react' +import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from 'react' +import { useState, useCallback, useEffect, useMemo, createContext, useContext } from 'react' import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, @@ -26,17 +26,20 @@ import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons' import { useTranslation } from '@/lib/use-translation' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' +import { remarkSessionPathLinks } from '@/lib/remark-session-path-links' +import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference' import { UriConfirmDialog } from '@/components/UriConfirmDialog' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' // ── Plugin array ──────────────────────────────────────────────────────────── -// Order: remarkGfm → remarkRepairTables → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks +// Order: remarkGfm → remarkRepairTables → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkSessionPathLinks → remarkFilePathLinks // remarkRepairTables must run immediately after remarkGfm — it reads file.value // (raw source) to pad short separator rows before remark-gfm parses the table. // remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the // CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation // from them. Both must come before remarkMath (to avoid treating TeX as URI). +// remarkSessionPathLinks turns bare /sessions/ citations into links. // remarkFilePathLinks runs last to convert file paths → links after all other // transforms have settled. // @@ -55,6 +58,7 @@ const MARKDOWN_PLUGIN_TAIL_HEAD = [ const MARKDOWN_PLUGIN_TAIL = [ ...MARKDOWN_PLUGIN_TAIL_HEAD, + remarkSessionPathLinks, // bare /sessions/ → clickable session citation remarkFilePathLinks, // upstream — file path → link conversion, runs last ] satisfies NonNullable @@ -64,6 +68,7 @@ const MARKDOWN_PLUGIN_TAIL = [ // autolinks (already inert on that surface) but disable explicit-link rewrite. const MARKDOWN_PLUGIN_TAIL_STANDALONE = [ ...MARKDOWN_PLUGIN_TAIL_HEAD, + remarkSessionPathLinks, [remarkFilePathLinks, { rewriteExplicitLinks: false }], ] satisfies NonNullable @@ -504,6 +509,35 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin ) } +function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessionId: string }) { + const navigate = useNavigate() + const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel + // Preserve Vite BASE_URL for copy / open-in-new-tab (SPA click uses navigate). + const href = buildSessionReferencePath(props.targetSessionId) + + const handleClick = (event: MouseEvent) => { + props.onClick?.(event) + if (event.defaultPrevented) return + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + event.preventDefault() + void navigate({ + to: '/sessions/$sessionId', + params: { sessionId: props.targetSessionId }, + }) + } + + return ( + + ) +} + /** * Anchor component with URI scheme policy enforcement. * @@ -519,6 +553,7 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin * - Custom schemes, already allowed by user: live href in DOM; middle-click works. * - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor * which uses useNavigate for SPA routing. + * - Session citation paths (`/sessions/`): SessionPathAnchor SPA navigation. */ function A(props: ComponentPropsWithoutRef<'a'>) { const chat = useOptionalHappyChatContext() @@ -534,6 +569,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) { // (or supply a mock UriConfirmContext.Provider). const ctx = useContext(UriConfirmContext) const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null + const targetSessionId = typeof props.href === 'string' ? parseSessionPathHref(props.href) : null const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel if (filePath) { @@ -543,6 +579,10 @@ function A(props: ComponentPropsWithoutRef<'a'>) { return } + if (targetSessionId) { + return + } + const isAllowed = ctx?.isAllowed ?? (() => false) const { onClick, href, ...rest } = props diff --git a/web/src/lib/remark-session-path-links.test.ts b/web/src/lib/remark-session-path-links.test.ts new file mode 100644 index 00000000..4494dee1 --- /dev/null +++ b/web/src/lib/remark-session-path-links.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { remarkSessionPathLinks } from './remark-session-path-links' + +type Node = { + type?: string + value?: string + url?: string + children?: Node[] +} + +function textTree(text: string): Node { + return { + type: 'root', + children: [{ type: 'paragraph', children: [{ type: 'text', value: text }] }], + } +} + +function collectLinks(node: Node, out: { url?: string; text: string }[] = []) { + if (node.type === 'link') { + const text = (node.children ?? []).map((c) => c.value ?? '').join('') + out.push({ url: node.url, text }) + } + for (const child of node.children ?? []) collectLinks(child, out) + return out +} + +describe('remarkSessionPathLinks', () => { + it('linkifies bare /sessions/ in a citation sentence', () => { + const tree = textTree( + 'See session "upstream issue/pr discovery" (/sessions/abc-def) for context' + ) + remarkSessionPathLinks()(tree) + expect(collectLinks(tree)).toEqual([ + { url: '/sessions/abc-def', text: '/sessions/abc-def' }, + ]) + }) + + it('does not rewrite paths already inside a link', () => { + const tree: Node = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: '/sessions/abc-def', + children: [{ type: 'text', value: '/sessions/abc-def' }], + }, + ], + }, + ], + } + remarkSessionPathLinks()(tree) + expect(collectLinks(tree)).toHaveLength(1) + }) + + it('ignores non-session paths', () => { + const tree = textTree('see /settings/general and ./src/foo.ts') + remarkSessionPathLinks()(tree) + expect(collectLinks(tree)).toEqual([]) + }) + + it('does not steal source paths under a sessions/ directory', () => { + const tree = textTree( + 'lives in web/src/routes/sessions/chat.tsx for this view' + ) + remarkSessionPathLinks()(tree) + expect(collectLinks(tree)).toEqual([]) + }) +}) diff --git a/web/src/lib/remark-session-path-links.ts b/web/src/lib/remark-session-path-links.ts new file mode 100644 index 00000000..17a6d298 --- /dev/null +++ b/web/src/lib/remark-session-path-links.ts @@ -0,0 +1,79 @@ +/** + * Convert bare `/sessions/` paths in markdown text into links so citations + * from Copy reference / @-mention autocomplete are clickable in chat. + * + * Id segment excludes `.` so paths like `routes/sessions/chat.tsx` remain for + * `remarkFilePathLinks` (plugin order: session links before file links). + */ + +import { parseSessionPathHref } from '@/lib/sessionReference' + +type MarkdownNode = { + type?: string + value?: string + url?: string + title?: string | null + children?: MarkdownNode[] +} + +// Optional leading BASE_URL segment(s), then sessions/ (no dots in id). +// Do not treat `.` as a soft end when it starts a file extension (`.tsx`). +const BARE_SESSION_PATH = + /(?:^|[\s(])((?:\.?\/)?(?:[\w.-]+\/)*sessions\/[A-Za-z0-9_~%-]+)(?=[\s),;:!?]|$(?!\.)|(?=\.(?:[\s),;:!?]|$)))/g + +function linkifyTextNode(node: MarkdownNode): MarkdownNode[] { + const value = node.value ?? '' + if (!value.includes('sessions/')) return [node] + + const parts: MarkdownNode[] = [] + let lastIndex = 0 + BARE_SESSION_PATH.lastIndex = 0 + + for (const match of value.matchAll(BARE_SESSION_PATH)) { + const full = match[0] ?? '' + const path = match[1] ?? '' + const matchIndex = match.index ?? 0 + const pathStartInFull = full.indexOf(path) + const absoluteStart = matchIndex + pathStartInFull + const absoluteEnd = absoluteStart + path.length + + if (!parseSessionPathHref(path)) continue + + if (absoluteStart > lastIndex) { + parts.push({ type: 'text', value: value.slice(lastIndex, absoluteStart) }) + } + parts.push({ + type: 'link', + url: path.startsWith('/') || path.startsWith('./') ? path : `/${path}`, + title: null, + children: [{ type: 'text', value: path }], + }) + lastIndex = absoluteEnd + } + + if (parts.length === 0) return [node] + if (lastIndex < value.length) { + parts.push({ type: 'text', value: value.slice(lastIndex) }) + } + return parts +} + +function walk(node: MarkdownNode, parentIsLink: boolean): void { + if (!node.children?.length) return + const next: MarkdownNode[] = [] + for (const child of node.children) { + if (child.type === 'text' && !parentIsLink && node.type !== 'code' && node.type !== 'inlineCode') { + next.push(...linkifyTextNode(child)) + } else { + walk(child, parentIsLink || child.type === 'link') + next.push(child) + } + } + node.children = next +} + +export function remarkSessionPathLinks() { + return (tree: MarkdownNode) => { + walk(tree, false) + } +} diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index bdabd6d1..d407ebf7 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -1,5 +1,32 @@ import { describe, expect, it } from 'vitest' -import { buildSessionReferencePath, buildSessionReferenceText } from './sessionReference' +import type { SessionSummary } from '@/types/api' +import { + buildSessionReferencePath, + buildSessionReferenceText, + matchSessionsForMention, + parseSessionPathHref, +} from './sessionReference' +import { getSessionTitle } from './sessionTitle' + +function makeSession(overrides: Partial & { id: string }): SessionSummary { + return { + active: false, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + model: null, + effort: null, + ...overrides, + } +} describe('buildSessionReferencePath', () => { it('builds a relative session path', () => { @@ -31,3 +58,131 @@ describe('buildSessionReferenceText', () => { ) }) }) + +describe('matchSessionsForMention', () => { + const sessions = [ + makeSession({ + id: 'aaa-active', + active: true, + updatedAt: 100, + metadata: { + path: '/work/a', + name: 'Peer #921: scratchlist', + lifecycleState: 'running', + }, + }), + makeSession({ + id: 'bbb-recent', + updatedAt: 200, + metadata: { path: '/work/b', name: 'session external_refs + PR chip' }, + }), + makeSession({ + id: 'ccc-old', + updatedAt: 50, + metadata: { + path: '/work/c', + name: 'old scratchlist notes', + lifecycleState: 'archived', + }, + }), + makeSession({ + id: 'ddd-meta', + active: true, + updatedAt: 150, + metadata: { + path: '/work/d', + name: 'Meta soup custodian', + lifecycleState: 'running', + }, + }), + // Official name vs agent summary — same dual-field case as share/sidebar search. + makeSession({ + id: 'eee-parity', + active: true, + updatedAt: 180, + metadata: { + path: '/work/e', + name: 'share picker title parity', + summary: { text: 'Upstream Feature Fix' }, + lifecycleState: 'running', + }, + }), + ] + + it('excludes the current session', () => { + const hits = matchSessionsForMention(sessions, 'scratch', { excludeId: 'aaa-active' }) + expect(hits.map((s) => s.id)).not.toContain('aaa-active') + expect(hits.some((s) => getSessionTitle(s).includes('scratch'))).toBe(true) + }) + + it('ranks title prefix / contains matches and prefers active', () => { + const hits = matchSessionsForMention(sessions, 'scratch') + expect(hits[0]?.id).toBe('aaa-active') + expect(hits.map((s) => s.id)).toContain('ccc-old') + }) + + it('matches id prefixes via sessionMatchesQuery', () => { + const hits = matchSessionsForMention(sessions, 'bbb-rec') + expect(hits.map((s) => s.id)).toEqual(['bbb-recent']) + }) + + it('matches summary.text while displaying/inserting official name', () => { + const hits = matchSessionsForMention(sessions, 'Upstream Feature') + expect(hits.map((s) => s.id)).toContain('eee-parity') + const hit = hits.find((s) => s.id === 'eee-parity')! + expect(getSessionTitle(hit)).toBe('share picker title parity') + expect(buildSessionReferenceText(getSessionTitle(hit), hit.id)).toContain( + 'share picker title parity' + ) + expect(buildSessionReferenceText(getSessionTitle(hit), hit.id)).not.toContain( + 'Upstream Feature Fix' + ) + }) + + it('matches machine label via the same sessionMatchesQuery resolver', () => { + const withMachine = [ + makeSession({ + id: 'fff-machine', + updatedAt: 90, + metadata: { + path: '/work/f', + name: 'quiet title', + machineId: 'machine-abcdef12', + }, + }), + ] + const hits = matchSessionsForMention(withMachine, 'desktop', { + resolveMachineLabel: (id) => (id === 'machine-abcdef12' ? 'desktop' : id?.slice(0, 8) ?? ''), + }) + expect(hits.map((s) => s.id)).toEqual(['fff-machine']) + }) + + it('empty query returns active/recent shortlist without archived', () => { + const hits = matchSessionsForMention(sessions, '', { limit: 10 }) + // Active first (by updatedAt), then inactive recent — archived omitted. + expect(hits.map((s) => s.id)).toEqual([ + 'eee-parity', + 'ddd-meta', + 'aaa-active', + 'bbb-recent', + ]) + expect(hits.map((s) => s.id)).not.toContain('ccc-old') + }) +}) + +describe('parseSessionPathHref', () => { + it('parses plain and encoded session paths', () => { + expect(parseSessionPathHref('/sessions/abc-def')).toBe('abc-def') + expect(parseSessionPathHref('/sessions/a%2Fb')).toBe('a/b') + }) + + it('rejects absolute URLs and non-session paths', () => { + expect(parseSessionPathHref('https://example.com/sessions/x')).toBeNull() + expect(parseSessionPathHref('/settings/general')).toBeNull() + }) + + it('rejects dotted tails that look like filenames', () => { + expect(parseSessionPathHref('/sessions/chat.tsx')).toBeNull() + expect(parseSessionPathHref('web/src/routes/sessions/chat.tsx')).toBeNull() + }) +}) diff --git a/web/src/lib/sessionReference.ts b/web/src/lib/sessionReference.ts index 3cb0d4f2..6ce4a919 100644 --- a/web/src/lib/sessionReference.ts +++ b/web/src/lib/sessionReference.ts @@ -1,3 +1,7 @@ +import type { SessionSummary } from '@/types/api' +import { normalizeSearch, sessionMatchesQuery } from '@/components/SessionList' +import { getSessionTitle } from '@/lib/sessionTitle' + export function buildSessionReferencePath(sessionId: string): string { const base = import.meta.env.BASE_URL ?? '/' const normalizedBase = base.endsWith('/') ? base : `${base}/` @@ -17,3 +21,95 @@ export function buildSessionReferenceText(sessionTitle: string, sessionId: strin } return `See HAPI session ${path} for context` } + +export type MatchSessionsForMentionOptions = { + excludeId?: string + limit?: number + /** Same resolver share/sidebar pass into `sessionMatchesQuery`. */ + resolveMachineLabel?: (machineId: string | null) => string +} + +/** + * Rank score for a session that already passed `sessionMatchesQuery`. + * Prefer official-title / id hits over summary-or-path-only matches; then active + recency. + */ +function scoreMatchedSession(session: SessionSummary, query: string): number { + const title = getSessionTitle(session).toLowerCase() + const id = session.id.toLowerCase() + const idPrefix = id.slice(0, 8) + + // Matched via summary/path/machine/etc. — keep below id/title tiers. + let score = 150 + if (title === query) score = 500 + else if (title.startsWith(query)) score = 400 + else if (title.includes(query)) score = 300 + else if (idPrefix.startsWith(query) || id.startsWith(query)) score = 200 + else if (id.includes(query)) score = 100 + + if (session.active) score += 50 + if (session.metadata?.lifecycleState === 'archived') score -= 25 + return score * 1e13 + session.updatedAt +} + +/** + * Rank sessions for composer `@` autocomplete. + * Match filter is the same code path as share/sidebar search (`sessionMatchesQuery`). + * Display/insert still use `getSessionTitle` (name before summary). + * Empty query → active/recent shortlist (excludes archived). + */ +export function matchSessionsForMention( + sessions: readonly SessionSummary[], + query: string, + options: MatchSessionsForMentionOptions = {} +): SessionSummary[] { + const limit = options.limit ?? 20 + const excludeId = options.excludeId + const resolveMachineLabel = options.resolveMachineLabel ?? (() => '') + const normalized = normalizeSearch(query) + + const scored: { session: SessionSummary; score: number }[] = [] + for (const session of sessions) { + if (excludeId && session.id === excludeId) continue + + if (!normalized) { + // Empty / whitespace query: shortlist only — active first, then recent. + // Archived stay searchable once the user types (same as share: type to widen). + if (session.metadata?.lifecycleState === 'archived') continue + const score = session.active ? 1_000_000_000 + session.updatedAt : session.updatedAt + scored.push({ session, score }) + continue + } + + const machineLabel = resolveMachineLabel(session.metadata?.machineId ?? null) + if (!sessionMatchesQuery(session, normalized, machineLabel)) continue + scored.push({ session, score: scoreMatchedSession(session, normalized) }) + } + + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, limit).map((entry) => entry.session) +} + +/** Match in-app session paths produced by buildSessionReferencePath (optional BASE_URL). */ +const SESSION_PATH_RE = /^(?:\.?\/)?(?:[\w.-]+\/)*sessions\/([^/?#]+)\/?$/ + +/** + * Hub session ids are UUIDs (no dots). Reject dotted tails so source paths like + * `web/src/routes/sessions/chat.tsx` stay available for file-path autolinking. + */ +function isPlausibleSessionId(id: string): boolean { + return id.length > 0 && !id.includes('.') +} + +/** Parse a session id from a relative `/sessions/` (or BASE_URL-prefixed) href. */ +export function parseSessionPathHref(href: string): string | null { + const trimmed = href.trim() + if (!trimmed || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null + const match = SESSION_PATH_RE.exec(trimmed) + if (!match) return null + try { + const id = decodeURIComponent(match[1] ?? '') + return isPlausibleSessionId(id) ? id : null + } catch { + return null + } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 7337e1f1..2768388a 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -33,6 +33,9 @@ import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStat import { useSessions } from '@/hooks/queries/useSessions' import { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' +import { getSessionTitle } from '@/lib/sessionTitle' +import { buildSessionReferenceText, matchSessionsForMention } from '@/lib/sessionReference' +import type { Suggestion } from '@/hooks/useActiveSuggestions' import { useSendMessage, type SendErrorInfo } from '@/hooks/mutations/useSendMessage' import type { ComposerSendError } from '@/components/AssistantChat/HappyComposer' import { ApiError } from '@/api/client' @@ -951,28 +954,76 @@ function SessionPage() { const { getSuggestions: getSkillSuggestions, } = useSkills(api, sessionId) + // Same list + search matcher as sidebar / share picker (tiann/hapi#1213). + const { sessions: allSessions } = useSessions(api) + const { machines: mentionMachines } = useMachines(api, true) + const mentionMachineLabelsById = useMachineLabels(mentionMachines) + // Same fallbacks as share picker / SessionList search. + const resolveMentionMachineLabel = useCallback((machineId: string | null) => { + if (machineId && mentionMachineLabelsById[machineId]) { + return mentionMachineLabelsById[machineId] + } + if (machineId) { + return machineId.slice(0, 8) + } + return t('machine.unknown') + }, [mentionMachineLabelsById, t]) const getAutocompleteSuggestions = useCallback(async (query: string) => { if (query.startsWith('@')) { - if (agentType !== 'codex' || !api || !sessionId) return [] const search = query.slice(1) - const response = await api.searchSessionFiles(sessionId, search, 50) - if (!response.success || !response.files) return [] - return response.files.map((file) => { - const mentionText = `@"${file.fullPath.replace(/(["\\])/g, '\\$1')}"` + // v1: plain-text expansion (same grammar as Copy reference). + // v2: segmented rich composer with inline session tokens (#1215). + // Match via sessionMatchesQuery (share/sidebar); label/insert via getSessionTitle. + const sessionHits = matchSessionsForMention(allSessions, search, { + excludeId: sessionId, + limit: 20, + resolveMachineLabel: resolveMentionMachineLabel, + }).map((s) => { + const title = getSessionTitle(s) + const mentionText = buildSessionReferenceText(title, s.id) + const idPrefix = s.id.slice(0, 8) return { - key: mentionText, + key: `session:${s.id}`, text: mentionText, - label: `@${file.fileName}`, - description: file.filePath || file.fullPath + label: `@${title || idPrefix}`, + description: s.active + ? `Session · ${idPrefix} · active` + : `Session · ${idPrefix}`, } }) + + const fileHits: Suggestion[] = [] + if (agentType === 'codex' && api && sessionId) { + const response = await api.searchSessionFiles(sessionId, search, 50) + if (response.success && response.files) { + for (const file of response.files) { + const mentionText = `@"${file.fullPath.replace(/(["\\])/g, '\\$1')}"` + fileHits.push({ + key: mentionText, + text: mentionText, + label: `@${file.fileName}`, + description: file.filePath || file.fullPath, + }) + } + } + } + + return [...sessionHits, ...fileHits] } if (query.startsWith('$')) { return await getSkillSuggestions(query) } return await getSlashSuggestions(query) - }, [agentType, api, sessionId, getSkillSuggestions, getSlashSuggestions]) + }, [ + agentType, + api, + sessionId, + allSessions, + resolveMentionMachineLabel, + getSkillSuggestions, + getSlashSuggestions, + ]) const refreshSelectedSession = useCallback(() => { void refetchSession()