mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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>
80 lines
2.6 KiB
TypeScript
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)
|
|
}
|
|
}
|