Files
hapi/web/src/lib/sessionReference.ts
T
bc543dc494 feat: rich composer session @-mentions + inspect_peer (#1228)
* feat(web): feature-flagged rich composer for inline session @ mentions

Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.

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

* fix(web): rich composer mention boundary + #1215 refs

Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes #1215.

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

* test(web): peer-stack e2e for rich composer session @ mentions (#1215)

Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve newlines in rich composer Enter-newline mode

Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).

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

* fix(web): show @ badge when rich composer mentions flag is on

Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.

* fix(web): rich session composer on by default (not a user setting)

The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.

* fix(web): make rich composer Shift+Enter create a visible newline

Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.

* feat(web): hover tooltips on rich composer session chips

Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).

* fix(web): dismiss rich composer chip tooltips on mouse leave

contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.

* fix(web): address cold-review Blocker/Majors on rich composer

Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.

* chore: drop accidental .cursor files from rich-composer tip

* fix(web): close remaining cold-review gaps on rich composer

Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.

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

* chore: absorb soup playwright.config union for clean remat

Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.

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

* revert: drop fork playwright tooling from upstreamable tip

Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).

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

* fix(web): fix rich composer Shift+Enter double newline and paste space

Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.

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

* fix(web): pad EOL Shift+Enter after Range.insertNode split

insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.

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

* fix(web): drop custom onDrop from rich composer

Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.

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

* feat(web): sidebar-parity tooltips on rich composer session chips

Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.

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

* chore: keep peer-stack e2e off the upstreamable tip

Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.

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

* feat: cite sessions with UUID wire + inspect_peer for agent/overseer

Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.

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

* fix(cli): gate inspect_peer behind permission approval

Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.

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

* chore: clarify playwright peer testIgnore is upstream-safe

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

* fix(web): keep session UUIDs on rich composer copy/cut/paste

Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 19:42:56 +08:00

173 lines
6.4 KiB
TypeScript

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}/`
return `${normalizedBase}sessions/${encodeURIComponent(sessionId)}`.replace(/\/{2,}/g, '/')
}
function sanitizeSessionReferenceTitle(sessionTitle: string): string {
return sessionTitle.replace(/\s+/g, ' ').trim().slice(0, 120)
}
/** Clipboard text for citing this session in another HAPI chat (not a public share link). */
export function buildSessionReferenceText(sessionTitle: string, sessionId: string): string {
const path = buildSessionReferencePath(sessionId)
const title = sanitizeSessionReferenceTitle(sessionTitle)
if (title) {
return `See session ${JSON.stringify(title)} (${path}) for context`
}
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
}
}
/** Live / fallback fields for composer mention chip hover tooltips. */
export type SessionMentionTooltipSource = {
id: string
title: string
active: boolean
lifecycleState?: string | null
path?: string | null
worktreePath?: string | null
/** Preformatted relative time (sidebar "ago"), when available. */
relativeTime?: string | null
thinking?: boolean
/** Sidebar attention label (permission / input / unread / …). */
attentionLabel?: string | null
}
export type SessionMentionTooltipModel = {
title: string
lines: string[]
ariaLabel: string
}
/**
* Expand a truncated `@chip` into full title + meta for aria-label / fallback tip.
* Visual hover uses SessionRowSummary when a live SessionSummary is available.
*/
export function formatSessionMentionTooltip(
session: SessionMentionTooltipSource | null,
fallbackTitle: string,
id: string
): SessionMentionTooltipModel {
const shortId = id.slice(0, 8)
const rawTitle = (session?.title || fallbackTitle || shortId).replace(/\s+/g, ' ').trim()
const title = rawTitle || shortId
let status: string | null = null
if (session) {
if (session.lifecycleState === 'archived') status = 'Archived'
else if (session.thinking) status = 'Thinking'
else if (session.attentionLabel) status = session.attentionLabel
else status = session.active ? 'Active' : 'Inactive'
}
const path = (session?.worktreePath || session?.path || '').trim() || null
const lines: string[] = [
status ? `Session · ${shortId} · ${status}` : `Session · ${shortId}`,
]
const ago = session?.relativeTime?.trim()
if (ago) lines.push(ago)
if (path) lines.push(path)
return {
title,
lines,
ariaLabel: [title, ...lines].join('. '),
}
}