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:
HeavyGee
2026-07-29 10:03:52 +08:00
committed by GitHub
co-authored by Cursor
parent b281b931f5
commit e32fe146c3
6 changed files with 505 additions and 13 deletions
@@ -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([])
})
})
+79
View File
@@ -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)
}
}
+156 -1
View File
@@ -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()
})
})
+96
View File
@@ -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
View File
@@ -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()