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:
HeavyGee
2026-07-31 19:42:56 +08:00
committed by GitHub
co-authored by Cursor
parent c7afc50321
commit bc543dc494
35 changed files with 2937 additions and 273 deletions
+9 -3
View File
@@ -53,13 +53,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
'--url',
'http://127.0.0.1:43006',
'--tools',
'change_title,display_image,ping_peer,skill_lookup'
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
])
expect([...harness.tools.keys()]).toEqual([
'change_title',
'display_image',
'ping_peer',
'inspect_peer',
'skill_lookup'
])
@@ -80,9 +81,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
'--url',
'http://127.0.0.1:43006',
'--tools',
'change_title,display_image,ping_peer'
'change_title,display_image,ping_peer,inspect_peer'
])
expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'ping_peer'])
expect([...harness.tools.keys()]).toEqual([
'change_title',
'display_image',
'ping_peer',
'inspect_peer'
])
})
})
+37 -3
View File
@@ -17,7 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer'];
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer', 'inspect_peer'];
function parseArgs(argv: string[]): { url: string | null; toolNames: Set<string> } {
let url: string | null = null;
@@ -135,7 +135,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
const pingPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix (another session - not this chat)'
'Target HAPI session id or unique id prefix (another session - not this chat). Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
message: z.string().min(1).describe('Message text to deliver to the target session'),
});
@@ -144,7 +144,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
server.registerTool<any, any>(
'ping_peer',
{
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.',
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Ping Peer Session',
inputSchema: pingPeerInputSchema,
},
@@ -165,6 +165,40 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
);
}
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix. Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
messageLimit: z.number().int().min(1).max(100).optional().describe(
'Recent message page size (default 30, max 100). Text snippets only.'
),
});
if (toolNames.has('inspect_peer')) {
server.registerTool<any, any>(
'inspect_peer',
{
description: 'Read another HAPI session (metadata + recent message text). Resolves by session id / prefix on the same hub/namespace. Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Inspect Peer Session',
inputSchema: inspectPeerInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'inspect_peer', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to inspect peer: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const skillLookupInputSchema: z.ZodTypeAny = z.object({
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
});
@@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({
return {
url: 'http://127.0.0.1:43006/',
toolNames: options.skillLookup
? ['change_title', 'display_image', 'ping_peer', 'skill_lookup']
: ['change_title', 'display_image', 'ping_peer'],
? ['change_title', 'display_image', 'ping_peer', 'inspect_peer', 'skill_lookup']
: ['change_title', 'display_image', 'ping_peer', 'inspect_peer'],
stop: vi.fn()
}
})
@@ -71,7 +71,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
'--url',
'http://127.0.0.1:43006/',
'--tools',
'change_title,display_image,ping_peer,skill_lookup'
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
])
expect(bridge.mcpServers.hapi.tools).toEqual({
change_title: { approval_mode: 'approve' },
@@ -82,7 +82,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
it('does not expose skill_lookup for native-skill bridge callers', async () => {
const bridge = await buildHapiMcpBridge(createClient())
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer')
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer,inspect_peer')
expect(bridge.mcpServers.hapi.tools).toEqual({
change_title: { approval_mode: 'approve' }
})
+3 -2
View File
@@ -95,8 +95,9 @@ export async function buildHapiMcpBridge(
approval_mode: 'approve'
};
}
// ping_peer is registered on the HTTP MCP server / stdio bridge, but is not
// auto-approved: it targets another session (resume + inject message).
// ping_peer / inspect_peer are registered on the HTTP MCP server / stdio
// bridge, but are not auto-approved: they target another session (resume +
// inject, or read peer histories).
if (options.skillLookup) {
tools.skill_lookup = {
approval_mode: 'approve'
+1 -1
View File
@@ -19,7 +19,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording.
Rename only when the user's primary objective changes substantially and the existing title would be misleading.
When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image.
To message another HAPI session (peer handoff / nudge), call functions.hapi__ping_peer with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer <prefix> <message>.
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call functions.hapi__inspect_peer with sessionIdPrefix=<id> to read metadata and recent messages; call functions.hapi__ping_peer with sessionIdPrefix=<id> and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.
`);
/**