mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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>
This commit is contained in:
@@ -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/<id> 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/<id> → clickable session citation
|
||||
remarkFilePathLinks, // upstream — file path → link conversion, runs last
|
||||
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
@@ -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<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
@@ -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<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
rel={rel}
|
||||
onClick={handleClick}
|
||||
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/<id>`): SessionPathAnchor SPA navigation.
|
||||
*/
|
||||
function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
const chat = useOptionalHappyChatContext()
|
||||
@@ -534,6 +569,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
// <UriConfirmProvider> (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 <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
|
||||
}
|
||||
|
||||
if (targetSessionId) {
|
||||
return <SessionPathAnchor {...props} targetSessionId={targetSessionId} />
|
||||
}
|
||||
|
||||
const isAllowed = ctx?.isAllowed ?? (() => false)
|
||||
|
||||
const { onClick, href, ...rest } = props
|
||||
|
||||
@@ -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/<id> 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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -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<SessionSummary> & { 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/<id>` (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
|
||||
}
|
||||
}
|
||||
|
||||
+60
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user