Files
hapi/web/src/lib/remark-session-path-links.ts
T
e32fe146c3 feat(web): @ autocomplete to cite other sessions by title (#1217)
* feat(web): @ autocomplete to cite other sessions by title

Composer @ ranks fleet session titles (and id prefixes), inserting the
same Copy-reference citation grammar. Codex file @ mentions remain and
follow session hits. Bare /sessions/<id> paths autolink for in-app nav.

Closes tiann/hapi#1213.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): show session @ picks as composer chips

Selecting a session from @ autocomplete adds an attachment-style chip
(flavor + title + active dot) instead of expanding prose in the textarea.
On send, chips serialize to markdown session links that render as chips
in the transcript.

Part of tiann/hapi#1213 dogfood.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert(web): v1 session @ mentions use plain-text expansion

Attachment-style composer chips cannot express positional
"this → session A / that → session B" intent. Keep @ autocomplete
and Copy-reference text insert for v1; rich segmented composer is v2.

Part of tiann/hapi#1213.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): do not autolink source paths under sessions/

Reject dotted session-id tails and stop treating `.ext` as a soft path
end so citations like `routes/sessions/chat.tsx` stay file-path links.

Part of tiann/hapi#1213.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep Vite BASE_URL on session citation anchors

SessionPathAnchor href now uses buildSessionReferencePath so copy /
open-in-new-tab stay in the PWA subpath scope.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): @ session mention search uses sessionMatchesQuery

Reuse the share/sidebar matcher (name + summary + path + id + machine)
while still labeling and inserting getSessionTitle (name before summary).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:03:52 +08:00

80 lines
2.6 KiB
TypeScript

/**
* Convert bare `/sessions/<id>` 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/<id> (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)
}
}