From 1e39ae2f2d1b2fca242bf2aa1f06a0ae0cc765cc Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:46:43 -0700 Subject: [PATCH] feat(desktop): token chips for slash commands and file mentions in the composer (#15070) * feat(ui): support an inline highlight range in AgentComposerTextarea * feat(desktop): highlight a leading slash command in the composer * feat(ui): multiple composer tokens, bolder chip, whole-token delete * feat(desktop): highlight known @file mentions in the composer * fix(ui): keep composer token chips within the line box so the top border isn't clipped * fix(desktop): only open the slash menu at the start of the prompt * feat(ui): export the composer token chip class * refactor(desktop): share known composer tokens outside the composer * feat(desktop): record attached files on projected user messages * feat(desktop): chip slash commands and file mentions in sent messages * fix(desktop): key chip token processors by their tokens * test(desktop): fix attached files test typecheck * fix(ui): reserve the scrollbar gutter so composer chips stay aligned * test(desktop): cover loading slash commands before the menu opens * fix(desktop): only treat the slash command catalog as loaded once it arrives * fix(desktop): mark the command catalog loaded only after both catalogs arrive * fix(desktop): strip mention punctuation without a backtracking regex and normalize mention paths * fix(desktop): match sent mentions to attached files exactly, relative to the session workspace * fix(desktop): resolve SSH sessions' attached files against the remote workspace --------- Co-authored-by: Cursor Agent --- apps/examples/desktop-app/package.json | 2 +- apps/examples/desktop-app/sidecar/commands.ts | 13 +- .../remote-environment-commands.test.ts | 3 + .../sidecar/session-data/messages.test.ts | 92 +++++++++ .../sidecar/session-data/messages.ts | 65 ++++++- .../components/ui/markdown-chip-tokens.ts | 85 ++++++++ .../webview/components/ui/markdown.test.tsx | 34 ++++ .../webview/components/ui/markdown.tsx | 30 ++- .../views/chat/chat-input-bar.test.tsx | 184 +++++++++++++----- .../components/views/chat/chat-input-bar.tsx | 54 +++-- .../views/chat/messages/message-bubble.tsx | 45 ++++- .../desktop-app/webview/lib/chat-schema.ts | 5 + .../webview/lib/composer-tokens.test.ts | 64 ++++++ .../webview/lib/composer-tokens.ts | 111 +++++++++++ sdk/packages/ui/components/agent-composer.tsx | 95 ++++++++- sdk/packages/ui/components/index.ts | 1 + sdk/packages/ui/tests/agent-composer.test.tsx | 61 +++++- 17 files changed, 862 insertions(+), 82 deletions(-) create mode 100644 apps/examples/desktop-app/webview/components/ui/markdown-chip-tokens.ts create mode 100644 apps/examples/desktop-app/webview/lib/composer-tokens.test.ts create mode 100644 apps/examples/desktop-app/webview/lib/composer-tokens.ts diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index fa7a0637be..d81bb304ce 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -32,7 +32,7 @@ "test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts", "pretest:settings-ui": "bun run build:ui", "test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx webview/components/quit-hold-handler.test.tsx webview/lib/keybindings.test.ts --config vitest.config.ts", - "test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts", + "test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/composer-tokens.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts", "test:sidecar-startup": "bun test scripts/desktop-startup.test.ts", "test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts", "clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)" diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 65e77121ce..f3194be352 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -2138,15 +2138,20 @@ export async function handleCommand( return await readSessionMessages(ctx, sessionId, maxMessages, messages); } const binding = await getCommandSessionBinding(ctx, sessionId, args); - const remoteMessages = - binding?.kind === "ssh" - ? await binding.sessionManager.readMessages(sessionId) - : undefined; + if (binding?.kind !== "ssh") { + return await readSessionMessages(ctx, sessionId, maxMessages); + } + const [remoteMessages, remoteSession] = await Promise.all([ + binding.sessionManager.readMessages(sessionId), + // Only refines mention chips, so a failed lookup must not fail the read. + binding.sessionManager.get(sessionId).catch(() => undefined), + ]); return await readSessionMessages( ctx, sessionId, maxMessages, remoteMessages, + remoteSession?.workspaceRoot || remoteSession?.cwd || undefined, ); } if (command === "read_session_hooks") { diff --git a/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts b/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts index 2126bfe335..2a59c8585b 100644 --- a/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts +++ b/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts @@ -901,8 +901,10 @@ describe("remote environment command routing", () => { ]); const update = vi.fn(async () => ({ updated: true })); const deleteSession = vi.fn(async () => true); + const getSession = vi.fn(async () => ({ workspaceRoot: "/remote/repo" })); const sessionManager = { readMessages, + get: getSession, update, delete: deleteSession, dispose: vi.fn(async () => undefined), @@ -920,6 +922,7 @@ describe("remote environment command routing", () => { }), ).resolves.toHaveLength(1); expect(readMessages).toHaveBeenCalledWith("remote-session"); + expect(getSession).toHaveBeenCalledWith("remote-session"); ctx.liveSessions.set("same-id", { environmentId: "local", diff --git a/apps/examples/desktop-app/sidecar/session-data/messages.test.ts b/apps/examples/desktop-app/sidecar/session-data/messages.test.ts index 85b4f05ab9..73f1095d82 100644 --- a/apps/examples/desktop-app/sidecar/session-data/messages.test.ts +++ b/apps/examples/desktop-app/sidecar/session-data/messages.test.ts @@ -1,4 +1,8 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSessionManifest } from "../paths"; import { readSessionMessages } from "./messages"; describe("readSessionMessages", () => { @@ -318,6 +322,94 @@ describe("readSessionMessages", () => { ]); }); + it("records file blocks of a user message relative to the mention base", async () => { + const sessionId = `file-projection-${Date.now()}`; + const dataDir = mkdtempSync(join(tmpdir(), "session-data-")); + const previousDataDir = process.env.CLINE_SESSION_DATA_DIR; + process.env.CLINE_SESSION_DATA_DIR = dataDir; + try { + writeSessionManifest(sessionId, { + session_id: sessionId, + workspace_root: "/repo", + cwd: "/repo/.worktrees/feature", + }); + const liveSessions = new Map([ + [ + sessionId, + { + messages: [ + { + id: "user-files", + role: "user", + content: [ + { type: "text", text: "Review @src/app.ts" }, + { + type: "file", + path: "/repo/src/app.ts", + content: "export {}", + }, + { + type: "file", + path: "/elsewhere/notes.md", + content: "notes", + }, + ], + }, + ], + }, + ], + ]); + + await expect( + readSessionMessages( + { liveSessions } as Parameters[0], + sessionId, + ), + ).resolves.toEqual([ + expect.objectContaining({ + content: "Review @src/app.ts", + meta: expect.objectContaining({ + attachedFiles: ["src/app.ts", "/elsewhere/notes.md"], + }), + }), + ]); + } finally { + if (previousDataDir === undefined) { + delete process.env.CLINE_SESSION_DATA_DIR; + } else { + process.env.CLINE_SESSION_DATA_DIR = previousDataDir; + } + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("resolves remote attached files against the remote mention base", async () => { + const [message] = await readSessionMessages( + { liveSessions: new Map() } as Parameters[0], + "remote-file-projection", + 800, + [ + { + id: "remote-user", + role: "user", + content: [ + { type: "text", text: "Review @src/app.ts" }, + { + type: "file", + path: "/home/dev/repo/src/app.ts", + content: "export {}", + }, + ], + }, + ], + "/home/dev/repo", + ); + expect(message).toMatchObject({ + content: "Review @src/app.ts", + meta: { attachedFiles: ["src/app.ts"] }, + }); + }); + it("preserves absolute user run counts when older messages are omitted", async () => { const sessionId = `run-count-projection-${Date.now()}`; const liveSessions = new Map([ diff --git a/apps/examples/desktop-app/sidecar/session-data/messages.ts b/apps/examples/desktop-app/sidecar/session-data/messages.ts index 7b7695f146..cafa90c2c4 100644 --- a/apps/examples/desktop-app/sidecar/session-data/messages.ts +++ b/apps/examples/desktop-app/sidecar/session-data/messages.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import nodePath, { dirname } from "node:path"; import { getUserRunSpan, projectSessionMessagesForDisplay, @@ -23,6 +23,31 @@ import { stringifyMessageContent, } from "./common"; +/** The directory the runtime resolved this session's `@` mentions against. */ +function readMentionBaseDir(sessionId: string): string | undefined { + const manifest = readSessionManifest(sessionId); + const base = manifest?.workspace_root ?? manifest?.cwd; + return typeof base === "string" && base.trim() ? base : undefined; +} + +// Relative to the mention base so the webview can match `@path` text exactly; +// paths outside it stay absolute and simply never match a relative mention. +function toMentionRelativePath( + filePath: string, + mentionBaseDir: string | undefined, +): string { + if (!mentionBaseDir) return filePath; + // SSH sessions report paths from the remote host, whose separators can + // differ from this machine's. + const pathApi = mentionBaseDir.startsWith("/") ? nodePath.posix : nodePath; + const relativePath = pathApi.relative(mentionBaseDir, filePath); + return !relativePath || + relativePath.startsWith("..") || + pathApi.isAbsolute(relativePath) + ? filePath + : relativePath.split(pathApi.sep).join("/"); +} + type ChatTurnResult = { usage?: { inputTokens?: number; @@ -338,6 +363,8 @@ export function readSessionMessagesSync( maxMessages = 800, /** Explicit authoritative source for remote sessions; bypasses local disk. */ sourceMessages?: unknown[], + /** Where a remote session resolved `@` mentions (its workspace root). */ + remoteMentionBaseDir?: string, ): unknown[] { const isRemoteRead = sourceMessages !== undefined; const persisted = isRemoteRead @@ -369,6 +396,9 @@ export function readSessionMessagesSync( ? new Map() : readCheckpointEntriesByRunCount(sessionId); const pendingToolMessages = new Map(); + const mentionBaseDir = isRemoteRead + ? remoteMentionBaseDir + : readMentionBaseDir(sessionId); let userRunCount = 0; for (let idx = 0; idx < start; idx += 1) { const rawMessage = messages[idx]; @@ -475,6 +505,7 @@ export function readSessionMessagesSync( const textParts: string[] = []; const images: Array<{ id: string; mediaType: string; data: string }> = []; + const attachedFiles: string[] = []; const reasoningParts: string[] = []; let reasoningRedacted = false; let textSegmentIndex = 0; @@ -656,6 +687,14 @@ export function readSessionMessagesSync( } continue; } + if (blockType === "file") { + if (role === "user" && typeof record.path === "string") { + attachedFiles.push( + toMentionRelativePath(record.path, mentionBaseDir), + ); + } + continue; + } if (blockType === "media" && isGeneratedMedia(record.media)) { flushTextParts(); out.push({ @@ -696,6 +735,21 @@ export function readSessionMessagesSync( textMeta = undefined; } } + if (attachedFiles.length > 0) { + // The webview chips `@` mentions in the user's text that resolved to + // one of these attachments. + const target = out + .slice(outStartIndex) + .find((item) => item.role === role && item.content); + if (target) { + target.meta = { + ...(target.meta && typeof target.meta === "object" + ? (target.meta as JsonRecord) + : {}), + attachedFiles, + }; + } + } flushReasoningParts(); if (textMeta && out[outStartIndex]) { out[outStartIndex].meta = { @@ -735,6 +789,13 @@ export async function readSessionMessages( sessionId: string, maxMessages = 800, sourceMessages?: unknown[], + remoteMentionBaseDir?: string, ): Promise { - return readSessionMessagesSync(ctx, sessionId, maxMessages, sourceMessages); + return readSessionMessagesSync( + ctx, + sessionId, + maxMessages, + sourceMessages, + remoteMentionBaseDir, + ); } diff --git a/apps/examples/desktop-app/webview/components/ui/markdown-chip-tokens.ts b/apps/examples/desktop-app/webview/components/ui/markdown-chip-tokens.ts new file mode 100644 index 0000000000..1231634ce9 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/ui/markdown-chip-tokens.ts @@ -0,0 +1,85 @@ +import { stripMentionTrailingPunctuation } from "@/lib/composer-tokens"; + +export const CHIP_TOKEN_TAG = "cline-chip-token"; + +type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + data?: Record; +}; + +const SKIPPED_NODE_TYPES = new Set([ + "code", + "definition", + "html", + "inlineCode", + "link", + "linkReference", +]); + +function chip(value: string): MarkdownNode { + return { + type: "chipToken", + data: { hName: CHIP_TOKEN_TAG }, + children: [{ type: "text", value }], + }; +} + +function splitText( + value: string, + tokens: ReadonlySet, + allowCommand: boolean, +): MarkdownNode[] { + const nodes: MarkdownNode[] = []; + let consumed = 0; + for (const match of value.matchAll(/(^|\s)([/@]\S+)/g)) { + const start = match.index + match[1].length; + const raw = match[2]; + const isCommand = raw.startsWith("/"); + if (isCommand && !(allowCommand && start === 0)) continue; + const token = isCommand ? raw : stripMentionTrailingPunctuation(raw); + if (!tokens.has(token)) continue; + if (start > consumed) { + nodes.push({ type: "text", value: value.slice(consumed, start) }); + } + nodes.push(chip(token)); + consumed = start + token.length; + } + if (consumed === 0) return [{ type: "text", value }]; + if (consumed < value.length) { + nodes.push({ type: "text", value: value.slice(consumed) }); + } + return nodes; +} + +/** + * Wraps the given exact tokens (a leading `/command`, `@path` mentions) in + * {@link CHIP_TOKEN_TAG} elements, leaving code and links untouched. + * + * Use as `[remarkChipTokens, { tokens }]`: Streamdown caches processors by + * plugin name and options, so the tokens must travel as options. + */ +export function remarkChipTokens(options: { tokens: string[] }) { + const tokens = new Set(options.tokens); + return (tree: MarkdownNode) => { + let atDocumentStart = true; + const walk = (node: MarkdownNode) => { + if (!node.children) return; + node.children = node.children.flatMap((child) => { + if (child.type === "text") { + const parts = splitText(child.value ?? "", tokens, atDocumentStart); + atDocumentStart = false; + return parts; + } + if (SKIPPED_NODE_TYPES.has(child.type) || !child.children) { + atDocumentStart = false; + return [child]; + } + walk(child); + return [child]; + }); + }; + walk(tree); + }; +} diff --git a/apps/examples/desktop-app/webview/components/ui/markdown.test.tsx b/apps/examples/desktop-app/webview/components/ui/markdown.test.tsx index 481018ef04..b2484083b4 100644 --- a/apps/examples/desktop-app/webview/components/ui/markdown.test.tsx +++ b/apps/examples/desktop-app/webview/components/ui/markdown.test.tsx @@ -2,6 +2,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test, vi } from "vitest"; import { createDesktopMarkdownPlugins, MemoizedMarkdown } from "./markdown"; +const chipTexts = (html: string) => + [...html.matchAll(/([^<]*)<\/span>/g)].map( + (match) => match[1], + ); + describe("MemoizedMarkdown", () => { test("renders structured GFM content and blocks remote images", () => { const html = renderToStaticMarkup( @@ -60,6 +65,35 @@ const ready = true; expect(html).not.toContain("flowchart LR"); }); + test("renders chip tokens in prose but not in code or mid-sentence commands", () => { + const html = renderToStaticMarkup( + , + ); + + expect(chipTexts(html)).toEqual(["/compact", "@src/app.ts"]); + expect(html).toContain(" { + const render = (tokens: string[], content: string) => + chipTexts( + renderToStaticMarkup( + , + ), + ); + + expect(render(["/team"], "/team plan it")).toEqual(["/team"]); + expect(render(["@src/app.ts"], "look at @src/app.ts")).toEqual([ + "@src/app.ts", + ]); + }); + test("names the diagram from the fence title", () => { const html = renderToStaticMarkup( ( + {children} + ), +} as Components; +const chipTokenAllowedTags = { [CHIP_TOKEN_TAG]: [] }; + export const MemoizedMarkdown = memo( ({ content, classNames, streaming = false, + chipTokens, }: { content: string; streaming?: boolean; classNames?: string; + /** Exact `/command` and `@path` tokens to render as composer chips. */ + chipTokens?: ReadonlySet; }) => { const { captureDiagramLink, @@ -387,6 +401,16 @@ export const MemoizedMarkdown = memo( recordDiagramLinkPress, setPendingUrl, } = useDiagramLinkTarget(); + const remarkPlugins = useMemo( + () => + chipTokens?.size + ? [ + ...Object.values(defaultRemarkPlugins), + [remarkChipTokens, { tokens: [...chipTokens] }], + ] + : undefined, + [chipTokens], + ); return ( // `contents` keeps the wrapper out of layout while still receiving @@ -399,8 +423,9 @@ export const MemoizedMarkdown = memo( onPointerDownCapture={recordDiagramLinkPress} > {content} diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx index 03e5274e01..03bed17c5b 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx @@ -1,6 +1,11 @@ // @vitest-environment jsdom -import { act, type MouseEvent as ReactMouseEvent } from "react"; +import { + act, + Fragment, + type MouseEvent as ReactMouseEvent, + StrictMode, +} from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceProvider } from "@/contexts/workspace-context"; @@ -200,6 +205,7 @@ async function renderVoiceComposer({ executionTarget, onAttachFiles = vi.fn(), sessionId, + strictMode = false, }: { attachments?: Parameters[0]["attachments"]; model?: string; @@ -214,47 +220,51 @@ async function renderVoiceComposer({ executionTarget?: "cloud" | "local"; onAttachFiles?: Parameters[0]["onAttachFiles"]; sessionId?: string; + strictMode?: boolean; } = {}) { + const Wrapper = strictMode ? StrictMode : Fragment; await act(async () => { root.render( - - ({ - current: "main", - branches: ["main"], - }))} - onModeToggle={vi.fn()} - onModelChange={vi.fn()} - onPromptInputChange={onPromptInputChange} - onProviderChange={vi.fn()} - onReasoningChange={vi.fn()} - onRemoveAttachment={vi.fn()} - onRemovePromptInQueue={vi.fn()} - onSend={onSend} - onSteerPromptInQueue={vi.fn()} - onSwitchGitBranch={vi.fn(async () => true)} - promptDraft={{ version: promptVersion, value: prompt }} - promptsInQueue={[]} - provider="cline" - reasoningEffort="low" - status={status} - summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }} - thinking - /> - , + + + ({ + current: "main", + branches: ["main"], + }))} + onModeToggle={vi.fn()} + onModelChange={vi.fn()} + onPromptInputChange={onPromptInputChange} + onProviderChange={vi.fn()} + onReasoningChange={vi.fn()} + onRemoveAttachment={vi.fn()} + onRemovePromptInQueue={vi.fn()} + onSend={onSend} + onSteerPromptInQueue={vi.fn()} + onSwitchGitBranch={vi.fn(async () => true)} + promptDraft={{ version: promptVersion, value: prompt }} + promptsInQueue={[]} + provider="cline" + reasoningEffort="low" + status={status} + summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }} + thinking + /> + + , ); await Promise.resolve(); }); @@ -465,6 +475,73 @@ describe("ChatInputBar", () => { expect(container.textContent).not.toContain("Session A plugin"); }); + it("loads commands up front so a skill chips before the menu opens", async () => { + const invoke = vi + .spyOn(desktopClient, "invoke") + .mockImplementation(async (name) => + name === "list_plugin_commands" + ? { status: "ready", commands: [] } + : { + runtimeCommands: [ + { name: "publish-skill", kind: "skill", description: "" }, + ], + }, + ); + const instructionRequests = () => + invoke.mock.calls.filter( + ([name]) => name === "list_user_instruction_configs", + ).length; + // StrictMode replays the mount effect; the cancelled first load must not + // count as loaded (Next dev renders the app this way). + await renderVoiceComposer({ + prompt: "/publish-skill ship it", + executionTarget: "local", + strictMode: true, + }); + expect(container.querySelector("#slash-command-suggestions")).toBeNull(); + const chip = container.querySelector("[aria-hidden] span"); + expect(chip?.textContent).toBe("/publish-skill"); + + const loadsBeforeMenu = instructionRequests(); + await renderVoiceComposer({ + prompt: "/pub", + promptVersion: 1, + executionTarget: "local", + strictMode: true, + }); + expect(instructionRequests()).toBeGreaterThan(loadsBeforeMenu); + }); + + it("retries a plugin catalog cut off by closing the menu", async () => { + const firstPluginLoad = deferred(); + let pluginRequests = 0; + vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => { + if (name !== "list_plugin_commands") return { runtimeCommands: [] }; + pluginRequests++; + return pluginRequests === 1 + ? firstPluginLoad.promise + : { status: "ready", commands: [{ name: "deploy" }] }; + }); + await renderVoiceComposer({ prompt: "/dep", executionTarget: "local" }); + await renderVoiceComposer({ + prompt: "/deploy now", + promptVersion: 1, + executionTarget: "local", + }); + const textarea = container.querySelector("textarea"); + // Moving the caret past the command closes the menu mid-load. + await act(async () => { + textarea?.setSelectionRange(11, 11); + textarea?.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true })); + }); + expect(container.querySelector("#slash-command-suggestions")).toBeNull(); + await vi.waitFor(() => + expect(container.querySelector("[aria-hidden] span")?.textContent).toBe( + "/deploy", + ), + ); + }); + it("refreshes commands on reopen without resetting cached suggestions", async () => { const updated = deferred<{ runtimeCommands: Array<{ @@ -820,6 +897,21 @@ describe("ChatInputBar", () => { expect(suggestions?.textContent).toContain("/compact"); }); + it("opens the slash menu after leading whitespace", async () => { + await renderVoiceComposer({ prompt: " /comp", executionTarget: "local" }); + expect( + container.querySelector("#slash-command-suggestions"), + ).not.toBeNull(); + }); + + it("does not open the slash menu mid-sentence", async () => { + await renderVoiceComposer({ + prompt: "please /comp", + executionTarget: "local", + }); + expect(container.querySelector("#slash-command-suggestions")).toBeNull(); + }); + it("scrolls the arrow-key selected slash command into view", async () => { await renderVoiceComposer({ prompt: "/", executionTarget: "local" }); const textarea = container.querySelector("textarea"); @@ -1177,9 +1269,10 @@ describe("ChatInputBar", () => { const promptInput = container.querySelector( 'textarea[role="combobox"]', ); - expect(promptInput?.parentElement?.className).toContain("min-h-16"); - expect(promptInput?.parentElement?.className).toContain("items-end"); - expect(promptInput?.className).toContain("self-start"); + const field = promptInput?.parentElement?.parentElement; + expect(field?.className).toContain("min-h-16"); + expect(field?.className).toContain("items-end"); + expect(promptInput?.parentElement?.className).toContain("self-start"); }); it("inserts cumulative browser fallback transcripts without duplicating text", async () => { @@ -1744,9 +1837,10 @@ describe("ChatInputBar", () => { const stopTrigger = container.querySelector( '[aria-label="Stop agent"]', ); - expect(promptInput?.parentElement?.className).toContain("items-start"); - expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true); - expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true); + const field = promptInput?.parentElement?.parentElement; + expect(field?.className).toContain("items-start"); + expect(field?.contains(sendTrigger)).toBe(true); + expect(field?.contains(stopTrigger)).toBe(true); // The mic button is hidden for now (SHOW_VOICE_INPUT_BUTTON). expect(speechTrigger).toBeNull(); expect(sendTrigger?.parentElement?.className).toContain("self-end"); diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index 4149849029..7256d00707 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -47,6 +47,12 @@ import { toast } from "@/hooks/use-toast"; import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema"; import { imageFilesFromClipboard } from "@/lib/clipboard-images"; import { cloudRepositoryLabel } from "@/lib/cloud-repositories"; +import { + addKnownMentionPaths, + getComposerHighlights, + setKnownSlashCommands, + useKnownComposerTokens, +} from "@/lib/composer-tokens"; import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client"; import { buildModelPickerData, @@ -260,8 +266,9 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null { if (slashIndex === -1) { return null; } - // Slash must be at the start or preceded by whitespace. - if (slashIndex > 0 && !/\s/.test(left[slashIndex - 1] ?? "")) { + // The sidecar only runs a command that starts the trimmed prompt, so a + // mid-sentence slash is plain text. + if (left.slice(0, slashIndex).trim() !== "") { return null; } const query = left.slice(slashIndex + 1); @@ -270,12 +277,6 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null { if (/\s/.test(query)) { return null; } - // Don't open slash mode if there's already a completed slash command earlier in the input. - const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/; - const textBeforeCurrentSlash = input.slice(0, slashIndex); - if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) { - return null; - } return { slashIndex, query }; } @@ -632,6 +633,7 @@ function ChatInputBarImpl({ const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0); const mentionResultsCacheRef = useRef(new Map()); const mentionLastRequestKeyRef = useRef(null); + const knownTokens = useKnownComposerTokens(); // ---- Slash command state ---- const activeSlash = useMemo( @@ -695,6 +697,19 @@ function ChatInputBarImpl({ const slashLoading = pluginLoading || instructionLoading; const slashError = pluginError ?? instructionError; const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); + const loadedCommandCatalogKeyRef = useRef(null); + useEffect(() => { + setKnownSlashCommands(slashCommands.map((command) => command.name)); + }, [slashCommands]); + const composerHighlights = useMemo( + () => + executionTarget === "local" + ? getComposerHighlights(promptInput, knownTokens.commandNames, (path) => + knownTokens.mentionPaths.has(path), + ) + : undefined, + [executionTarget, promptInput, knownTokens], + ); useEffect(() => { let cancelled = false; @@ -970,6 +985,7 @@ function ChatInputBarImpl({ const nextResults = Array.isArray(results) ? results : []; mentionResultsCacheRef.current.set(requestKey, nextResults); setMentionFiles(nextResults); + addKnownMentionPaths(nextResults); setMentionSelectedIndex(0); } catch { if (cancelled) { @@ -1047,16 +1063,27 @@ function ChatInputBarImpl({ ?.scrollIntoView({ block: "nearest" }); }, [mentionOpen, mentionSelectedIndex]); - // Refresh user and plugin commands whenever the slash menu opens, keeping - // the last results while fetching so suggestions do not flash away. Each - // request settles independently so one failure cannot hide the other. + // Load commands once per workspace/session up front (sent messages chip + // them too), then refresh whenever the slash menu opens, keeping the last + // results while fetching so suggestions do not flash away. Each request + // settles independently so one failure cannot hide the other. useEffect(() => { - if (!slashOpen) { + const catalogKey = `${environmentId}\u0000${workspaceRoot}\u0000${sessionId ?? ""}`; + if (!slashOpen && loadedCommandCatalogKeyRef.current === catalogKey) { setInstructionLoading(false); setPluginLoading(false); return; } let cancelled = false; + // Marked loaded only once both catalogs arrive: a cancelled (e.g. a + // StrictMode effect replay), failed, or half-finished load must be + // retried rather than leaving commands unchipped. + let pendingCatalogs = 2; + const catalogArrived = () => { + pendingCatalogs -= 1; + if (pendingCatalogs === 0) + loadedCommandCatalogKeyRef.current = catalogKey; + }; setInstructionLoading(true); setPluginLoading(true); desktopClient @@ -1066,6 +1093,7 @@ function ChatInputBarImpl({ }) .then((response) => { if (cancelled) return; + catalogArrived(); setInstructionResponse({ workspaceRoot, environmentId, response }); setInstructionError(undefined); }) @@ -1083,6 +1111,7 @@ function ChatInputBarImpl({ }) .then((catalog) => { if (cancelled) return; + if (catalog.status !== "error") catalogArrived(); setPluginResponse({ workspaceRoot, environmentId, @@ -1299,6 +1328,7 @@ function ChatInputBarImpl({ } aria-expanded={slashOpen || mentionOpen} aria-haspopup="listbox" + highlights={composerHighlights} variant={variant} onChange={(e) => { if (speechInputActive) return; diff --git a/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx b/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx index 420aa29f14..9dcd015f19 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx @@ -16,7 +16,7 @@ import { SplitIcon, UndoIcon, } from "lucide-react"; -import { memo } from "react"; +import { memo, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { resolveCredentialFailureAction } from "@/hooks/chat-session/helpers"; import type { @@ -24,6 +24,10 @@ import type { ChatMessageImage, ChatMessageMedia, } from "@/lib/chat-schema"; +import { + getComposerHighlights, + useKnownComposerTokens, +} from "@/lib/composer-tokens"; import { cn } from "@/lib/utils"; import { MemoizedMarkdown } from "../../../ui/markdown"; import { formatChatMessageContent } from "../message-content"; @@ -68,6 +72,30 @@ function MessageImages({ ); } +// Chips the same tokens the composer did: known commands, plus mentions the +// composer saw in search results or the history recorded as attached files. +function UserMessageMarkdown({ + content, + attachedFiles, +}: { + content: string; + attachedFiles?: string[]; +}) { + const { commandNames, mentionPaths } = useKnownComposerTokens(); + const tokenKey = getComposerHighlights( + content, + commandNames, + (path) => mentionPaths.has(path) || Boolean(attachedFiles?.includes(path)), + ) + .map(({ start, end }) => content.slice(start, end)) + .join("\n"); + const chipTokens = useMemo( + () => new Set(tokenKey ? tokenKey.split("\n") : []), + [tokenKey], + ); + return ; +} + function MessageMedia({ media }: { media: ChatMessageMedia[] }) { return (
@@ -258,10 +286,17 @@ export const MessageBubble = memo(function MessageBubble({ {displayContent ? (
- + {isUser ? ( + + ) : ( + + )}
) : null} diff --git a/apps/examples/desktop-app/webview/lib/chat-schema.ts b/apps/examples/desktop-app/webview/lib/chat-schema.ts index 88d6909be3..53a1c663d6 100644 --- a/apps/examples/desktop-app/webview/lib/chat-schema.ts +++ b/apps/examples/desktop-app/webview/lib/chat-schema.ts @@ -98,6 +98,11 @@ export const ChatMessageSchema = z.object({ providerAuth: ProviderAuthInfoSchema.optional(), modelId: z.string().optional(), userRunSpan: z.number().int().nonnegative().optional(), + /** + * Files attached to a user message (resolved `@` mentions), relative + * to the session's mention base directory when inside it. + */ + attachedFiles: z.array(z.string()).optional(), runCount: z.number().int().positive().optional(), checkpoint: z .object({ diff --git a/apps/examples/desktop-app/webview/lib/composer-tokens.test.ts b/apps/examples/desktop-app/webview/lib/composer-tokens.test.ts new file mode 100644 index 0000000000..b7b88bd58b --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/composer-tokens.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + getComposerHighlights, + normalizeMentionPath, + stripMentionTrailingPunctuation, +} from "./composer-tokens"; + +describe("getComposerHighlights", () => { + it("highlights only a leading, known slash command", () => { + const highlight = (input: string) => + getComposerHighlights( + input, + new Set(["compact", "release"]), + () => false, + ); + expect(highlight(" /Release v2")).toEqual([{ start: 2, end: 10 }]); + expect(highlight("/compact")).toEqual([{ start: 0, end: 8 }]); + expect(highlight("/comp")).toEqual([]); + expect(highlight("/compactly")).toEqual([]); + expect(highlight("please /compact")).toEqual([]); + }); + + it("highlights mentions of known workspace files", () => { + const known = new Set(["src/app.ts", "README.md"]); + const highlight = (input: string) => + getComposerHighlights(input, new Set(["compact"]), (path) => + known.has(path), + ); + expect(highlight("/compact @src/app.ts, then @README.md")).toEqual([ + { start: 0, end: 8 }, + { start: 9, end: 20 }, + { start: 27, end: 37 }, + ]); + expect(highlight("@src/app")).toEqual([]); + expect(highlight("mail me@src/app.ts")).toEqual([]); + }); + + it("matches normalized mention paths exactly, never by suffix", () => { + const attached = new Set(["docs/README.md"]); + const highlight = (input: string) => + getComposerHighlights(input, new Set(), (path) => attached.has(path)); + expect(highlight("Review @README.md and @docs/README.md")).toEqual([ + { start: 22, end: 37 }, + ]); + expect(highlight("see @./docs/README.md!")).toEqual([ + { start: 4, end: 21 }, + ]); + }); + + it("strips trailing punctuation without backtracking on long runs", () => { + expect(stripMentionTrailingPunctuation("src/app.ts),.")).toBe("src/app.ts"); + const adversarial = `${"!".repeat(50_000)}file.ts`; + const started = performance.now(); + expect(stripMentionTrailingPunctuation(adversarial)).toBe(adversarial); + expect(performance.now() - started).toBeLessThan(50); + }); +}); + +describe("normalizeMentionPath", () => { + it("collapses dot segments and backslashes", () => { + expect(normalizeMentionPath("./docs/../src\\app.ts")).toBe("src/app.ts"); + expect(normalizeMentionPath("/abs//path/./a.ts")).toBe("/abs/path/a.ts"); + }); +}); diff --git a/apps/examples/desktop-app/webview/lib/composer-tokens.ts b/apps/examples/desktop-app/webview/lib/composer-tokens.ts new file mode 100644 index 0000000000..c7e29f5ccd --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/composer-tokens.ts @@ -0,0 +1,111 @@ +import { useSyncExternalStore } from "react"; + +export type TokenRange = { start: number; end: number }; + +const MENTION_TRAILING_PUNCTUATION = new Set("),.:;!?`'\""); + +/** + * Drops trailing punctuation like the runtime's mention parser. A backwards + * scan rather than an anchored regex, which backtracks quadratically on long + * punctuation runs. + */ +export function stripMentionTrailingPunctuation(token: string): string { + let end = token.length; + while (end > 0 && MENTION_TRAILING_PUNCTUATION.has(token.charAt(end - 1))) { + end -= 1; + } + return token.slice(0, end); +} + +/** Collapses `./`, `..` and backslashes so `@./src/a.ts` compares as `src/a.ts`. */ +export function normalizeMentionPath(path: string): string { + const segments: string[] = []; + for (const segment of path.replace(/\\/g, "/").split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === ".." && segments.length > 0 && segments.at(-1) !== "..") { + segments.pop(); + } else { + segments.push(segment); + } + } + return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`; +} + +/** + * Ranges of `input` that the sidecar acts on: a leading `/command` (it only + * runs a command that starts the trimmed prompt) and `@path` mentions of real + * workspace files (it only attaches mentions that resolve). + */ +export function getComposerHighlights( + input: string, + commandNames: ReadonlySet, + isKnownMentionPath: (path: string) => boolean, +): TokenRange[] { + const highlights: TokenRange[] = []; + const command = /^(\s*)\/(\S+)(?=\s|$)/.exec(input); + if (command && commandNames.has(command[2].toLowerCase())) { + highlights.push({ start: command[1].length, end: command[0].length }); + } + for (const match of input.matchAll(/(^|\s)@(\S+)/g)) { + const path = stripMentionTrailingPunctuation(match[2]); + if (!path || !isKnownMentionPath(normalizeMentionPath(path))) continue; + const start = match.index + match[1].length; + highlights.push({ start, end: start + 1 + path.length }); + } + return highlights; +} + +type KnownComposerTokens = { + /** Lower-cased slash command names. */ + commandNames: ReadonlySet; + /** Workspace-relative paths seen in mention search results, normalized. */ + mentionPaths: ReadonlySet; +}; + +// Module-level so sent messages in the transcript can chip the same tokens the +// composer recognized, without threading them through every message. +let known: KnownComposerTokens = { + commandNames: new Set(), + mentionPaths: new Set(), +}; +const listeners = new Set<() => void>(); + +function update(next: KnownComposerTokens) { + known = next; + for (const listener of listeners) listener(); +} + +export function setKnownSlashCommands(names: string[]) { + const commandNames = new Set(names.map((name) => name.toLowerCase())); + if ( + commandNames.size === known.commandNames.size && + [...commandNames].every((name) => known.commandNames.has(name)) + ) { + return; + } + update({ ...known, commandNames }); +} + +export function addKnownMentionPaths(paths: string[]) { + const normalized = paths.map(normalizeMentionPath); + if (normalized.every((path) => known.mentionPaths.has(path))) return; + update({ + ...known, + mentionPaths: new Set([...known.mentionPaths, ...normalized]), + }); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useKnownComposerTokens(): KnownComposerTokens { + return useSyncExternalStore( + subscribe, + () => known, + () => known, + ); +} diff --git a/sdk/packages/ui/components/agent-composer.tsx b/sdk/packages/ui/components/agent-composer.tsx index 42fee888d4..cdd1fe939b 100644 --- a/sdk/packages/ui/components/agent-composer.tsx +++ b/sdk/packages/ui/components/agent-composer.tsx @@ -6,6 +6,7 @@ import { createContext, forwardRef, useContext, + useState, } from "react"; /** Presentation only. Hosts retain drafts, input events, menus, and runtime actions. */ @@ -96,28 +97,102 @@ export const AgentComposerField = forwardRef< ); }); +/** Chip style for composer tokens; hosts reuse it where sent messages echo them. */ +export const agentComposerTokenClassName = + "-mx-0.5 rounded-cline-ui-md bg-cline-ui-primary/25 px-0.5 py-px inset-ring inset-ring-cline-ui-primary/50"; + export type AgentComposerTextareaProps = ComponentPropsWithoutRef<"textarea"> & - VariantProps; + VariantProps & { + /** + * Sorted, non-overlapping `[start, end)` ranges of `value` drawn as inline + * tokens (slash commands, file mentions). Backspace at a token's end or + * Delete at its start removes the whole token. + */ + highlights?: { start: number; end: number }[]; + }; export const AgentComposerTextarea = forwardRef< HTMLTextAreaElement, AgentComposerTextareaProps >(function AgentComposerTextarea( - { variant: variantProp, className, ...props }, + { + variant: variantProp, + className, + highlights, + onKeyDown, + onScroll, + ...props + }, ref, ) { const variant = useAgentComposerVariant(variantProp); + const [scrollTop, setScrollTop] = useState(0); + const value = typeof props.value === "string" ? props.value : ""; + let renderedUpTo = 0; + // The wrapper is always rendered so toggling the highlight never remounts + // (and blurs) the textarea mid-typing. Both layers reserve the scrollbar + // gutter so text wraps identically once the textarea scrolls. return ( -