mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { SessionSummary } from '@/types/api'
|
||||
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
|
||||
import { ScheduleIcon } from '@/components/icons'
|
||||
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
|
||||
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
|
||||
import { classifySessionAttention } from '@/lib/sessionAttention'
|
||||
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
|
||||
import { formatRelativeTime } from '@/lib/relativeTime'
|
||||
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
|
||||
import { getCodexImportedAt } from '@/lib/codexImportedSessions'
|
||||
import { getSessionTitle } from '@/lib/sessionTitle'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
|
||||
|
||||
function LoaderIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
|
||||
<line x1="12" y1="2" x2="12" y2="6" />
|
||||
<line x1="12" y1="18" x2="12" y2="22" />
|
||||
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
|
||||
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
|
||||
<line x1="2" y1="12" x2="6" y2="12" />
|
||||
<line x1="18" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
|
||||
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BulbIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M12 2a7 7 0 0 0-4 12c.6.6 1 1.2 1 2h6c0-.8.4-1.4 1-2a7 7 0 0 0-4-12Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const ATTENTION_DOT_CLASS = {
|
||||
permission: 'bg-amber-500 animate-pulse',
|
||||
input: 'bg-blue-500',
|
||||
background: 'bg-blue-400',
|
||||
unread: 'bg-[var(--app-link)]',
|
||||
} as const
|
||||
|
||||
function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null {
|
||||
if (!session.todoProgress) return null
|
||||
if (session.todoProgress.completed === session.todoProgress.total) return null
|
||||
return session.todoProgress
|
||||
}
|
||||
|
||||
function formatCodexImportedRelativeTime(
|
||||
value: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
): string | null {
|
||||
const ms = value < 1_000_000_000_000 ? value * 1000 : value
|
||||
if (!Number.isFinite(ms)) return null
|
||||
const delta = Date.now() - ms
|
||||
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
|
||||
const minutes = Math.floor(delta / 60_000)
|
||||
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
|
||||
return formatRelativeTime(value, t)
|
||||
}
|
||||
|
||||
function getSessionTimeLabel(
|
||||
session: SessionSummary,
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
): string | null {
|
||||
const importedAt = session.metadata?.flavor === 'codex'
|
||||
? getCodexImportedAt(session.metadata?.agentSessionId)
|
||||
: null
|
||||
if (importedAt !== null) {
|
||||
return formatCodexImportedRelativeTime(importedAt, t)
|
||||
}
|
||||
return formatRelativeTime(session.updatedAt, t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational session row — same chrome as the sidebar SessionItem body
|
||||
* (flavor, title, thinking/attention, schedule, todos, relative time, path).
|
||||
* Used by the session list and by rich-composer mention chip tooltips.
|
||||
*/
|
||||
export function SessionRowSummary(props: {
|
||||
session: SessionSummary
|
||||
showPath?: boolean
|
||||
showDetailedStatus?: boolean
|
||||
selected?: boolean
|
||||
/**
|
||||
* When false, attention is a bare colored dot (no nested HoverTooltip).
|
||||
* Use false inside an already-open chip tooltip portal.
|
||||
*/
|
||||
nestedTooltips?: boolean
|
||||
/** Pass from parent when the parent owns `aria-describedby` (session list). */
|
||||
attentionTooltipId?: string
|
||||
scheduleTooltipId?: string
|
||||
className?: string
|
||||
}) {
|
||||
const {
|
||||
session: s,
|
||||
showPath = true,
|
||||
showDetailedStatus = true,
|
||||
selected = false,
|
||||
nestedTooltips = true,
|
||||
attentionTooltipId: attentionTooltipIdProp,
|
||||
scheduleTooltipId: scheduleTooltipIdProp,
|
||||
className,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const sessionName = getSessionTitle(s)
|
||||
const worktreeLabel = getWorktreeSessionLabel(s)
|
||||
const todoProgress = getTodoProgress(s)
|
||||
const attention = useMemo(
|
||||
() => showDetailedStatus
|
||||
? classifySessionAttention(s, {
|
||||
selected,
|
||||
lastSeenAt: getSessionLastSeenAt(s.id),
|
||||
})
|
||||
: null,
|
||||
[s, selected, showDetailedStatus]
|
||||
)
|
||||
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
|
||||
const scheduledLabel = s.futureScheduledMessageCount > 1
|
||||
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
|
||||
: t('session.item.scheduledMessage')
|
||||
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
|
||||
const ownedIds = useSessionRowTooltipIds(
|
||||
Boolean(attention) && nestedTooltips && !attentionTooltipIdProp,
|
||||
hasScheduleTooltip && nestedTooltips && !scheduleTooltipIdProp
|
||||
)
|
||||
const attentionId = attentionTooltipIdProp ?? ownedIds.attentionId
|
||||
const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId
|
||||
const timeLabel = getSessionTimeLabel(s, t)
|
||||
|
||||
return (
|
||||
<div className={`flex w-full min-w-0 flex-col gap-1 ${className ?? ''}`}>
|
||||
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AgentFlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0 -translate-y-px" />
|
||||
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
|
||||
{sessionName}
|
||||
</div>
|
||||
{s.active && s.thinking ? (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin-slow text-[var(--app-hint)]" />
|
||||
) : attention && nestedTooltips && attentionId ? (
|
||||
<SessionAttentionIndicator
|
||||
attention={attention}
|
||||
summary={s}
|
||||
label={attentionLabel ?? ''}
|
||||
tooltipId={attentionId}
|
||||
/>
|
||||
) : attention ? (
|
||||
<span
|
||||
className={`inline-flex h-2 w-2 shrink-0 rounded-full ${ATTENTION_DOT_CLASS[attention.kind]}`}
|
||||
title={attentionLabel ?? undefined}
|
||||
aria-label={attentionLabel ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{hasScheduleTooltip && nestedTooltips && scheduleId ? (
|
||||
<HoverTooltip
|
||||
id={scheduleId}
|
||||
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="shrink-0"
|
||||
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
|
||||
>
|
||||
<span className="block">
|
||||
<span className="block font-medium">{scheduledLabel}</span>
|
||||
<span className="mt-1 block text-[var(--app-hint)]">
|
||||
{formatScheduledTooltipDetail(s, t)}
|
||||
</span>
|
||||
</span>
|
||||
</HoverTooltip>
|
||||
) : hasScheduleTooltip ? (
|
||||
<span className="shrink-0" aria-label={scheduledLabel} title={scheduledLabel}>
|
||||
<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs">
|
||||
{todoProgress ? (
|
||||
<span className="flex items-center gap-1 text-[var(--app-hint)]">
|
||||
<BulbIcon className="h-3 w-3" />
|
||||
{todoProgress.completed}/{todoProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
{!attention && s.pendingRequestsCount > 0 ? (
|
||||
<span className="text-[var(--app-badge-warning-text)]">
|
||||
{t('session.item.pending')} {s.pendingRequestsCount}
|
||||
</span>
|
||||
) : null}
|
||||
{timeLabel ? (
|
||||
<span className="tabular-nums text-[var(--app-hint)]">{timeLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showPath || worktreeLabel ? (
|
||||
<div
|
||||
className="truncate text-xs text-[var(--app-hint)]"
|
||||
title={worktreeLabel
|
||||
? s.metadata?.worktree?.worktreePath ?? s.metadata?.path
|
||||
: undefined}
|
||||
>
|
||||
{worktreeLabel ?? s.metadata?.path ?? s.id}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user