diff --git a/cli/README.md b/cli/README.md index 52d5d1e2..983634d3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -35,6 +35,7 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins. - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` over reinventing JWT+curl. Also `--message-file` / `--list`. +- `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)`. Optional `--limit`. ### Resume a remote session locally @@ -117,7 +118,7 @@ See `src/configuration.ts` for all options. ### Set for the wrapped agent -- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To message **another** session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. +- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To **read** another session, prefer MCP `inspect_peer` or `hapi inspect-peer`. To **message** another session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. User citations look like `[title](/sessions/)`; pass that `` as `sessionIdPrefix`. Lazy Codex (terminal) sessions export the id only after the hub row is materialized, which happens when the MCP bridge starts — before the agent process is spawned — so path-only self-targeting does not race a missing hub row. diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index 8f5ac043..2336a6bd 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -15,8 +15,8 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID'; * * Prefer the MCP `display_image` tool for inline media when it is available; * `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling. - * To message another session, prefer MCP `ping_peer` or `hapi ping-peer` - do not - * reinvent JWT+curl. + * To read or message another session, prefer MCP `inspect_peer` / `ping_peer` + * (or `hapi inspect-peer` / `hapi ping-peer`) - do not reinvent JWT+curl. * * For lazy Codex sessions the id must only be exported after the hub row is * materialized — exporting the provisional id early makes GET /api/sessions/:id diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index 0e224aa1..6fb5d90c 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'ping_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'ping_peer', 'inspect_peer', 'skill_lookup'], stop: harness.stopServer } }) @@ -167,7 +167,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,ping_peer,skill_lookup' + 'change_title,display_image,ping_peer,inspect_peer,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 63f2fd4c..1a1a9ed2 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -108,7 +108,8 @@ describe('startHappyServer skill_lookup', () => { expect(tools.tools.map((tool) => tool.name)).toEqual([ 'change_title', 'display_image', - 'ping_peer' + 'ping_peer', + 'inspect_peer' ]) }) @@ -126,18 +127,23 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'ping_peer']) - expect(tools.tools.map((tool) => tool.name)).toEqual(['display_image', 'ping_peer']) + expect(server.toolNames).toEqual(['display_image', 'ping_peer', 'inspect_peer']) + expect(tools.tools.map((tool) => tool.name)).toEqual([ + 'display_image', + 'ping_peer', + 'inspect_peer' + ]) }) }) describe('toClaudeAllowedHapiMcpTools', () => { - it('keeps ping_peer registered but out of Claude --allowedTools', () => { + it('keeps ping_peer and inspect_peer registered but out of Claude --allowedTools', () => { expect(toClaudeAllowedHapiMcpTools([ 'change_title', 'display_image', 'ping_peer', + 'inspect_peer', 'skill_lookup' ])).toEqual([ 'mcp__hapi__change_title', diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 5924d977..ba2c0b48 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -14,7 +14,7 @@ import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; import { detectImageMimeType, registerGeneratedImage } from "@/modules/common/generatedImages"; import { resolveSkill } from "@/modules/common/skills"; -import { PingPeerError, pingPeer } from "@/modules/pingPeer/pingPeer"; +import { PingPeerError, formatInspectPeerReport, inspectPeer, pingPeer } from "@/modules/pingPeer/pingPeer"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -26,11 +26,12 @@ type StartHappyServerOptions = { }; /** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */ -const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set(['ping_peer']); +const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set(['ping_peer', 'inspect_peer']); /** * Map HAPI MCP tool names to Claude `--allowedTools` entries. - * Keeps `ping_peer` off the auto-allow list so resume+inject still prompts. + * Keeps `ping_peer` / `inspect_peer` off the auto-allow list so cross-session + * write (resume+inject) and read (peer histories) still prompt. */ export function toClaudeAllowedHapiMcpTools(toolNames: string[]): string[] { return toolNames @@ -77,11 +78,20 @@ function createHapiMcpServer( 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/) citation.' ), message: z.string().min(1).describe('Message text to deliver to the target session'), }); + 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/) citation.' + ), + messageLimit: z.number().int().min(1).max(100).optional().describe( + 'Recent message page size (default 30, max 100). Text snippets only.' + ), + }); + const skillLookupInputSchema: z.ZodTypeAny = z.object({ name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'), }); @@ -184,7 +194,7 @@ function createHapiMcpServer( }); mcp.registerTool('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/), pass that as sessionIdPrefix.', title: 'Ping Peer Session', inputSchema: pingPeerInputSchema, }, async (args: { sessionIdPrefix: string; message: string }) => { @@ -222,6 +232,45 @@ function createHapiMcpServer( } }); + mcp.registerTool('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/), pass that as sessionIdPrefix.', + title: 'Inspect Peer Session', + inputSchema: inspectPeerInputSchema, + }, async (args: { sessionIdPrefix: string; messageLimit?: number }) => { + logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix); + try { + const result = await inspectPeer({ + sessionIdPrefix: args.sessionIdPrefix, + messageLimit: args.messageLimit, + }); + return { + content: [ + { + type: 'text' as const, + text: formatInspectPeerReport(result), + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError + ? error.message + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] inspect_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to inspect peer: ${message}`, + }, + ], + isError: true, + }; + } + }); + if (skillLookup) { mcp.registerTool('skill_lookup', { description: 'Load a HAPI skill by exact name. When a user message starts with $name, call this tool with that name before acting.', @@ -342,8 +391,8 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'ping_peer'] - : ['display_image', 'ping_peer']; + ? ['change_title', 'display_image', 'ping_peer', 'inspect_peer'] + : ['display_image', 'ping_peer', 'inspect_peer']; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 51fd9d70..e17918fe 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -7,7 +7,7 @@ import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; const BASE_SYSTEM_PROMPT = (() => trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. 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 the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. - To message another HAPI session (peer handoff / nudge), call "mcp__hapi__ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: \`hapi ping-peer \`. + When the user cites another HAPI session as [title](/sessions/) (or a bare /sessions/), extract that . Call "mcp__hapi__inspect_peer" with sessionIdPrefix= to read that session's metadata and recent messages. Call "mcp__hapi__ping_peer" with sessionIdPrefix= and a message to nudge or hand off. Do not reinvent JWT+curl. Shell fallbacks: \`hapi inspect-peer \` / \`hapi ping-peer \`. `))(); /** diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index f748ec93..8dc867c0 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -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' + ]) }) }) diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index 013fdbfa..f9129ab1 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -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 } { let url: string | null = null; @@ -135,7 +135,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { 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/) 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 { server.registerTool( '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/), pass that as sessionIdPrefix.', title: 'Ping Peer Session', inputSchema: pingPeerInputSchema, }, @@ -165,6 +165,40 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + 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/) 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( + '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/), pass that as sessionIdPrefix.', + title: 'Inspect Peer Session', + inputSchema: inspectPeerInputSchema, + }, + async (args: Record) => { + 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'), }); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index ef183e5e..80ba6a0b 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -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' } }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8af67d5b..24b4bfd2 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -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' diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index 6d5c0533..ec2984a9 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -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 . + When the user cites another HAPI session as [title](/sessions/) (or a bare /sessions/), extract that . Call functions.hapi__inspect_peer with sessionIdPrefix= to read metadata and recent messages; call functions.hapi__ping_peer with sessionIdPrefix= and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer / hapi ping-peer . `); /** diff --git a/cli/src/commands/inspectPeer.test.ts b/cli/src/commands/inspectPeer.test.ts new file mode 100644 index 00000000..7ebd32b2 --- /dev/null +++ b/cli/src/commands/inspectPeer.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { PingPeerError } from '@/modules/pingPeer/pingPeer' +import { parseInspectPeerArgs } from './inspectPeer' + +describe('parseInspectPeerArgs', () => { + it('parses session id and optional limit', () => { + expect(parseInspectPeerArgs(['7d55ed21-8a9f-4309-b4f8-30069df36b4b', '--limit', '20'])).toEqual({ + help: false, + sessionIdPrefix: '7d55ed21-8a9f-4309-b4f8-30069df36b4b', + messageLimit: 20 + }) + }) + + it('parses --limit= form and help', () => { + expect(parseInspectPeerArgs(['--help'])).toEqual({ help: true }) + expect(parseInspectPeerArgs(['aaaa', '--limit=5']).messageLimit).toBe(5) + }) + + it('rejects unknown flags', () => { + expect(() => parseInspectPeerArgs(['aaaa', '--resume'])).toThrow(PingPeerError) + }) +}) diff --git a/cli/src/commands/inspectPeer.ts b/cli/src/commands/inspectPeer.ts new file mode 100644 index 00000000..cde57769 --- /dev/null +++ b/cli/src/commands/inspectPeer.ts @@ -0,0 +1,116 @@ +import chalk from 'chalk' +import { initializeToken } from '@/ui/tokenInit' +import { + PingPeerError, + exitCodeForPingPeerError, + formatInspectPeerReport, + inspectPeer +} from '@/modules/pingPeer/pingPeer' +import type { CommandDefinition } from './types' + +type ParsedInspectPeerArgs = { + help: boolean + sessionIdPrefix?: string + messageLimit?: number +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi inspect-peer')} - Read another HAPI session's metadata + recent messages + +${chalk.bold('Usage:')} + hapi inspect-peer + hapi inspect-peer --limit 50 + +${chalk.bold('Notes:')} + Read-only twin of ping-peer. Prefer this (or MCP inspect_peer) over JWT+curl. + Resolves by id prefix (8 chars OK; full UUID best). Same hub token/namespace. + Does NOT resume inactive sessions. + When a user cites [title](/sessions/), pass that here. + +${chalk.bold('Env:')} + HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) +`) +} + +export function parseInspectPeerArgs(args: string[]): ParsedInspectPeerArgs { + const result: ParsedInspectPeerArgs = { help: false } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--limit') { + const value = args[++i] + if (!value) { + throw new PingPeerError('bad_args', '--limit requires a number') + } + result.messageLimit = Number(value) + continue + } + if (arg.startsWith('--limit=')) { + result.messageLimit = Number(arg.slice('--limit='.length)) + continue + } + if (arg.startsWith('-')) { + throw new PingPeerError('bad_args', `unexpected flag: ${arg}`) + } + if (!result.sessionIdPrefix) { + result.sessionIdPrefix = arg + continue + } + throw new PingPeerError('bad_args', `unexpected arg: ${arg}`) + } + + if (result.messageLimit !== undefined && !Number.isFinite(result.messageLimit)) { + throw new PingPeerError('bad_args', '--limit must be a number') + } + + return result +} + +export async function handleInspectPeerCommand(args: string[]): Promise { + const parsed = parseInspectPeerArgs(args) + if (parsed.help) { + showHelp() + return + } + + await initializeToken() + + if (!parsed.sessionIdPrefix) { + showHelp() + throw new PingPeerError('bad_args', 'missing session id; usage: hapi inspect-peer ') + } + + const result = await inspectPeer({ + sessionIdPrefix: parsed.sessionIdPrefix, + messageLimit: parsed.messageLimit + }) + console.log(formatInspectPeerReport(result)) +} + +export const inspectPeerCommand: CommandDefinition = { + name: 'inspect-peer', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handleInspectPeerCommand(commandArgs) + } catch (error) { + if (error instanceof PingPeerError) { + console.error(chalk.red('hapi inspect-peer:'), error.message) + process.exit(exitCodeForPingPeerError(error)) + } + console.error( + chalk.red('hapi inspect-peer:'), + error instanceof Error ? error.message : 'Unknown error' + ) + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index e0d72ecb..a21c38b2 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -16,6 +16,7 @@ import { mcpCommand } from './mcp' import { notifyCommand } from './notify' import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' +import { inspectPeerCommand } from './inspectPeer' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -52,7 +53,8 @@ const COMMANDS: CommandDefinition[] = [ resumeCommand, runnerCommand, notifyCommand, - pingPeerCommand + pingPeerCommand, + inspectPeerCommand ] const commandMap = new Map() diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 5c21d7f2..a721f422 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -61,3 +61,23 @@ describe('resolveToolAutoApprovalDecision ping_peer', () => { expect(resolveToolAutoApprovalDecision('read-only', 'grep', 'call-2')).toBe('approved') }) }) + +describe('resolveToolAutoApprovalDecision inspect_peer', () => { + it.each([ + 'inspect_peer', + 'mcp__hapi__inspect_peer', + 'hapi_inspect_peer', + 'Inspect Peer Session' + ])('does not auto-approve %s in default mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull() + }) + + it.each([ + 'inspect_peer', + 'mcp__hapi__inspect_peer', + 'hapi_inspect_peer', + 'Inspect Peer Session' + ])('does not auto-approve %s in read-only mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('read-only', toolName, 'call-1')).toBeNull() + }) +}) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index a4a3ebc1..23c9262c 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -32,12 +32,18 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'happy__skill_lookup', 'mcp__hapi__skill_lookup' ]); -// ping_peer intentionally omitted from always-approve: it can resume another -// session and inject a prompt into a peer, so permission modes must still gate -// it (Codex PR #1195). Treat it as write-like in read-only so ACP titles such as -// "Ping Peer Session" also require approval. +// ping_peer / inspect_peer intentionally omitted from always-approve: they can +// resume+inject into another session or read peer histories, so permission +// modes must still gate them. Treat both as write-like in read-only so ACP +// titles such as "Ping Peer Session" / "Inspect Peer Session" also require +// approval. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; -const SENSITIVE_TOOL_NAME_HINTS = ['ping_peer', 'ping peer']; +const SENSITIVE_TOOL_NAME_HINTS = [ + 'ping_peer', + 'ping peer', + 'inspect_peer', + 'inspect peer', +]; const AUTO_APPROVE_WRITE_TOOL_HINTS = [ 'write', 'edit', diff --git a/cli/src/modules/pingPeer/inspectPeer.test.ts b/cli/src/modules/pingPeer/inspectPeer.test.ts new file mode 100644 index 00000000..f035e8e0 --- /dev/null +++ b/cli/src/modules/pingPeer/inspectPeer.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PingPeerError, + formatInspectPeerReport, + inspectPeer, + type PingPeerSessionSummary +} from './pingPeer' + +type MockResponse = { + status: number + data: unknown +} + +function createHttpMock(handlers: { + post?: (url: string, body?: unknown) => MockResponse | Promise + get?: (url: string, config?: { params?: Record }) => MockResponse | Promise +}) { + return { + post: vi.fn(async (url: string, body?: unknown) => { + if (!handlers.post) { + throw new Error(`unexpected POST ${url}`) + } + return handlers.post(url, body) + }), + get: vi.fn(async (url: string, config?: { params?: Record }) => { + if (!handlers.get) { + throw new Error(`unexpected GET ${url}`) + } + return handlers.get(url, config) + }) + } +} + +describe('inspectPeer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads metadata and recent messages without calling resume', async () => { + const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b' + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url, config) => { + if (url.endsWith('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + active: false, + updatedAt: 1_700_000_000_000, + metadata: { + name: 'hub runner version governance', + flavor: 'cursor', + path: '/home/heavygee/coding/hapi' + } + } satisfies PingPeerSessionSummary] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { + session: { + id: sessionId, + active: false, + thinking: false, + updatedAt: 1_700_000_000_000, + metadata: { + name: 'hub runner version governance', + flavor: 'cursor', + path: '/home/heavygee/coding/hapi', + lifecycleState: 'archived' + } + } + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(config?.params).toEqual({ limit: 30 }) + return { + status: 200, + data: { + messages: [ + { + id: 'm1', + createdAt: 1_700_000_000_100, + content: { + role: 'user', + content: { text: 'status on runner versions?' } + } + }, + { + id: 'm2', + createdAt: 1_700_000_000_200, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Looking into it.' + } + } + } + } + ], + page: { hasMore: false } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await inspectPeer({ + sessionIdPrefix: sessionId, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + + expect(result.sessionId).toBe(sessionId) + expect(result.name).toBe('hub runner version governance') + expect(result.active).toBe(false) + expect(result.flavor).toBe('cursor') + expect(result.path).toBe('/home/heavygee/coding/hapi') + expect(result.messages).toHaveLength(2) + expect(result.messages[0]).toMatchObject({ + role: 'user', + text: 'status on runner versions?' + }) + expect(result.messages[1]?.text).toContain('Looking into it.') + + // Read-only: never resume + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post.mock.calls[0]![0]).toContain('/api/auth') + expect(http.post.mock.calls.some((call) => String(call[0]).includes('/resume'))).toBe(false) + }) + + it('respects messageLimit and refuses empty prefix', async () => { + await expect(inspectPeer({ + sessionIdPrefix: ' ', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: createHttpMock({}) as never + })).rejects.toMatchObject({ code: 'bad_args' } satisfies Partial) + + const sessionId = 'aaaaaaaa-1111-1111-1111-111111111111' + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url, config) => { + if (url.endsWith('/api/sessions')) { + return { + status: 200, + data: { sessions: [{ id: sessionId, active: true, metadata: { name: 'A' } }] } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { session: { id: sessionId, active: true, metadata: { name: 'A' } } } + } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(config?.params).toEqual({ limit: 5 }) + return { status: 200, data: { messages: [], page: {} } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await inspectPeer({ + sessionIdPrefix: 'aaaaaaaa', + messageLimit: 5, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + }) + + it('clamps messageLimit to 1..100', async () => { + const sessionId = 'bbbbbbbb-3333-3333-3333-333333333333' + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url, config) => { + if (url.endsWith('/api/sessions')) { + return { + status: 200, + data: { sessions: [{ id: sessionId, active: true, metadata: { name: 'C' } }] } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { session: { id: sessionId, active: true, metadata: { name: 'C' } } } + } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(config?.params).toEqual({ limit: 100 }) + return { status: 200, data: { messages: [], page: {} } } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await inspectPeer({ + sessionIdPrefix: sessionId, + messageLimit: 999, + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + }) + }) +}) + +describe('formatInspectPeerReport', () => { + it('includes session id and message snippets for agent consumption', () => { + const report = formatInspectPeerReport({ + sessionId: '7d55ed21-8a9f-4309-b4f8-30069df36b4b', + name: 'hub runner version governance', + active: true, + thinking: false, + flavor: 'cursor', + path: '/tmp/x', + lifecycleState: null, + updatedAt: 1_700_000_000_000, + messages: [ + { id: '1', role: 'user', text: 'hello', createdAt: 1 }, + { id: '2', role: 'agent', text: 'world', createdAt: 2 } + ] + }) + expect(report).toContain('7d55ed21-8a9f-4309-b4f8-30069df36b4b') + expect(report).toContain('hub runner version governance') + expect(report).toContain('[user] hello') + expect(report).toContain('[agent] world') + expect(report).toContain('/sessions/7d55ed21-8a9f-4309-b4f8-30069df36b4b') + }) +}) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 06288ee6..83ae8e3a 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -1,12 +1,15 @@ /** - * Resume-if-inactive + wait-active + POST /api/sessions/:id/messages. + * Resume-if-inactive + wait-active + POST /api/sessions/:id/messages, + * plus read-only inspectPeer (GET session + messages, never resume). * - * Shared by `hapi ping-peer` and MCP `ping_peer`. Uses the same hub JWT flow - * as the web app (`POST /api/auth` with CLI_API_TOKEN), scoped to the token's - * namespace. Callers must not invent parallel auth or arbitrary hosts. + * Shared by `hapi ping-peer` / `hapi inspect-peer` and MCP `ping_peer` / + * `inspect_peer`. Uses the same hub JWT flow as the web app + * (`POST /api/auth` with CLI_API_TOKEN), scoped to the token's namespace. + * Callers must not invent parallel auth or arbitrary hosts. */ import axios, { type AxiosInstance } from 'axios' +import { extractAssistantPlainText, isObject } from '@hapi/protocol' import { configuration } from '@/configuration' import { getAuthToken } from '@/api/auth' import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' @@ -33,10 +36,13 @@ export class PingPeerError extends Error { export type PingPeerSessionSummary = { id: string active: boolean + thinking?: boolean updatedAt?: number metadata?: { name?: string flavor?: string | null + path?: string | null + lifecycleState?: string | null piSessionId?: string } | null } @@ -422,3 +428,181 @@ export function exitCodeForPingPeerError(error: PingPeerError): number { return 1 } } + +// ── inspect_peer (read twin; no resume) ───────────────────────────────────── + +export type InspectPeerOptions = { + sessionIdPrefix: string + /** Recent message page size (default 30, clamped 1..100). */ + messageLimit?: number + apiUrl?: string + accessToken?: string + http?: AxiosInstance +} + +export type InspectPeerMessage = { + id: string + role: string + text: string + createdAt: number | null +} + +export type InspectPeerResult = { + sessionId: string + name: string + active: boolean + thinking: boolean + flavor: string | null + path: string | null + lifecycleState: string | null + updatedAt: number | null + messages: InspectPeerMessage[] +} + +const DEFAULT_INSPECT_MESSAGE_LIMIT = 30 +const MAX_INSPECT_MESSAGE_LIMIT = 100 +const MAX_SNIPPET_CHARS = 1_200 + +function clampInspectMessageLimit(raw: number | undefined): number { + const n = raw ?? DEFAULT_INSPECT_MESSAGE_LIMIT + if (!Number.isFinite(n)) { + throw new PingPeerError('bad_args', 'messageLimit must be a number') + } + return Math.min(MAX_INSPECT_MESSAGE_LIMIT, Math.max(1, Math.floor(n))) +} + +function extractUserPlainText(inner: unknown): string | null { + if (typeof inner === 'string' && inner.trim()) return inner + if (!isObject(inner)) return null + if (typeof inner.text === 'string' && inner.text.trim()) return inner.text + if (isObject(inner.content) && typeof inner.content.text === 'string' && inner.content.text.trim()) { + return inner.content.text + } + return null +} + +/** Best-effort text from a hub message row; skip tool-call / empty noise. */ +export function extractInspectMessageSnippet(content: unknown): InspectPeerMessage | null { + if (!isObject(content)) return null + const role = typeof content.role === 'string' ? content.role : 'unknown' + const inner = content.content + let text: string | null = null + if (role === 'user') { + text = extractUserPlainText(inner) + } else { + text = extractAssistantPlainText(inner) + if (!text) text = extractUserPlainText(inner) + } + if (!text) return null + const trimmed = text.replace(/\s+/g, ' ').trim() + if (!trimmed) return null + const snippet = trimmed.length > MAX_SNIPPET_CHARS + ? `${trimmed.slice(0, MAX_SNIPPET_CHARS)}…` + : trimmed + return { + id: typeof content.id === 'string' ? content.id : '', + role, + text: snippet, + createdAt: null + } +} + +async function fetchSessionMessages( + apiUrl: string, + jwt: string, + sessionId: string, + limit: number, + http: AxiosInstance +): Promise { + const response = await http.get( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, + { + headers: authHeaders(jwt), + params: { limit }, + timeout: 20_000, + validateStatus: () => true + } + ) + if (response.status < 200 || response.status >= 300) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new PingPeerError('not_found', `failed to load messages for ${sessionId} (${detail})`) + } + const rows = Array.isArray(response.data?.messages) ? response.data.messages : [] + const out: InspectPeerMessage[] = [] + for (const row of rows) { + if (!isObject(row)) continue + const snippet = extractInspectMessageSnippet(row.content) + if (!snippet) continue + out.push({ + ...snippet, + id: typeof row.id === 'string' ? row.id : snippet.id, + createdAt: typeof row.createdAt === 'number' ? row.createdAt : null + }) + } + return out +} + +/** + * Resolve a peer by id/prefix and return metadata + recent text messages. + * Read-only: never resumes inactive sessions (unlike `pingPeer`). + */ +export async function inspectPeer(options: InspectPeerOptions): Promise { + const prefix = options.sessionIdPrefix?.trim() ?? '' + if (!prefix) { + throw new PingPeerError('bad_args', 'session id prefix is required') + } + const messageLimit = clampInspectMessageLimit(options.messageLimit) + + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const http = options.http ?? axios + + const jwt = await exchangeJwt(apiUrl, accessToken, http) + const sessions = await listSessions(apiUrl, jwt, http) + const matched = resolveSessionByPrefix(sessions, prefix) + const live = await getSession(apiUrl, jwt, matched.id, http) + const meta = live.metadata ?? matched.metadata ?? null + const messages = await fetchSessionMessages(apiUrl, jwt, matched.id, messageLimit, http) + + return { + sessionId: matched.id, + name: meta?.name ?? '(unnamed)', + active: live.active, + thinking: Boolean(live.thinking), + flavor: typeof meta?.flavor === 'string' ? meta.flavor : null, + path: typeof meta?.path === 'string' ? meta.path : null, + lifecycleState: typeof meta?.lifecycleState === 'string' ? meta.lifecycleState : null, + updatedAt: typeof live.updatedAt === 'number' + ? live.updatedAt + : typeof matched.updatedAt === 'number' + ? matched.updatedAt + : null, + messages + } +} + +/** Human/agent-readable report for MCP tool results and CLI stdout. */ +export function formatInspectPeerReport(result: InspectPeerResult): string { + const lines: string[] = [ + `sessionId: ${result.sessionId}`, + `path: /sessions/${result.sessionId}`, + `name: ${result.name}`, + `flavor: ${result.flavor ?? '(unknown)'}`, + `active: ${result.active}`, + `thinking: ${result.thinking}`, + `lifecycle: ${result.lifecycleState ?? '(none)'}`, + `cwd: ${result.path ?? '(unknown)'}`, + `updatedAt: ${result.updatedAt ?? '(unknown)'}`, + `messages (text snippets, newest page): ${result.messages.length}` + ] + if (result.messages.length === 0) { + lines.push('(no extractable user/assistant text in this page)') + } else { + for (const message of result.messages) { + lines.push(`[${message.role}] ${message.text}`) + } + } + return lines.join('\n') +} diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 9235e362..21146e05 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -14,7 +14,7 @@ import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstructio export const TITLE_INSTRUCTION = trimIdent(` Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. 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 the tool "hapi_display_image" with the image path so HAPI can show it inline. - To message another HAPI session (peer handoff / nudge), call "hapi_ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer . + When the user cites another HAPI session as [title](/sessions/) (or a bare /sessions/), extract that . Call "hapi_inspect_peer" with sessionIdPrefix= to read metadata and recent messages; call "hapi_ping_peer" with sessionIdPrefix= and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer / hapi ping-peer . ${SKILL_LOOKUP_INSTRUCTION} `); @@ -24,7 +24,7 @@ export const TITLE_INSTRUCTION = trimIdent(` */ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. - To message another HAPI session (peer handoff / nudge), call "hapi_ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer . + When the user cites another HAPI session as [title](/sessions/) (or a bare /sessions/), extract that . Call "hapi_inspect_peer" with sessionIdPrefix= to read metadata and recent messages; call "hapi_ping_peer" with sessionIdPrefix= and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer / hapi ping-peer . ${SKILL_LOOKUP_INSTRUCTION} `); diff --git a/playwright.config.ts b/playwright.config.ts index 7ff2c288..f24807f9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,8 @@ const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ testDir: './e2e', + // Ignore fork-local peer-stack specs if present (HAPI_PEER_*); not shipped upstream. + testIgnore: ['**/peer/**'], timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: false, diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 0c79e921..28e8a37b 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -12,6 +12,12 @@ import { useRef, useState } from 'react' +import { isRichComposerMentionsEnabled } from '@/lib/composerSegments' +import type { SessionMentionResolveResult } from '@/components/AssistantChat/RichComposerInput' +import { + RichComposerInput, + type RichComposerInputHandle, +} from '@/components/AssistantChat/RichComposerInput' import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary, ThreadGoal } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' @@ -202,6 +208,8 @@ export function HappyComposer(props: { // inline error affordance until the user dismisses or starts editing. sendError?: ComposerSendError | null onClearSendError?: () => void + /** Chip hover / aria-label resolver (SessionChat → useSessions). */ + resolveSessionMentionTooltip?: (id: string, title: string) => SessionMentionResolveResult }) { const { t } = useTranslation() const { @@ -252,7 +260,8 @@ export function HappyComposer(props: { onSchedule: onScheduleProp, onClearSchedule: onClearScheduleProp, sendError = null, - onClearSendError + onClearSendError, + resolveSessionMentionTooltip, } = props // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) @@ -304,6 +313,10 @@ export function HappyComposer(props: { const setPendingSchedule = isControlled ? onScheduleProp : setPendingScheduleLocal const textareaRef = useRef(null) + const richInputRef = useRef(null) + // Kill-switch only (?richMentions=0 / localStorage=0 / VITE=false). Mount-time + // read — hard reload required, so no per-keystroke localStorage/URL parse. + const [richMentionsEnabled] = useState(() => isRichComposerMentionsEnabled()) const prevControlledByUser = useRef(controlledByUser) const attachmentDrafts = attachments.flatMap((attachment) => { @@ -360,6 +373,10 @@ export function HappyComposer(props: { }, [sendError, api, composerText, onScheduleProp]) useEffect(() => { + if (richMentionsEnabled) { + // Rich input owns mirror text + selection via onMirrorChange. + return + } setInputState((prev) => { if (prev.text === composerText) return prev // When syncing from composerText, update selection to end of text @@ -367,7 +384,7 @@ export function HappyComposer(props: { const newPos = composerText.length return { text: composerText, selection: { start: newPos, end: newPos } } }) - }, [composerText]) + }, [composerText, richMentionsEnabled]) // Track one-time "continue" hint after switching from local to remote. useEffect(() => { @@ -403,11 +420,33 @@ export function HappyComposer(props: { const handleSuggestionSelect = useCallback((index: number) => { const suggestion = suggestions[index] - if (!suggestion || !textareaRef.current) return + if (!suggestion) return if (suggestion.text.startsWith('$')) { markSkillUsed(suggestion.text.slice(1)) } + if (richMentionsEnabled && richInputRef.current) { + // insert*/apply* emit mirror state via onMirrorChange (keep inputState in mirror space). + if (suggestion.sessionMention) { + richInputRef.current.insertSessionMention( + suggestion.sessionMention, + autocompletePrefixes + ) + } else { + richInputRef.current.applyPlainSuggestion( + suggestion.text, + autocompletePrefixes + ) + } + setTimeout(() => { + richInputRef.current?.focus() + }, 0) + haptic('light') + return + } + + if (!textareaRef.current) return + const result = applySuggestion( inputState.text, inputState.selection, @@ -434,7 +473,7 @@ export function HappyComposer(props: { }, 0) haptic('light') - }, [api, suggestions, inputState, autocompletePrefixes, haptic]) + }, [api, suggestions, inputState, autocompletePrefixes, haptic, richMentionsEnabled]) const abortDisabled = controlsDisabled || isAborting || !threadIsRunning const switchDisabled = controlsDisabled || isSwitching || !controlledByUser @@ -543,7 +582,15 @@ export function HappyComposer(props: { [permissionModeOptions] ) - const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { + /** Flush rich chips → `[title](/sessions/)` into composer.text, then send. */ + const flushAndSend = useCallback(() => { + if (richMentionsEnabled && richInputRef.current) { + richInputRef.current.flushSerializedText() + } + api.composer().send() + }, [api, richMentionsEnabled]) + + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key // Avoid intercepting IME composition keystrokes (Enter, arrows, etc.) @@ -551,9 +598,9 @@ export function HappyComposer(props: { return } - // Shift+Enter inserts a newline (standard behavior) + // Shift+Enter inserts a newline (textarea default; rich path inserts
). if (key === 'Enter' && e.shiftKey) { - return // let default textarea behavior handle newline + return } // Enter with suggestions visible: select the suggestion @@ -569,14 +616,14 @@ export function HappyComposer(props: { if (composerEnterBehavior === 'newline') { if ((e.ctrlKey || e.metaKey) && !e.altKey && canSend) { e.preventDefault() - api.composer().send() + flushAndSend() setShowContinueHint(false) } return } e.preventDefault() if (!e.ctrlKey && !e.altKey && !e.metaKey && canSend) { - api.composer().send() + flushAndSend() setShowContinueHint(false) } return @@ -635,7 +682,9 @@ export function HappyComposer(props: { canSend, api, haptic, - composerEnterBehavior + composerEnterBehavior, + richMentionsEnabled, + flushAndSend, ]) useEffect(() => { @@ -678,7 +727,7 @@ export function HappyComposer(props: { })) }, []) - const handlePaste = useCallback(async (e: ReactClipboardEvent) => { + const handlePaste = useCallback(async (e: ReactClipboardEvent) => { const files = Array.from(e.clipboardData?.files || []) const imageFiles = files.filter(file => file.type.startsWith('image/')) @@ -801,7 +850,7 @@ export function HappyComposer(props: { const voiceEnabled = Boolean(onVoiceToggle) const handleSend = useCallback(() => { - api.composer().send() + flushAndSend() // SessionChat owns clearing the schedule — it clears only after awaiting // the send hook's accepted result, which covers both pre-mutation guards // and async inactive-session resume failure. Clearing here unconditionally @@ -812,7 +861,7 @@ export function HappyComposer(props: { // the route-level state (`onSuccess`/`onError` in router.tsx) replaces // or clears it based on the actual mutation result, so the user keeps // the error context while the new attempt is in flight. - }, [api]) + }, [flushAndSend]) // Pi: selected model info for UI labels and thinking level filtering const piModelLabel = agentFlavor === 'pi' @@ -1326,20 +1375,39 @@ export function HappyComposer(props: { ) : null}
- + {richMentionsEnabled ? ( + api.composer().setText(text)} + onMirrorChange={(state) => setInputState(state)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + resolveSessionMentionTooltip={resolveSessionMentionTooltip} + onEdit={() => { + if (sendError && onClearSendError) onClearSendError() + }} + className="max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none" + /> + ) : ( + + )}
{ + it('preserves newlines between Chromium block divs (Enter-inserts-newline)', () => { + const root = document.createElement('div') + root.innerHTML = '
line1
line2
' + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('line1\nline2') + }) + + it('maps br to newlines', () => { + const root = document.createElement('div') + root.innerHTML = 'a
b' + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb') + }) + + it('preserves blank-line endings from renderSegments (br+br+pad)', () => { + // renderSegmentsToEditor maps "...\n\n" → text +
+
+ ZWSP. + // Must not strip a real trailing blank line on re-serialize. + const root = document.createElement('div') + root.appendChild(document.createTextNode('a')) + root.appendChild(document.createElement('br')) + root.appendChild(document.createElement('br')) + root.appendChild(document.createTextNode('\u200B')) + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n\n') + }) + + it('serializes Chromium-style LF text nodes without inventing extras', () => { + // plaintext-only insertLineBreak used to leave hello + \\n + \\n text nodes. + const root = document.createElement('div') + root.appendChild(document.createTextNode('hello')) + root.appendChild(document.createTextNode('\n')) + root.appendChild(document.createTextNode('\n')) + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n\n') + }) + + it('strips caret-pad ZWSP used for trailing linebreak line-boxes', () => { + const root = document.createElement('div') + root.appendChild(document.createTextNode('a')) + root.appendChild(document.createElement('br')) + root.appendChild(document.createTextNode('\u200B')) + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n') + }) + + it('keeps session atoms across block breaks', () => { + const root = document.createElement('div') + root.innerHTML = + '
see @Peer A
next
' + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe( + 'see [Peer A](/sessions/aaa)\nnext' + ) + }) + + it('serializes chip with full UUID — never chip-visible @title alone', () => { + const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b' + const title = 'hub runner version governance' + const root = document.createElement('div') + root.innerHTML = + `see @${title}` + const wire = serializeComposerSegments(segmentsFromEditor(root)) + expect(wire).toBe(`see [${title}](/sessions/${sessionId})`) + expect(wire).toContain(sessionId) + expect(wire.includes(`@${title}`)).toBe(false) + }) + + it('drops orphan session chips missing data-session-id (no title-only wire)', () => { + const root = document.createElement('div') + root.innerHTML = + 'see @orphan x' + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('see x') + }) + + it('preserves newlines inside pasted wrapper blocks (nested p/li)', () => { + const root = document.createElement('div') + root.innerHTML = '

a

b

' + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb') + + const list = document.createElement('div') + list.innerHTML = '
  • one
  • two
' + expect(serializeComposerSegments(segmentsFromEditor(list))).toBe('one\ntwo') + }) +}) + +describe('insertLineBreakAtCaret', () => { + afterEach(() => { + document.body.replaceChildren() + window.getSelection()?.removeAllRanges() + }) + + it('inserts CARET_PAD after EOL break even when insertNode leaves an empty sibling', () => { + const root = document.createElement('div') + document.body.appendChild(root) + const hello = document.createTextNode('hello') + root.appendChild(hello) + placeCaretAtEnd(root, hello) + + insertLineBreakAtCaret(root) + + const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '') + expect(texts).toContain(CARET_PAD) + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n') + }) + + it('does not pad when there is meaningful content after the caret', () => { + const root = document.createElement('div') + document.body.appendChild(root) + const text = document.createTextNode('helloworld') + root.appendChild(text) + placeCaretInText(text, 5) // between hello|world + + insertLineBreakAtCaret(root) + + expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld') + expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false) + }) +}) + +describe('mirrorOffsetFromPoint', () => { + it('maps root-anchored caret before a leading chip to offset 0', () => { + const root = document.createElement('div') + root.innerHTML = + '@Peer A after' + expect(mirrorOffsetFromPoint(root, root, 0)).toBe(0) + expect(mirrorOffsetFromPoint(root, root, 1)).toBe(1) + }) + + it('matches segmentsFromEditor length for br-separated lines', () => { + const root = document.createElement('div') + root.innerHTML = 'a
b' + const mirrorLen = serializeComposerSegments(segmentsFromEditor(root)).length + // caret after 'b' → end of second text node + const b = root.childNodes[2] as Text + expect(b.nodeType).toBe(Node.TEXT_NODE) + expect(mirrorOffsetFromPoint(root, b, b.textContent!.length)).toBe(mirrorLen) + }) +}) diff --git a/web/src/components/AssistantChat/RichComposerInput.tsx b/web/src/components/AssistantChat/RichComposerInput.tsx new file mode 100644 index 00000000..9450186b --- /dev/null +++ b/web/src/components/AssistantChat/RichComposerInput.tsx @@ -0,0 +1,865 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type ClipboardEvent as ReactClipboardEvent, + type FormEvent as ReactFormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' +import { createPortal } from 'react-dom' +import { + COMPOSER_MENTION_MIRROR_CHAR, + coalesceComposerSegments, + deleteBackwardInComposerSegments, + insertPlainTextInComposerSegments, + insertSegmentsInComposerSegments, + insertSessionMentionInComposerSegments, + mirrorComposerSegments, + parseComposerSegments, + serializeComposerSegments, + serializeComposerSelection, + type ComposerSegment, + type ComposerSelection, +} from '@/lib/composerSegments' +import { + formatSessionMentionTooltip, + type SessionMentionTooltipModel, +} from '@/lib/sessionReference' +import { SessionRowSummary } from '@/components/SessionRowSummary' +import type { SessionSummary } from '@/types/api' + +export type RichComposerInputHandle = { + focus: () => void + /** + * Re-read the contenteditable → serialize session chips to + * `[title](/sessions/)` and push into composer state. Call before + * send so the agent prompt never gets chip-visible `@title` alone. + */ + flushSerializedText: () => string + insertSessionMention: ( + mention: { id: string; title: string }, + prefixes?: string[] + ) => { text: string; selection: ComposerSelection } + applyPlainSuggestion: ( + suggestionText: string, + prefixes?: string[] + ) => { text: string; selection: ComposerSelection } +} + +export type SessionMentionResolveResult = { + model: SessionMentionTooltipModel + /** Live row for sidebar-parity chip tooltip; null → fallback text tip. */ + session: SessionSummary | null +} + +type ResolveSessionMentionTooltip = ( + id: string, + title: string +) => SessionMentionResolveResult + +type Props = { + value: string + disabled?: boolean + placeholder?: string + className?: string + autoFocus?: boolean + onValueChange: (value: string) => void + onMirrorChange: (state: { text: string; selection: ComposerSelection }) => void + onKeyDown?: (e: ReactKeyboardEvent) => void + onPaste?: (e: ReactClipboardEvent) => void + onEdit?: () => void + /** Live session meta for chip hover / aria-label (from useSessions). */ + resolveSessionMentionTooltip?: ResolveSessionMentionTooltip +} + +type MentionTooltipState = { + model: SessionMentionTooltipModel + session: SessionSummary | null + top: number + left: number +} + +function createMentionSpan( + id: string, + title: string, + resolveTooltip?: ResolveSessionMentionTooltip +): HTMLSpanElement { + const span = document.createElement('span') + span.contentEditable = 'false' + span.dataset.sessionId = id + span.dataset.sessionTitle = title + span.dataset.composerMention = 'session' + span.className = + 'mx-0.5 inline-flex max-w-[12rem] items-center truncate rounded-md bg-[var(--app-subtle-bg)] px-1.5 py-0.5 align-baseline text-[0.95em] font-medium text-[var(--app-link)]' + span.textContent = `@${title || id.slice(0, 8)}` + const tip = resolveTooltip?.(id, title)?.model + ?? formatSessionMentionTooltip(null, title, id) + span.setAttribute('aria-label', tip.ariaLabel) + return span +} + +const BLOCK_TAGS = new Set(['DIV', 'P', 'LI', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'PRE']) +/** Zero-width pad so a trailing linebreak keeps a caret line-box (pre-wrap / br). */ +const CARET_PAD = '\u200B' + +function stripCaretPad(text: string): string { + return text.replaceAll(CARET_PAD, '') +} + +/** Exported for unit tests — maps contenteditable DOM → composer segments. */ +export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] { + const segments: ComposerSegment[] = [] + let pendingBlockBreak = false + + const pushText = (text: string) => { + const cleaned = stripCaretPad(text) + if (!cleaned) return + segments.push({ type: 'text', text: cleaned }) + } + + const pushNewlineIfNeeded = () => { + if (!pendingBlockBreak) return + if (segments.length === 0) { + pendingBlockBreak = false + return + } + pushText('\n') + pendingBlockBreak = false + } + + const walk = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + pushNewlineIfNeeded() + pushText(node.textContent ?? '') + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + const el = node as HTMLElement + // Session chips are atomic. Never walk their visible `@title` text — + // that would strip the id from the agent prompt on send. + if (el.dataset.composerMention === 'session') { + pushNewlineIfNeeded() + const id = el.dataset.sessionId?.trim() + if (id) { + segments.push({ + type: 'session', + id, + title: el.dataset.sessionTitle || id.slice(0, 8), + }) + } + // Orphan chip (missing id): drop it rather than emit title-only. + return + } + if (el.tagName === 'BR') { + pushNewlineIfNeeded() + pushText('\n') + return + } + const isBlock = BLOCK_TAGS.has(el.tagName) + // Any block after existing content (Chrome Enter, pasted

/

  • , nested + // wrappers) → newline. Depth-agnostic so paste wrappers do not collapse. + if (isBlock && segments.length > 0) { + pendingBlockBreak = true + } + for (const child of Array.from(el.childNodes)) { + walk(child) + } + if (isBlock) { + pendingBlockBreak = true + } + } + for (const child of Array.from(root.childNodes)) { + walk(child) + } + return coalesceComposerSegments(segments) +} + +/** + * True when some node after `from` carries mirror-visible content. + * Range.insertNode splits the caret's text node, so a bare `\n` at EOL always + * has an empty Text nextSibling — `!nextSibling` is the wrong at-end test. + */ +function hasMeaningfulTrailingAfter(from: Node): boolean { + for (let n: Node | null = from.nextSibling; n; n = n.nextSibling) { + if (n.nodeType === Node.TEXT_NODE) { + if (stripCaretPad(n.textContent ?? '')) return true + continue + } + return true + } + return false +} + +/** + * Insert a single mirror newline at the caret. Prefer this over execCommand + * ('insertLineBreak'): in plaintext-only / pre-wrap Chromium inserts two `\n` + * text nodes (placeholder), which serializes as `\n\n` on the wire. + * Manual `\n` + CARET_PAD gives the same line-box height and serializes once. + * Exported for jsdom coverage of the EOL pad path. + */ +export function insertLineBreakAtCaret(root: HTMLElement): void { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return + + root.focus() + const range = sel.getRangeAt(0) + range.deleteContents() + const nl = document.createTextNode('\n') + range.insertNode(nl) + if (!hasMeaningfulTrailingAfter(nl)) { + const pad = document.createTextNode(CARET_PAD) + nl.parentNode?.insertBefore(pad, nl.nextSibling) + range.setStart(pad, pad.length) + } else { + range.setStart(nl, nl.length) + } + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) +} + +function renderSegmentsToEditor( + root: HTMLElement, + segments: readonly ComposerSegment[], + resolveTooltip?: ResolveSessionMentionTooltip +) { + root.replaceChildren() + for (const segment of segments) { + if (segment.type === 'text') { + const parts = segment.text.split('\n') + parts.forEach((part, index) => { + if (part) root.appendChild(document.createTextNode(part)) + if (index < parts.length - 1) root.appendChild(document.createElement('br')) + }) + // Trailing newline needs a caret target or the new line is invisible. + if (segment.text.endsWith('\n')) { + root.appendChild(document.createTextNode(CARET_PAD)) + } + continue + } + root.appendChild(createMentionSpan(segment.id, segment.title, resolveTooltip)) + } + if (root.childNodes.length === 0) { + root.appendChild(document.createTextNode('')) + } +} + +/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */ +export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number { + let count = 0 + + const visit = (n: Node): boolean => { + if (n === endContainer && n.nodeType === Node.TEXT_NODE) { + const raw = n.textContent ?? '' + count += stripCaretPad(raw.slice(0, endOffset)).length + return true + } + if (n.nodeType === Node.TEXT_NODE) { + count += stripCaretPad(n.textContent ?? '').length + return false + } + if (n.nodeType !== Node.ELEMENT_NODE) return false + const el = n as HTMLElement + if (el.dataset.composerMention === 'session') { + if (n === endContainer) { + count += endOffset > 0 ? 1 : 0 + return true + } + count += 1 + return false + } + if (el.tagName === 'BR') { + if (n === endContainer) return true + count += 1 + return false + } + if (n === endContainer) { + const children = Array.from(n.childNodes) + for (let i = 0; i < endOffset && i < children.length; i++) { + if (visit(children[i]!)) return true + } + return true + } + for (const child of Array.from(n.childNodes)) { + if (visit(child)) return true + } + return false + } + + // Root-anchored ranges (caret before a leading chip, select-all) report + // endContainer === root; visit only children of that offset. + if (endContainer === root) { + const children = Array.from(root.childNodes) + for (let i = 0; i < endOffset && i < children.length; i++) { + visit(children[i]!) + } + return count + } + + for (const child of Array.from(root.childNodes)) { + if (visit(child)) break + } + return count +} + +function getMirrorSelection(root: HTMLElement): ComposerSelection { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) { + const len = mirrorComposerSegments(segmentsFromEditor(root)).length + return { start: len, end: len } + } + const range = sel.getRangeAt(0) + if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) { + const len = mirrorComposerSegments(segmentsFromEditor(root)).length + return { start: len, end: len } + } + const start = mirrorOffsetFromPoint(root, range.startContainer, range.startOffset) + const end = mirrorOffsetFromPoint(root, range.endContainer, range.endOffset) + return { start: Math.min(start, end), end: Math.max(start, end) } +} + +function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) { + const target = Math.max(0, selection.start) + let remaining = target + const sel = window.getSelection() + if (!sel) return + + const place = (node: Node, offset: number) => { + const range = document.createRange() + range.setStart(node, offset) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + } + + const walk = (n: Node): boolean => { + if (n.nodeType === Node.TEXT_NODE) { + const raw = n.textContent ?? '' + // Caret-pad ZWSP is not part of the mirror; still a valid caret target. + if (raw === CARET_PAD) { + if (remaining === 0) { + place(n, raw.length) + return true + } + return false + } + const cleaned = stripCaretPad(raw) + if (remaining <= cleaned.length) { + // Map cleaned offset back into raw (pads have mirror width 0). + let cleanedSeen = 0 + let rawOffset = 0 + while (rawOffset < raw.length && cleanedSeen < remaining) { + if (raw[rawOffset] !== CARET_PAD) cleanedSeen += 1 + rawOffset += 1 + } + place(n, rawOffset) + return true + } + remaining -= cleaned.length + return false + } + if (n.nodeType !== Node.ELEMENT_NODE) return false + const el = n as HTMLElement + if (el.dataset.composerMention === 'session') { + const parent = el.parentNode + if (!parent) return true + const index = Array.from(parent.childNodes).indexOf(el) + if (remaining === 0) { + place(parent, index) + return true + } + if (remaining === 1) { + place(parent, index + 1) + return true + } + remaining -= 1 + return false + } + if (el.tagName === 'BR') { + const parent = el.parentNode + if (!parent) return true + if (remaining === 0) { + place(parent, Array.from(parent.childNodes).indexOf(el)) + return true + } + remaining -= 1 + return false + } + for (const child of Array.from(n.childNodes)) { + if (walk(child)) return true + } + return false + } + + for (const child of Array.from(root.childNodes)) { + if (walk(child)) return + } + place(root, root.childNodes.length) +} + +const MENTION_TOOLTIP_DELAY_MS = 300 + +/** Lazily probed once — Firefox <136 treats unknown values as inherit (not editable). */ +let supportsPlaintextOnlyCached: boolean | null = null + +function supportsPlaintextOnly(): boolean { + if (supportsPlaintextOnlyCached !== null) return supportsPlaintextOnlyCached + if (typeof document === 'undefined') { + supportsPlaintextOnlyCached = false + return false + } + try { + const probe = document.createElement('div') + probe.contentEditable = 'plaintext-only' + supportsPlaintextOnlyCached = probe.contentEditable === 'plaintext-only' + } catch { + supportsPlaintextOnlyCached = false + } + return supportsPlaintextOnlyCached +} + +function contentEditableValue(disabled: boolean): boolean | 'plaintext-only' { + if (disabled) return false + return supportsPlaintextOnly() ? 'plaintext-only' : true +} + +export const RichComposerInput = forwardRef(function RichComposerInput( + { + value, + disabled = false, + placeholder, + className, + autoFocus = false, + onValueChange, + onMirrorChange, + onKeyDown, + onPaste, + onEdit, + resolveSessionMentionTooltip, + }, + ref +) { + const rootRef = useRef(null) + // null until first sync/emit so mount-time `value` always paints into the DOM. + const lastEmittedRef = useRef(null) + const composingRef = useRef(false) + const tooltipTimerRef = useRef | null>(null) + const hoveredChipRef = useRef(null) + const [mentionTooltip, setMentionTooltip] = useState(null) + + const clearMentionTooltip = useCallback(() => { + if (tooltipTimerRef.current) { + clearTimeout(tooltipTimerRef.current) + tooltipTimerRef.current = null + } + hoveredChipRef.current = null + setMentionTooltip(null) + }, []) + + const emitFromDom = useCallback(() => { + const root = rootRef.current + if (!root) return + const segments = segmentsFromEditor(root) + const serialized = serializeComposerSegments(segments) + const selection = getMirrorSelection(root) + const mirror = mirrorComposerSegments(segments) + lastEmittedRef.current = serialized + onValueChange(serialized) + onMirrorChange({ text: mirror, selection }) + }, [onMirrorChange, onValueChange]) + + const syncFromValue = useCallback((next: string, selection?: ComposerSelection) => { + const root = rootRef.current + if (!root) return + const segments = parseComposerSegments(next) + renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip) + lastEmittedRef.current = next + clearMentionTooltip() + const mirror = mirrorComposerSegments(segments) + const sel = selection ?? { start: mirror.length, end: mirror.length } + // Placing a Selection inside contenteditable focuses it in Blink/WebKit — + // skip when the editor is not already focused (draft restore / queue edit). + const hadFocus = root.contains(document.activeElement) + if (hadFocus || selection) { + setMirrorSelection(root, sel) + } + onMirrorChange({ text: mirror, selection: sel }) + }, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip]) + + useLayoutEffect(() => { + if (value === lastEmittedRef.current) return + syncFromValue(value) + }, [value, syncFromValue]) + + useEffect(() => { + if (!autoFocus || disabled) return + const root = rootRef.current + if (!root) return + try { + root.focus({ preventScroll: true }) + } catch { + root.focus() + } + }, [autoFocus, disabled]) + + useImperativeHandle(ref, () => ({ + focus: () => { + rootRef.current?.focus() + }, + flushSerializedText: () => { + const root = rootRef.current + if (!root) return value + const segments = segmentsFromEditor(root) + const serialized = serializeComposerSegments(segments) + lastEmittedRef.current = serialized + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(segments), + selection: getMirrorSelection(root), + }) + return serialized + }, + insertSessionMention: (mention, prefixes = ['@', '/', '$']) => { + const root = rootRef.current + if (!root) { + return { text: value, selection: { start: value.length, end: value.length } } + } + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes) + const serialized = serializeComposerSegments(result.segments) + renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + return { text: serialized, selection: result.selection } + }, + applyPlainSuggestion: (suggestionText, prefixes = ['@', '/', '$']) => { + const root = rootRef.current + if (!root) { + return { text: value, selection: { start: value.length, end: value.length } } + } + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes) + const serialized = serializeComposerSegments(result.segments) + renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + return { text: serialized, selection: result.selection } + }, + }), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value]) + + useEffect(() => () => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current) + }, []) + + // While open, poll hit-test: contenteditable pointerout/relatedTarget is flaky + // (chip → prose / outside often never clears). elementFromPoint is the truth. + useEffect(() => { + if (!mentionTooltip) return + const onMove = (ev: PointerEvent) => { + if (ev.pointerType === 'touch') return + const chip = hoveredChipRef.current + if (!chip || !chip.isConnected) { + clearMentionTooltip() + return + } + const el = document.elementFromPoint(ev.clientX, ev.clientY) + if (!el || !chip.contains(el)) { + clearMentionTooltip() + } + } + const dismiss = () => clearMentionTooltip() + window.addEventListener('pointermove', onMove, { passive: true }) + window.addEventListener('scroll', dismiss, { capture: true, passive: true }) + window.addEventListener('resize', dismiss, { passive: true }) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('scroll', dismiss, true) + window.removeEventListener('resize', dismiss) + } + }, [mentionTooltip, clearMentionTooltip]) + + const showMentionTooltipForChip = useCallback((chip: HTMLElement) => { + const id = chip.dataset.sessionId + if (!id) return + const title = chip.dataset.sessionTitle || id.slice(0, 8) + const resolved = resolveSessionMentionTooltip?.(id, title) + const model = resolved?.model ?? formatSessionMentionTooltip(null, title, id) + chip.setAttribute('aria-label', model.ariaLabel) + hoveredChipRef.current = chip + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current) + tooltipTimerRef.current = setTimeout(() => { + if (hoveredChipRef.current !== chip || !chip.isConnected) return + const rect = chip.getBoundingClientRect() + setMentionTooltip({ + model, + session: resolved?.session ?? null, + top: rect.top - 8, + left: rect.left + rect.width / 2, + }) + }, MENTION_TOOLTIP_DELAY_MS) + }, [resolveSessionMentionTooltip]) + + const handlePointerOver = useCallback((e: ReactPointerEvent) => { + // Touch: no bubble (matches HoverTooltip). Mouse/pen only. + if (e.pointerType === 'touch') return + const chip = (e.target as HTMLElement | null)?.closest?.( + '[data-composer-mention="session"]' + ) as HTMLElement | null + if (!chip || !rootRef.current?.contains(chip)) { + // Over editor prose / empty space — dismiss any open chip tip. + if (hoveredChipRef.current) clearMentionTooltip() + return + } + if (hoveredChipRef.current === chip) return + showMentionTooltipForChip(chip) + }, [clearMentionTooltip, showMentionTooltipForChip]) + + const handlePointerLeave = useCallback(() => { + // Leaving the editor root entirely (does not fire for chip→prose moves). + clearMentionTooltip() + }, [clearMentionTooltip]) + + const handleInput = useCallback((_e: ReactFormEvent) => { + clearMentionTooltip() + if (composingRef.current) return + onEdit?.() + emitFromDom() + }, [clearMentionTooltip, emitFromDom, onEdit]) + + const insertPlainClipboardText = useCallback((text: string) => { + const root = rootRef.current + if (!root || !text) return + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + // Parse wire markdown so `[title](/sessions/)` paste restores chips + // (copy/cut put that format on the clipboard). Plain prose stays text. + const result = insertSegmentsInComposerSegments( + segments, + selection, + parseComposerSegments(text), + ) + const serialized = serializeComposerSegments(result.segments) + renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + onEdit?.() + }, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip]) + + const handleCopyOrCut = useCallback((e: ReactClipboardEvent, cut: boolean) => { + const root = rootRef.current + if (!root) return + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + const text = serializeComposerSelection(segments, selection) + if (text === null) return + e.preventDefault() + e.clipboardData.setData('text/plain', text) + if (!cut) return + clearMentionTooltip() + const result = deleteBackwardInComposerSegments(segments, selection) + const serialized = serializeComposerSegments(result.segments) + renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + onEdit?.() + }, [ + clearMentionTooltip, + onEdit, + onMirrorChange, + onValueChange, + resolveSessionMentionTooltip, + ]) + + const handlePaste = useCallback((e: ReactClipboardEvent) => { + const files = Array.from(e.clipboardData?.files ?? []) + const hasImage = files.some((file) => file.type.startsWith('image/')) + if (hasImage) { + onPaste?.(e) + return + } + // Contenteditable default paste inserts HTML; nested blocks collapse in + // segmentsFromEditor without depth-aware breaks. Force plain text. + e.preventDefault() + insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '') + }, [insertPlainClipboardText, onPaste]) + + // No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops + // in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215. + + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { + if (e.nativeEvent.isComposing) { + onKeyDown?.(e) + return + } + if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) { + const root = rootRef.current + if (root) { + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + const mirror = mirrorComposerSegments(segments) + const againstAtom = + selection.start === selection.end + && selection.start > 0 + && mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR + if (againstAtom || selection.start !== selection.end) { + e.preventDefault() + clearMentionTooltip() + const result = deleteBackwardInComposerSegments(segments, selection) + const serialized = serializeComposerSegments(result.segments) + renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + onEdit?.() + return + } + } + } + onKeyDown?.(e) + // Parent handles suggestion-select / send with preventDefault. If Enter + // was left alone (Shift+Enter, or Enter-inserts-newline mode), insert a + //
    instead of letting Chromium split the editor into block
    s + // that would collapse to "line1line2" on serialize. + // Any Enter the parent left unprevented (incl. Alt/Ctrl when !canSend) must + // become a
    — never Chromium block
    s (offset/serialize footguns). + if (!e.defaultPrevented && e.key === 'Enter') { + const root = rootRef.current + if (!root) return + e.preventDefault() + insertLineBreakAtCaret(root) + onEdit?.() + emitFromDom() + } + }, [ + emitFromDom, + onEdit, + onKeyDown, + onMirrorChange, + onValueChange, + resolveSessionMentionTooltip, + clearMentionTooltip, + ]) + + return ( +
    + {(!value || value.length === 0) && placeholder ? ( +
    + {placeholder} +
    + ) : null} +
    handleCopyOrCut(e, false)} + onCut={(e) => handleCopyOrCut(e, true)} + onPaste={handlePaste} + onCompositionStart={() => { + composingRef.current = true + }} + onCompositionEnd={() => { + composingRef.current = false + onEdit?.() + emitFromDom() + }} + onKeyUp={() => { + const root = rootRef.current + if (!root || composingRef.current) return + const segments = segmentsFromEditor(root) + onMirrorChange({ + text: mirrorComposerSegments(segments), + selection: getMirrorSelection(root), + }) + }} + onMouseUp={() => { + const root = rootRef.current + if (!root) return + const segments = segmentsFromEditor(root) + onMirrorChange({ + text: mirrorComposerSegments(segments), + selection: getMirrorSelection(root), + }) + }} + /> + {mentionTooltip && typeof document !== 'undefined' + ? createPortal( +
    + {mentionTooltip.session ? ( + + ) : ( + <> + + {mentionTooltip.model.title} + + {mentionTooltip.model.lines.map((line) => ( + + {line} + + ))} + + )} +
    , + document.body + ) + : null} +
    + ) +}) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 65fff1d5..9f36011d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -35,6 +35,12 @@ import { HappyThread } from '@/components/AssistantChat/HappyThread' import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar' import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel' import { useHubScratchlist } from '@/lib/use-hub-scratchlist' +import { useSessions } from '@/hooks/queries/useSessions' +import { getSessionTitle } from '@/lib/sessionTitle' +import { formatSessionMentionTooltip } from '@/lib/sessionReference' +import { classifySessionAttention, getSessionAttentionLabelKey } from '@/lib/sessionAttention' +import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' +import { formatRelativeTime } from '@/lib/relativeTime' import { ScratchlistMigrationBanner } from '@/components/AssistantChat/ScratchlistMigrationBanner' import { useHappyRuntime } from '@/lib/assistant-runtime' import type { OlderLoadOutcome } from '@/lib/message-window-store' @@ -478,6 +484,41 @@ function SessionChatInner(props: SessionChatProps) { const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) const scratchlist = useHubScratchlist(props.session.id, props.api) + const { sessions: allSessions } = useSessions(props.api) + const resolveSessionMentionTooltip = useCallback((id: string, title: string) => { + const hit = allSessions.find((s) => s.id === id) ?? null + if (!hit) { + return { + model: formatSessionMentionTooltip(null, title, id), + session: null, + } + } + const attention = classifySessionAttention(hit, { + selected: false, + lastSeenAt: getSessionLastSeenAt(hit.id), + }) + const attentionLabel = attention + ? t(getSessionAttentionLabelKey(attention)) + : null + return { + model: formatSessionMentionTooltip( + { + id: hit.id, + title: getSessionTitle(hit), + active: hit.active, + lifecycleState: hit.metadata?.lifecycleState ?? null, + path: hit.metadata?.path ?? null, + worktreePath: hit.metadata?.worktree?.worktreePath ?? null, + relativeTime: formatRelativeTime(hit.updatedAt, t), + thinking: hit.thinking, + attentionLabel, + }, + title, + id + ), + session: hit, + } + }, [allSessions, t]) const [scratchlistMode, setScratchlistMode] = useState(false) // Mode resets across sessions implicitly: SessionChat is keyed by // session.id at the public-export boundary, so a session switch @@ -1369,6 +1410,7 @@ function SessionChatInner(props: SessionChatProps) { - - - - - - - - - - ) -} - -function BulbIcon(props: { className?: string }) { - return ( - - - - - - ) -} - function ChevronIcon(props: { className?: string; collapsed?: boolean }) { return ( ) => 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 | null { - const codexSessionId = session.metadata?.agentSessionId - const importedAt = session.metadata?.flavor === 'codex' - ? getCodexImportedAt(codexSessionId) - : null - - // 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。 - if (importedAt !== null) { - return formatCodexImportedRelativeTime(importedAt, t) - } - - return formatRelativeTime(session.updatedAt, t) -} - function SessionItem(props: { session: SessionSummary onSelect: (sessionId: string) => void @@ -866,8 +774,6 @@ function SessionItem(props: { }) const sessionName = getSessionTitle(s) - const worktreeLabel = getWorktreeSessionLabel(s) - const todoProgress = getTodoProgress(s) const attention = useMemo( () => showDetailedStatus ? classifySessionAttention(s, { @@ -877,10 +783,6 @@ function SessionItem(props: { : 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 { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds( Boolean(attention), @@ -896,67 +798,15 @@ function SessionItem(props: { aria-current={selected ? 'page' : undefined} aria-describedby={describedBy} > -
    -
    - -
    - {sessionName} -
    - {s.active && s.thinking ? ( - - ) : attention ? ( - - ) : null} - {hasScheduleTooltip ? ( - } - side="bottom" - align="start" - className="shrink-0" - revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS} - > - - {scheduledLabel} - - {formatScheduledTooltipDetail(s, t)} - - - - ) : null} -
    -
    - {todoProgress ? ( - - - {todoProgress.completed}/{todoProgress.total} - - ) : null} - {!attention && s.pendingRequestsCount > 0 ? ( - - {t('session.item.pending')} {s.pendingRequestsCount} - - ) : null} - - {getSessionTimeLabel(s, t)} - -
    -
    - {showPath || worktreeLabel ? ( -
    - {worktreeLabel ?? s.metadata?.path ?? s.id} -
    - ) : null} + + + + + + + + + + + ) +} + +function BulbIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +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 | 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 | 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 ( +
    +
    +
    + +
    + {sessionName} +
    + {s.active && s.thinking ? ( + + ) : attention && nestedTooltips && attentionId ? ( + + ) : attention ? ( + + ) : null} + {hasScheduleTooltip && nestedTooltips && scheduleId ? ( + } + side="bottom" + align="start" + className="shrink-0" + revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS} + > + + {scheduledLabel} + + {formatScheduledTooltipDetail(s, t)} + + + + ) : hasScheduleTooltip ? ( + + + + ) : null} +
    +
    + {todoProgress ? ( + + + {todoProgress.completed}/{todoProgress.total} + + ) : null} + {!attention && s.pendingRequestsCount > 0 ? ( + + {t('session.item.pending')} {s.pendingRequestsCount} + + ) : null} + {timeLabel ? ( + {timeLabel} + ) : null} +
    +
    + {showPath || worktreeLabel ? ( +
    + {worktreeLabel ?? s.metadata?.path ?? s.id} +
    + ) : null} +
    + ) +} diff --git a/web/src/hooks/useActiveSuggestions.ts b/web/src/hooks/useActiveSuggestions.ts index 7bd76df6..78c50dfd 100644 --- a/web/src/hooks/useActiveSuggestions.ts +++ b/web/src/hooks/useActiveSuggestions.ts @@ -7,6 +7,8 @@ export interface Suggestion { description?: string content?: string // Expanded content for Codex user prompts source?: 'builtin' | 'user' | 'plugin' | 'project' + /** When set, rich composer inserts an inline session atom instead of `text`. */ + sessionMention?: { id: string; title: string } } interface SuggestionOptions { diff --git a/web/src/lib/composerSegments.test.ts b/web/src/lib/composerSegments.test.ts new file mode 100644 index 00000000..9c09521b --- /dev/null +++ b/web/src/lib/composerSegments.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { findActiveWord } from '@/utils/findActiveWord' +import { + COMPOSER_MENTION_MIRROR_CHAR, + deleteBackwardInComposerSegments, + insertPlainTextInComposerSegments, + insertSegmentsInComposerSegments, + insertSessionMentionInComposerSegments, + isRichComposerMentionsEnabled, + mirrorComposerSegments, + parseComposerSegments, + serializeComposerSegments, + serializeComposerSelection, + type ComposerSegment, +} from './composerSegments' + +describe('serializeComposerSegments', () => { + it('joins text and session markdown links', () => { + const segments: ComposerSegment[] = [ + { type: 'text', text: 'this → ' }, + { type: 'session', id: 'aaa', title: 'Peer A' }, + { type: 'text', text: ', that → ' }, + { type: 'session', id: 'bbb', title: 'Peer B' }, + ] + expect(serializeComposerSegments(segments)).toBe( + 'this → [Peer A](/sessions/aaa), that → [Peer B](/sessions/bbb)' + ) + }) + + it('escapes brackets in titles', () => { + const segments: ComposerSegment[] = [ + { type: 'session', id: 'x', title: 'foo [bar]' }, + ] + expect(serializeComposerSegments(segments)).toBe('[foo \\[bar\\]](/sessions/x)') + }) + + it('puts the full session UUID in the agent prompt wire (not title alone)', () => { + // Chip UI shows "@hub runner version governance"; send must expand to + // markdown with the real session id so CLI/agents can resolve it. + const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b' + const title = 'hub runner version governance' + const wire = serializeComposerSegments([ + { type: 'text', text: 'see ' }, + { type: 'session', id: sessionId, title }, + { type: 'text', text: ' please' }, + ]) + expect(wire).toBe( + `see [${title}](/sessions/${sessionId}) please` + ) + expect(wire).toContain(sessionId) + expect(wire).not.toBe(title) + expect(wire).not.toMatch(new RegExp(`^@?${title}$`)) + }) + + it('skips session atoms with empty id (never title-only wire)', () => { + expect(serializeComposerSegments([ + { type: 'text', text: 'before ' }, + { type: 'session', id: ' ', title: 'orphan title' }, + { type: 'text', text: ' after' }, + ])).toBe('before after') + }) +}) + +describe('parseComposerSegments', () => { + it('round-trips multi-mention messages', () => { + const source = 'this → [Peer A](/sessions/aaa), that → [Peer B](/sessions/bbb)' + expect(serializeComposerSegments(parseComposerSegments(source))).toBe(source) + }) + + it('treats plain prose as a single text segment', () => { + expect(parseComposerSegments('hello @world')).toEqual([ + { type: 'text', text: 'hello @world' }, + ]) + }) + + it('parses BASE_URL-prefixed session paths', () => { + expect(parseComposerSegments('[T](./app/sessions/abc)')).toEqual([ + { type: 'session', id: 'abc', title: 'T' }, + ]) + }) +}) + +describe('insertSessionMentionInComposerSegments', () => { + it('replaces active @query with a session atom at the caret', () => { + const segments: ComposerSegment[] = [ + { type: 'text', text: 'see @pee for context' }, + ] + // caret after "@pee" + const result = insertSessionMentionInComposerSegments( + segments, + { start: 8, end: 8 }, + { id: 'peer-1', title: 'Peer #1' }, + ['@', '/', '$'] + ) + expect(serializeComposerSegments(result.segments)).toBe( + 'see [Peer #1](/sessions/peer-1) for context' + ) + // caret after the mention (+ trailing space) + expect(result.selection.start).toBeGreaterThan(4) + }) + + it('expands @ pick to markdown with full session id for the agent prompt', () => { + const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b' + const title = 'hub runner version governance' + const result = insertSessionMentionInComposerSegments( + [{ type: 'text', text: 'ref @hub' }], + { start: 8, end: 8 }, + { id: sessionId, title }, + ['@'] + ) + const wire = serializeComposerSegments(result.segments) + expect(wire).toBe(`ref [${title}](/sessions/${sessionId}) `) + expect(wire.includes(sessionId)).toBe(true) + // Must not be chip-visible title alone + expect(wire.includes(`@${title}`)).toBe(false) + }) + + it('supports mid-message second mention', () => { + const segments = parseComposerSegments('A [Peer A](/sessions/aaa) then @b') + const caret = mirrorComposerSegments(segments).length + const result = insertSessionMentionInComposerSegments( + segments, + { start: caret, end: caret }, + { id: 'bbb', title: 'Peer B' }, + ['@'] + ) + expect(serializeComposerSegments(result.segments)).toBe( + 'A [Peer A](/sessions/aaa) then [Peer B](/sessions/bbb) ' + ) + }) +}) + +describe('insertPlainTextInComposerSegments', () => { + it('keeps existing session atoms when inserting a slash command', () => { + const segments = parseComposerSegments('ref [Peer A](/sessions/aaa) /hel') + const caret = mirrorComposerSegments(segments).length + const result = insertPlainTextInComposerSegments( + segments, + { start: caret, end: caret }, + '/help', + ['@', '/', '$'] + ) + expect(serializeComposerSegments(result.segments)).toBe( + 'ref [Peer A](/sessions/aaa) /help ' + ) + }) + + it('paste/drop path does not append trailing space', () => { + const empty = insertPlainTextInComposerSegments( + [], + { start: 0, end: 0 }, + 'pasted', + [], + false + ) + expect(serializeComposerSegments(empty.segments)).toBe('pasted') + + const mid = insertPlainTextInComposerSegments( + [{ type: 'text', text: 'abcd' }], + { start: 2, end: 2 }, + 'X', + [], + false + ) + expect(serializeComposerSegments(mid.segments)).toBe('abXcd') + + const multi = insertPlainTextInComposerSegments( + [], + { start: 0, end: 0 }, + 'l1\nl2', + [], + false + ) + expect(serializeComposerSegments(multi.segments)).toBe('l1\nl2') + }) +}) + +describe('findActiveWord with mention mirror atoms', () => { + it('treats U+FFFC as a word boundary so @ after a mention still triggers', () => { + const mirror = `${COMPOSER_MENTION_MIRROR_CHAR}@pee` + const active = findActiveWord(mirror, { start: mirror.length, end: mirror.length }, ['@']) + expect(active?.activeWord).toBe('@pee') + expect(active?.offset).toBe(1) + }) +}) + +describe('isRichComposerMentionsEnabled', () => { + const originalSearch = window.location.search + + afterEach(() => { + window.localStorage.removeItem('hapi.composer.richMentions') + window.history.replaceState({}, '', `${window.location.pathname}${originalSearch}`) + }) + + it('defaults to ON', () => { + window.localStorage.removeItem('hapi.composer.richMentions') + window.history.replaceState({}, '', window.location.pathname) + expect(isRichComposerMentionsEnabled()).toBe(true) + }) + + it('kills via localStorage=0', () => { + window.localStorage.setItem('hapi.composer.richMentions', '0') + expect(isRichComposerMentionsEnabled()).toBe(false) + }) + + it('kills via ?richMentions=0 (not =1 force-on)', () => { + window.history.replaceState({}, '', `${window.location.pathname}?richMentions=0`) + expect(isRichComposerMentionsEnabled()).toBe(false) + window.history.replaceState({}, '', `${window.location.pathname}?richMentions=1`) + expect(isRichComposerMentionsEnabled()).toBe(true) + }) +}) + +describe('serializeComposerSelection', () => { + it('emits wire markdown with session ids for a chip selection', () => { + const segments = parseComposerSegments('see [Peer A](/sessions/aaa) please') + // mirror: "see \uFFFC please" — select the atom only + const start = 'see '.length + const end = start + 1 + expect(serializeComposerSelection(segments, { start, end })).toBe( + '[Peer A](/sessions/aaa)' + ) + }) + + it('returns null for a caret (empty selection)', () => { + const segments: ComposerSegment[] = [{ type: 'text', text: 'abc' }] + expect(serializeComposerSelection(segments, { start: 1, end: 1 })).toBeNull() + }) +}) + +describe('insertSegmentsInComposerSegments', () => { + it('paste of wire markdown restores session atoms (not title-only text)', () => { + const segments: ComposerSegment[] = [{ type: 'text', text: 'before ' }] + const pasted = parseComposerSegments('[Peer A](/sessions/aaa) after') + const result = insertSegmentsInComposerSegments( + segments, + { start: 'before '.length, end: 'before '.length }, + pasted, + ) + expect(serializeComposerSegments(result.segments)).toBe( + 'before [Peer A](/sessions/aaa) after' + ) + }) +}) + +describe('deleteBackwardInComposerSegments', () => { + it('deletes a whole session token when caret is immediately after it', () => { + const segments = parseComposerSegments('hi [Peer A](/sessions/aaa) x') + // mirror: "hi \uFFFC x" — caret after mention + const afterMention = 'hi '.length + 1 + const result = deleteBackwardInComposerSegments(segments, { + start: afterMention, + end: afterMention, + }) + expect(serializeComposerSegments(result.segments)).toBe('hi x') + }) + + it('deletes one character in text when not against a mention', () => { + const segments: ComposerSegment[] = [{ type: 'text', text: 'abc' }] + const result = deleteBackwardInComposerSegments(segments, { start: 3, end: 3 }) + expect(result.segments).toEqual([{ type: 'text', text: 'ab' }]) + }) +}) diff --git a/web/src/lib/composerSegments.ts b/web/src/lib/composerSegments.ts new file mode 100644 index 00000000..cfe4396f --- /dev/null +++ b/web/src/lib/composerSegments.ts @@ -0,0 +1,304 @@ +import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference' +import { findActiveWord } from '@/utils/findActiveWord' + +/** Object Replacement Character — one mirror slot per session atom. */ +export const COMPOSER_MENTION_MIRROR_CHAR = '\uFFFC' + +export type ComposerTextSegment = { + type: 'text' + text: string +} + +export type ComposerSessionSegment = { + type: 'session' + id: string + title: string +} + +export type ComposerSegment = ComposerTextSegment | ComposerSessionSegment + +export type ComposerSelection = { + start: number + end: number +} + +function sanitizeMentionTitle(title: string): string { + return title.replace(/\s+/g, ' ').trim().slice(0, 120) +} + +function escapeMarkdownLinkLabel(title: string): string { + return sanitizeMentionTitle(title).replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]') +} + +function unescapeMarkdownLinkLabel(label: string): string { + return label.replace(/\\([\\\[\]])/g, '$1') +} + +/** + * Wire format for send / drafts / send-error restore. + * Session atoms become `[title](/sessions/)` so the agent prompt includes + * the full session id (not chip-visible `@title` alone). Hub + CLI pass this + * string through unchanged to every agent flavor. + */ +export function serializeComposerSegments(segments: readonly ComposerSegment[]): string { + let out = '' + for (const segment of segments) { + if (segment.type === 'text') { + out += segment.text + continue + } + const id = segment.id.trim() + if (!id) continue + const path = buildSessionReferencePath(id) + const label = escapeMarkdownLinkLabel(segment.title) || id.slice(0, 8) + out += `[${label}](${path})` + } + return out +} + +/** + * Editing mirror: text as-is, each session atom as a single `\uFFFC`. + * Selection offsets for insert/delete/activeWord live in this space. + */ +export function mirrorComposerSegments(segments: readonly ComposerSegment[]): string { + let out = '' + for (const segment of segments) { + out += segment.type === 'text' ? segment.text : COMPOSER_MENTION_MIRROR_CHAR + } + return out +} + +const SESSION_MD_LINK_RE = /\[((?:\\.|[^\]\\])*)\]\(([^)]+)\)/g + +/** Parse serialized composer text back into segments (session markdown links → atoms). */ +export function parseComposerSegments(source: string): ComposerSegment[] { + if (!source) return [{ type: 'text', text: '' }] + + const segments: ComposerSegment[] = [] + let cursor = 0 + SESSION_MD_LINK_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SESSION_MD_LINK_RE.exec(source)) !== null) { + const href = match[2] ?? '' + const sessionId = parseSessionPathHref(href) + if (!sessionId) continue + + const start = match.index + if (start > cursor) { + segments.push({ type: 'text', text: source.slice(cursor, start) }) + } + segments.push({ + type: 'session', + id: sessionId, + title: unescapeMarkdownLinkLabel(match[1] ?? '') || sessionId.slice(0, 8), + }) + cursor = start + match[0].length + } + if (cursor < source.length) { + segments.push({ type: 'text', text: source.slice(cursor) }) + } + if (segments.length === 0) { + return [{ type: 'text', text: source }] + } + return coalesceComposerSegments(segments) +} + +export function coalesceComposerSegments(segments: readonly ComposerSegment[]): ComposerSegment[] { + const out: ComposerSegment[] = [] + for (const segment of segments) { + if (segment.type === 'text' && segment.text.length === 0) continue + const prev = out[out.length - 1] + if (segment.type === 'text' && prev?.type === 'text') { + prev.text += segment.text + continue + } + out.push(segment.type === 'text' ? { type: 'text', text: segment.text } : { ...segment }) + } + if (out.length === 0) return [{ type: 'text', text: '' }] + return out +} + +function cloneComposerSegment(segment: ComposerSegment): ComposerSegment { + return segment.type === 'text' + ? { type: 'text', text: segment.text } + : { type: 'session', id: segment.id, title: segment.title } +} + +export function splitMirrorAt( + segments: readonly ComposerSegment[], + offset: number +): { before: ComposerSegment[]; after: ComposerSegment[] } { + let remaining = Math.max(0, offset) + const before: ComposerSegment[] = [] + const after: ComposerSegment[] = [] + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]! + const len = segment.type === 'text' ? segment.text.length : 1 + if (remaining > len) { + before.push(cloneComposerSegment(segment)) + remaining -= len + continue + } + if (segment.type === 'session') { + if (remaining === 0) { + after.push(cloneComposerSegment(segment), ...segments.slice(i + 1).map(cloneComposerSegment)) + } else { + // caret inside atom — treat as after the atom + before.push(cloneComposerSegment(segment)) + after.push(...segments.slice(i + 1).map(cloneComposerSegment)) + } + return { before: coalesceComposerSegments(before), after: coalesceComposerSegments(after) } + } + const left = segment.text.slice(0, remaining) + const right = segment.text.slice(remaining) + if (left) before.push({ type: 'text', text: left }) + if (right) after.push({ type: 'text', text: right }) + after.push(...segments.slice(i + 1).map(cloneComposerSegment)) + return { before: coalesceComposerSegments(before), after: coalesceComposerSegments(after) } + } + return { before: coalesceComposerSegments(before), after: coalesceComposerSegments(after) } +} + +/** + * Replace the active `@…` word (mirror space) with a session atom + trailing space. + */ +export function insertSessionMentionInComposerSegments( + segments: readonly ComposerSegment[], + selection: ComposerSelection, + mention: { id: string; title: string }, + prefixes: string[] = ['@', '/', '$'] +): { segments: ComposerSegment[]; selection: ComposerSelection } { + const mirror = mirrorComposerSegments(segments) + const active = findActiveWord(mirror, selection, prefixes) + const replaceStart = active?.offset ?? selection.start + const replaceEnd = active?.endOffset ?? selection.end + + const { before } = splitMirrorAt(segments, replaceStart) + const { after } = splitMirrorAt(segments, replaceEnd) + const title = sanitizeMentionTitle(mention.title) || mention.id.slice(0, 8) + const afterMirror = mirrorComposerSegments(after) + const needsTrailingSpace = afterMirror.length === 0 || afterMirror[0] !== ' ' + const inserted: ComposerSegment[] = [ + ...before, + { type: 'session', id: mention.id, title }, + ...(needsTrailingSpace ? [{ type: 'text' as const, text: ' ' }] : []), + ] + const next = coalesceComposerSegments([...inserted, ...after]) + const caret = mirrorComposerSegments(coalesceComposerSegments(inserted)).length + + return { + segments: next, + selection: { start: caret, end: caret }, + } +} + +/** Backspace in mirror space: deletes a whole session atom when caret is just after it. */ +export function deleteBackwardInComposerSegments( + segments: readonly ComposerSegment[], + selection: ComposerSelection +): { segments: ComposerSegment[]; selection: ComposerSelection } { + if (selection.start !== selection.end) { + const { before } = splitMirrorAt(segments, selection.start) + const { after } = splitMirrorAt(segments, selection.end) + const next = coalesceComposerSegments([...before, ...after]) + const caret = mirrorComposerSegments(before).length + return { segments: next, selection: { start: caret, end: caret } } + } + + if (selection.start <= 0) { + return { segments: coalesceComposerSegments(segments), selection } + } + + const deleteFrom = selection.start - 1 + const { before } = splitMirrorAt(segments, deleteFrom) + const { after } = splitMirrorAt(segments, selection.start) + const next = coalesceComposerSegments([...before, ...after]) + const caret = mirrorComposerSegments(before).length + return { segments: next, selection: { start: caret, end: caret } } +} + +/** + * Replace a mirror-space range with plain text (slash / skill / file @ picks). + * `addSpace` defaults true for autocomplete acceptance; paste/drop must pass false. + */ +export function insertPlainTextInComposerSegments( + segments: readonly ComposerSegment[], + selection: ComposerSelection, + text: string, + prefixes: string[] = ['@', '/', '$'], + addSpace: boolean = true +): { segments: ComposerSegment[]; selection: ComposerSelection } { + const mirror = mirrorComposerSegments(segments) + const active = findActiveWord(mirror, selection, prefixes) + const replaceStart = active?.offset ?? selection.start + const replaceEnd = active?.endOffset ?? selection.end + const { before } = splitMirrorAt(segments, replaceStart) + const { after } = splitMirrorAt(segments, replaceEnd) + const afterMirror = mirrorComposerSegments(after) + const needsTrailingSpace = + addSpace && (afterMirror.length === 0 || afterMirror[0] !== ' ') + const insert = needsTrailingSpace ? `${text} ` : text + const inserted: ComposerSegment[] = [...before, { type: 'text', text: insert }] + const next = coalesceComposerSegments([...inserted, ...after]) + const caret = mirrorComposerSegments(coalesceComposerSegments(inserted)).length + return { + segments: next, + selection: { start: caret, end: caret }, + } +} + +/** + * Serialize a mirror-space selection to wire text (session atoms keep full ids). + * Returns null when the selection is empty (browser default copy is fine). + */ +export function serializeComposerSelection( + segments: readonly ComposerSegment[], + selection: ComposerSelection, +): string | null { + if (selection.start === selection.end) return null + const start = Math.max(0, Math.min(selection.start, selection.end)) + const end = Math.max(selection.start, selection.end) + const { after } = splitMirrorAt(segments, start) + const { before: selected } = splitMirrorAt(after, end - start) + return serializeComposerSegments(selected) +} + +/** + * Insert parsed segments at the current selection (paste of wire markdown / + * plain text). Does not treat an active `@…` word as the replace range — + * paste always targets the caret/selection only. + */ +export function insertSegmentsInComposerSegments( + segments: readonly ComposerSegment[], + selection: ComposerSelection, + inserted: readonly ComposerSegment[], +): { segments: ComposerSegment[]; selection: ComposerSelection } { + const { before } = splitMirrorAt(segments, selection.start) + const { after } = splitMirrorAt(segments, selection.end) + const mid = coalesceComposerSegments(inserted) + const head = coalesceComposerSegments([...before, ...mid]) + const next = coalesceComposerSegments([...head, ...after]) + const caret = mirrorComposerSegments(head).length + return { + segments: next, + selection: { start: caret, end: caret }, + } +} + +/** + * Rich segmented composer is the product default (same as v1 @ autocomplete: + * no user opt-in). Emergency kill-switch only: + * localStorage `hapi.composer.richMentions=0` or `?richMentions=0` + * or build `VITE_RICH_COMPOSER_MENTIONS=false` + */ +export function isRichComposerMentionsEnabled(): boolean { + if (typeof window === 'undefined') return true + try { + if (window.localStorage.getItem('hapi.composer.richMentions') === '0') return false + if (new URLSearchParams(window.location.search).get('richMentions') === '0') return false + } catch { + // ignore storage / URL access failures + } + if (import.meta.env.VITE_RICH_COMPOSER_MENTIONS === 'false') return false + return true +} diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index d407ebf7..d40ac043 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -3,6 +3,7 @@ import type { SessionSummary } from '@/types/api' import { buildSessionReferencePath, buildSessionReferenceText, + formatSessionMentionTooltip, matchSessionsForMention, parseSessionPathHref, } from './sessionReference' @@ -186,3 +187,73 @@ describe('parseSessionPathHref', () => { expect(parseSessionPathHref('web/src/routes/sessions/chat.tsx')).toBeNull() }) }) + +describe('formatSessionMentionTooltip', () => { + it('uses full title, active status, ago, short id, and worktree path over metadata path', () => { + const tip = formatSessionMentionTooltip( + { + id: 'abcdef12-3456', + title: 'Peer #1215: a very long session title for chip truncation', + active: true, + path: '/home/me/coding/hapi', + worktreePath: '/home/me/coding/hapi/worktrees/session-mention-rich-composer', + relativeTime: '5m ago', + }, + 'fallback', + 'abcdef12-3456' + ) + expect(tip.title).toBe('Peer #1215: a very long session title for chip truncation') + expect(tip.lines[0]).toBe('Session · abcdef12 · Active') + expect(tip.lines[1]).toBe('5m ago') + expect(tip.lines[2]).toBe('/home/me/coding/hapi/worktrees/session-mention-rich-composer') + expect(tip.ariaLabel).toContain('5m ago') + }) + + it('prefers thinking / attention labels over bare Active', () => { + expect( + formatSessionMentionTooltip( + { + id: 'abc', + title: 'Busy', + active: true, + thinking: true, + }, + 'Busy', + 'abc' + ).lines[0] + ).toBe('Session · abc · Thinking') + + expect( + formatSessionMentionTooltip( + { + id: 'abc', + title: 'Needs you', + active: true, + attentionLabel: 'Needs input', + }, + 'Needs you', + 'abc' + ).lines[0] + ).toBe('Session · abc · Needs input') + }) + + it('labels archived sessions and falls back when session is unknown', () => { + expect( + formatSessionMentionTooltip( + { + id: 'zzz-archived', + title: 'Old notes', + active: false, + lifecycleState: 'archived', + }, + 'Old notes', + 'zzz-archived' + ).lines[0] + ).toBe('Session · zzz-arch · Archived') + + const unknown = formatSessionMentionTooltip(null, 'Chip Title', 'deadbeef-0001') + expect(unknown.title).toBe('Chip Title') + expect(unknown.lines).toEqual(['Session · deadbeef']) + expect(unknown.lines[0]).not.toContain('Active') + }) +}) diff --git a/web/src/lib/sessionReference.ts b/web/src/lib/sessionReference.ts index 6ce4a919..d3dc5653 100644 --- a/web/src/lib/sessionReference.ts +++ b/web/src/lib/sessionReference.ts @@ -113,3 +113,60 @@ export function parseSessionPathHref(href: string): string | null { 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('. '), + } +} diff --git a/web/src/lib/sessionWorktreeLabel.ts b/web/src/lib/sessionWorktreeLabel.ts new file mode 100644 index 00000000..54319578 --- /dev/null +++ b/web/src/lib/sessionWorktreeLabel.ts @@ -0,0 +1,18 @@ +import type { SessionSummary } from '@/types/api' + +/** Short worktree name for session list / mention tooltips. */ +export function getWorktreeSessionLabel(session: SessionSummary): string | null { + const worktree = session.metadata?.worktree + if (!worktree) { + return null + } + + const name = worktree.name.trim() + if (name) { + return name + } + + const path = (worktree.worktreePath ?? session.metadata?.path ?? '').replace(/[\\/]+$/, '') + const parts = path.split(/[\\/]+/).filter(Boolean) + return parts.at(-1) ?? null +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 155da9d7..dd7cdab1 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -627,8 +627,8 @@ function SessionPage() { const getAutocompleteSuggestions = useCallback(async (query: string) => { if (query.startsWith('@')) { const search = query.slice(1) - // v1: plain-text expansion (same grammar as Copy reference). - // v2: segmented rich composer with inline session tokens (#1215). + // v1: plain-text expansion (same grammar as Copy reference) — #1213. + // 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, @@ -645,6 +645,8 @@ function SessionPage() { description: s.active ? `Session · ${idPrefix} · active` : `Session · ${idPrefix}`, + // Rich composer atom; textarea path still inserts `text` prose. + sessionMention: { id: s.id, title: title || idPrefix }, } }) diff --git a/web/src/utils/findActiveWord.test.ts b/web/src/utils/findActiveWord.test.ts new file mode 100644 index 00000000..6f174dd1 --- /dev/null +++ b/web/src/utils/findActiveWord.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { findActiveWord } from './findActiveWord' + +describe('findActiveWord', () => { + it('does not let active words span newlines', () => { + expect(findActiveWord('@foo\nbar', { start: 8, end: 8 }, ['@', '/'])).toBeUndefined() + expect(findActiveWord('/help\nsome', { start: 10, end: 10 }, ['@', '/'])).toBeUndefined() + // Cold-review regression: line-2 caret must not pull @ from line 1. + expect( + findActiveWord('@abc\ndef', { start: 8, end: 8 }, ['@', '/', '$']) + ).toBeUndefined() + }) + + it('treats U+FFFC mention atoms as hard boundaries', () => { + const afterChip = findActiveWord('\uFFFC@pee', { start: 5, end: 5 }, ['@', '/']) + expect(afterChip?.activeWord).toBe('@pee') + expect(afterChip?.offset).toBe(1) + + // Prefix before a chip must not swallow the atom when caret is after it. + expect( + findActiveWord('@foo\uFFFCbar', { start: 8, end: 8 }, ['@', '/']) + ).toBeUndefined() + }) + + it('still finds @ after a space on the same line', () => { + const hit = findActiveWord('see @peer', { start: 9, end: 9 }, ['@', '/']) + expect(hit?.activeWord).toBe('@peer') + expect(hit?.offset).toBe(4) + }) +}) diff --git a/web/src/utils/findActiveWord.ts b/web/src/utils/findActiveWord.ts index e79c523d..e4a3ec5c 100644 --- a/web/src/utils/findActiveWord.ts +++ b/web/src/utils/findActiveWord.ts @@ -6,9 +6,22 @@ * @returns An object containing word info, or undefined if no prefixed word is found */ -// Characters that stop the active word search +// Characters that stop the active word search (hard stops — do not cross). const STOP_CHARACTERS = ['\n', ',', '(', ')', '[', ']', '{', '}', '<', '>', ';', '!', '?', '.'] +/** Rich-composer mirror uses U+FFFC for session atoms — hard word boundary. */ +const MENTION_MIRROR_CHAR = '\uFFFC' + +function isHardStopChar(char: string): boolean { + return char === MENTION_MIRROR_CHAR || STOP_CHARACTERS.includes(char) +} + +function isPrefixBoundaryBefore(content: string, index: number): boolean { + if (index === 0) return true + const prev = content.charAt(index - 1) + return prev === ' ' || prev === '\n' || prev === MENTION_MIRROR_CHAR +} + interface Selection { start: number end: number @@ -36,46 +49,35 @@ function findActiveWordStart( while (startIndex >= 0) { const char = content.charAt(startIndex) - // Check if we hit a space - if (char === ' ') { - if (foundPrefix) { - // We found a prefix earlier, return its position - return prefixIndex - } - if (spaceIndex >= 0) { - // Multiple spaces, stop here - return spaceIndex + 1 - } else { - spaceIndex = startIndex - startIndex-- - } - } - // Check if this is a prefix character at word boundary - else if ( - prefixes.includes(char) && - (startIndex === 0 || content.charAt(startIndex - 1) === ' ' || content.charAt(startIndex - 1) === '\n') - ) { - // For @ prefix, continue searching backwards to include the entire file path - if (char === '@') { - foundPrefix = true - prefixIndex = startIndex - // Return immediately for @ at word boundary - return startIndex - } else { - return startIndex - } - } - // Check if we hit a stop character - else if (STOP_CHARACTERS.includes(char)) { + // Hard stops (newline / mention atom / punctuation) — never cross. + if (isHardStopChar(char)) { if (foundPrefix) { return prefixIndex } return startIndex + 1 } - // Continue searching backwards - else { + // Soft space boundary (same as historical textarea behavior). + if (char === ' ') { + if (foundPrefix) { + return prefixIndex + } + if (spaceIndex >= 0) { + return spaceIndex + 1 + } + spaceIndex = startIndex startIndex-- + continue } + // Prefix at a word boundary + if (prefixes.includes(char) && isPrefixBoundaryBefore(content, startIndex)) { + if (char === '@') { + foundPrefix = true + prefixIndex = startIndex + return startIndex + } + return startIndex + } + startIndex-- } // Reached beginning of text @@ -107,8 +109,7 @@ function findActiveWordEnd( continue } - // Stop at spaces or stop characters - if (char === ' ' || STOP_CHARACTERS.includes(char)) { + if (char === ' ' || isHardStopChar(char)) { break } endIndex++