diff --git a/docs/frontend-ui-audit-2026-08-10/CanvasSlashCommand.md b/docs/frontend-ui-audit-2026-08-10/CanvasSlashCommand.md new file mode 100644 index 0000000000..f0b10b0cf0 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-10/CanvasSlashCommand.md @@ -0,0 +1,46 @@ +# Frontend UI Audit — Canvas Slash Command + +## Scope + +- `src/components/ComposerInput/CanvasCommandPillIcon.tsx` +- `src/components/ComposerInput/ComposerPill.tsx` +- `src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx` +- `src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.tsx` + +## D1 — Raw HTML / primitive scan + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| — | No raw interactive HTML added | keep with reason | The change renders through the existing `Button`, `BasePill`, and Lucide icon abstractions. | None. | + +## D2 — Design-system component usage + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `PinnedActionsBar/index.tsx:285` | Existing `ActionPill` click path | keep with reason | Canvas reuses the same secondary-button action surface and composer insertion path as other pinned actions. | None. | +| `ComposerPill.tsx:350` and `UserMessageContent.tsx:391` | Canvas command pill rendering | keep with reason | Both editable and history surfaces stay inside the existing shared pill containers; only the semantic icon changes. | None. | + +## D3 — Token and Tailwind consistency + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `CanvasCommandPillIcon.tsx:10` | Canvas command icon | keep with reason | Size and color come from existing pill tokens; no arbitrary Tailwind values or new visual constants were introduced. | None. | + +## D4 — Accessibility basics + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `PinnedActionsBar/index.tsx:285` | Pinned Canvas action | keep with reason | The action remains an existing `Button` with its command name as the title; the icon is decorative within a labeled pill. | None. | + +## D5 — Repeated-pattern / abstraction check + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `CanvasCommandPillIcon.tsx:6` | Canvas command detection and icon | keep with reason | A single helper and icon component are reused by composer and history instead of duplicating path matching or SVG styling. | None. | + +## Summary + +- Fix: 0 +- Keep with reason: 6 +- Abstract: 0 +- Sweep candidates: none diff --git a/docs/plans/2026-08-10-canvas-slash-command.md b/docs/plans/2026-08-10-canvas-slash-command.md new file mode 100644 index 0000000000..c966478691 --- /dev/null +++ b/docs/plans/2026-08-10-canvas-slash-command.md @@ -0,0 +1,54 @@ +# Canvas Creation Slash Command + +## Scope + +Add `/canvas` as one built-in creation command shared by the in-session composer and Session Creator. Selecting the command inserts an atomic pill, while submission keeps the serialized pill in chat history and sends a deterministic creation contract to the Agent. + +Canvas home, recent Canvas sessions, sharing, and existing-Canvas revision behavior are intentionally outside this change. + +## End-to-end data path + +1. `buildBuiltinSlashItems` registers Canvas once for both composer entry points. +2. Inline slash selection and pinned-action selection call `insertAtomicSlashActionPill`. +3. `ComposerInput` serializes the pill as `canvas [skill:/canvas]` and preserves any request typed after it. +4. The existing submit boundary expands the serialized skill pill to `/canvas`, removes editor-only payloads, and calls `resolveAgentMessageContent`. +5. Exact `/canvas` commands become an Agent-only Canvas creation contract. The original serialized text remains the user-visible and persisted message. +6. Existing session dispatch owns pending, success, failure, retry, and composer restoration behavior. + +## State machine + +| State | Trigger | Result | Exit | +| --- | --- | --- | --- | +| Idle | User types `/` or opens pinned actions | Canvas is available as a built-in action | Select or dismiss | +| Command inserted | User selects Canvas | Atomic `/canvas` pill is inserted and focused | Type a request or submit | +| Preparing | User submits | Display text is retained; Agent content is projected | Existing dispatch starts | +| Needs requirements | Bare `/canvas` | Agent is instructed to ask what to build and not call the Canvas tool yet | User replies | +| Creating | `/canvas ` | Agent is instructed to call `render_inline_canvas` exactly once for a new Canvas | Existing tool/session lifecycle | +| Failed send | Existing dispatch rejects | Existing snapshot restoration restores the composer | User retries or edits | + +No new timers, subscriptions, polling, caches, workers, or retained async resources are introduced. + +## Edge-case matrix + +| Input / condition | Expected behavior | Coverage | +| --- | --- | --- | +| `/canvas build a timer` | Create a new Canvas using the exact request | Parser and Agent-projection tests | +| Bare `/canvas` | Ask for requirements; do not call the tool | Parser test | +| Uppercase `/CANVAS` | Accept as the same command | Parser test | +| Multiline request | Preserve every request line | Parser test | +| `please use /canvas later` | Do not intercept ordinary prose | Parser test | +| `/canvasish` or `/canvas/design` | Do not intercept lookalike commands | Parser test | +| Canvas plus terminal/session context | Put the creation contract first and append context | Agent-projection test | +| Interceptors disabled | Preserve the owning composer's normal behavior | Agent-projection test | +| Inline slash menu | Use the shared built-in registry and atomic insertion helper | Registry and insertion tests | +| Pinned Canvas action | Insert the same atomic pill | Rendered component test | +| Editable and sent pill | Use the Canvas icon without changing other skill icons | Rendered component tests | + +## Acceptance criteria + +- Canvas appears in the shared built-in command list with localized copy. +- Inline and pinned entry points insert the same atomic `/canvas` pill. +- The user-visible message remains unchanged after submission. +- Only exact Canvas commands receive the Agent-side creation contract. +- Bare commands ask for requirements rather than creating an empty Canvas. +- Existing non-Canvas slash actions and skill-pill icons keep their behavior. diff --git a/src/components/ComposerInput/CanvasCommandPillIcon.tsx b/src/components/ComposerInput/CanvasCommandPillIcon.tsx new file mode 100644 index 0000000000..b8b6cda9e7 --- /dev/null +++ b/src/components/ComposerInput/CanvasCommandPillIcon.tsx @@ -0,0 +1,19 @@ +import { Layout } from "lucide-react"; +import React, { memo } from "react"; + +import { EDITOR_FILE_PILL_TEXT_COLOR, PILL_SIZE } from "@src/config/pillTokens"; + +export function isCanvasCommandPillPath(path: string): boolean { + return path.trim().toLowerCase() === "/canvas"; +} + +const CanvasCommandPillIcon: React.FC = memo(() => ( + +)); +CanvasCommandPillIcon.displayName = "CanvasCommandPillIcon"; + +export default CanvasCommandPillIcon; diff --git a/src/components/ComposerInput/ComposerPill.tsx b/src/components/ComposerInput/ComposerPill.tsx index 420d6ae6e2..a7b4a528da 100644 --- a/src/components/ComposerInput/ComposerPill.tsx +++ b/src/components/ComposerInput/ComposerPill.tsx @@ -45,6 +45,9 @@ import { openExternalLink } from "@src/util/platform/ipcRenderer"; import { resolveSessionRowIcon } from "@src/util/session/sessionSidebarRow"; import BasePill from "./BasePill"; +import CanvasCommandPillIcon, { + isCanvasCommandPillPath, +} from "./CanvasCommandPillIcon"; import { isGitHubPillUrl } from "./githubUrl"; import type { ComposerPillAttrs, PillIconType } from "./types"; import { truncateVisiblePillLabel } from "./utils"; @@ -345,6 +348,9 @@ const ComposerPill: React.FC = ({ case "dom-component": return ; case "skill": + if (isCanvasCommandPillPath(filePath)) { + return ; + } return ; case "member": return ; diff --git a/src/components/ComposerInput/__tests__/ComposerPill.canvasCommand.test.ts b/src/components/ComposerInput/__tests__/ComposerPill.canvasCommand.test.ts new file mode 100644 index 0000000000..92eed30568 --- /dev/null +++ b/src/components/ComposerInput/__tests__/ComposerPill.canvasCommand.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { EDITOR_FILE_PILL_TEXT_COLOR } from "@src/config/pillTokens"; + +import ComposerPill from "../ComposerPill"; + +describe("ComposerPill Canvas command icon", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderSkillPill(filePath: string, fileName: string) { + act(() => + root.render( + createElement(ComposerPill, { + attrs: { + filePath, + fileName, + iconType: "skill", + isFolder: false, + lineStart: null, + lineEnd: null, + }, + onDelete: vi.fn(), + }) + ) + ); + } + + it("uses the Canvas icon for the /canvas command", () => { + renderSkillPill("/canvas", "canvas"); + + const canvasIcon = container.querySelector( + ".lucide-panels-top-left" + ); + expect(canvasIcon).not.toBeNull(); + expect(canvasIcon?.style.color).toBe(EDITOR_FILE_PILL_TEXT_COLOR); + expect(container.querySelector(".lucide-toolbox")).toBeNull(); + }); + + it("keeps ordinary skill pills on the toolbox icon", () => { + renderSkillPill("/compact", "compact"); + + expect(container.querySelector(".lucide-toolbox")).not.toBeNull(); + expect(container.querySelector(".lucide-panels-top-left")).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx b/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx index 8d0e853c9c..537e402a7c 100644 --- a/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx @@ -25,6 +25,9 @@ import React, { memo, useCallback, useMemo } from "react"; import GitHubPillIcon from "@src/assets/modelIcons/github-pill.svg"; import { ChatImageThumbnailRow } from "@src/components/ChatImageThumbnail"; import BasePill from "@src/components/ComposerInput/BasePill"; +import CanvasCommandPillIcon, { + isCanvasCommandPillPath, +} from "@src/components/ComposerInput/CanvasCommandPillIcon"; import { isGitHubPillUrl, parseGitHubPillUrl, @@ -386,6 +389,9 @@ const PillIcon: React.FC<{ case "issue": return ; case "skill": + if (isCanvasCommandPillPath(path)) { + return ; + } return ; case "pr": return ; diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.canvasCommand.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.canvasCommand.test.ts new file mode 100644 index 0000000000..4bf07daa97 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.canvasCommand.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; + +import { EDITOR_FILE_PILL_TEXT_COLOR } from "@src/config/pillTokens"; + +import UserMessageContent from "../UserMessageContent"; + +describe("UserMessageContent Canvas command pill", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderMessage(text: string) { + act(() => root.render(createElement(UserMessageContent, { text }))); + } + + it("keeps the Canvas icon after the command is sent", () => { + renderMessage("canvas [skill:/canvas] 看看这个是啥"); + + const canvasIcon = container.querySelector( + ".lucide-panels-top-left" + ); + expect(canvasIcon).not.toBeNull(); + expect(canvasIcon?.style.color).toBe(EDITOR_FILE_PILL_TEXT_COLOR); + expect(container.querySelector(".lucide-toolbox")).toBeNull(); + expect(container.textContent).toContain("看看这个是啥"); + }); + + it("keeps non-Canvas skill messages on the toolbox icon", () => { + renderMessage("compact [skill:/compact] keep tests"); + + expect(container.querySelector(".lucide-toolbox")).not.toBeNull(); + expect(container.querySelector(".lucide-panels-top-left")).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.test.ts b/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.test.ts new file mode 100644 index 0000000000..9c3f07dfaa --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import React, { act, createElement, createRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { ComposerInputRef } from "@src/components/ComposerInput"; + +import PinnedActionsBar from "."; + +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), + useAtom: () => [ + [{ name: "canvas", category: "action", source: "builtin" }], + vi.fn(), + ], + useAtomValue: () => [], +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/components/Button", async () => { + const ReactModule = await import("react"); + return { + default: ReactModule.forwardRef( + ( + props: { + children?: React.ReactNode; + onClick?: React.MouseEventHandler; + title?: string; + }, + ref: React.ForwardedRef + ) => + ReactModule.createElement( + "button", + { ref, onClick: props.onClick, title: props.title }, + props.children + ) + ), + }; +}); + +vi.mock("@src/components/FileTreePreview/exports", () => ({ + FileTreeHoverPreview: ({ children }: { children: React.ReactNode }) => + children, +})); + +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/useCanvasForTurn", + () => ({ + useCanvasForTurn: () => ({ + snapshot: { isDismissed: false, latestPayload: null }, + clearCanvas: vi.fn(), + }), + }) +); + +vi.mock("@src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache", () => ({ + useSlashItemsCache: () => ({ + fetchFresh: vi.fn(async () => []), + filteredItems: [], + loading: false, + }), +})); + +vi.mock("./PinActionsPanel", () => ({ + actionKey: (action: { category: string; name: string; source: string }) => + `${action.category}:${action.source}:${action.name}`, + default: () => null, +})); + +describe("PinnedActionsBar Canvas action", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("inserts the pinned Canvas action as an atomic command pill", () => { + const insertFilePill = vi.fn(); + const focus = vi.fn(); + const composerInputRef = createRef(); + composerInputRef.current = { + focus, + insertFilePill, + } as unknown as ComposerInputRef; + + act(() => + root.render(createElement(PinnedActionsBar, { composerInputRef })) + ); + + const canvasButton = container.querySelector( + 'button[title="canvas"]' + ); + expect(canvasButton).not.toBeNull(); + + act(() => canvasButton?.click()); + + expect(insertFilePill).toHaveBeenCalledWith( + "/canvas", + false, + "skill", + "canvas" + ); + expect(focus).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.tsx b/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.tsx index 6e3babb29b..2fbda2efa4 100644 --- a/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.tsx +++ b/src/engines/ChatPanel/InputArea/components/PinnedActionsBar/index.tsx @@ -30,6 +30,7 @@ import { import { FileTreeHoverPreview } from "@src/components/FileTreePreview/exports"; import UserActionButton from "@src/engines/ChatPanel/InputArea/components/UserActionButton"; import { useCanvasForTurn } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/useCanvasForTurn"; +import { buildBuiltinSlashItems } from "@src/engines/ChatPanel/hooks/useInputArea/builtinSlashItems"; import { useSlashItemsCache } from "@src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache"; import { EditorTabService } from "@src/services/workStation/EditorTabService"; import { @@ -45,18 +46,20 @@ import { import type { SlashItem } from "@src/types/extensions"; import { SLASH_ACTIONS } from "@src/types/extensions"; -import { buildMcpToolCommand } from "../SlashCommandPortal/slashItemUtils"; +import { + buildMcpToolCommand, + buildSlashActionCommand, + insertAtomicSlashActionPill, +} from "../SlashCommandPortal/slashItemUtils"; import PinActionsPanel, { actionKey } from "./PinActionsPanel"; -const BUILTIN_SLASH_ITEMS: SlashItem[] = [ - { - name: SLASH_ACTIONS.SETUP_REPO, - description: "Auto-detect the repo and launch a one-click setup session", - category: "action", - source: "builtin", - acceptsArgs: false, - }, -]; +const SETUP_REPO_SLASH_ITEM: SlashItem = { + name: SLASH_ACTIONS.SETUP_REPO, + description: "Auto-detect the repo and launch a one-click setup session", + category: "action", + source: "builtin", + acceptsArgs: false, +}; // ── sub-components ──────────────────────────────────────────────────────────── @@ -137,6 +140,16 @@ const PinnedActionsBar: React.FC = memo( .map((folder) => folder.path.replace(/\/+$/, "")) .filter(Boolean); }, [workspaceFolders, workspacePaths]); + const builtinSlashItems = useMemo( + () => [ + ...buildBuiltinSlashItems({ + canvasDescription: t("input.canvasCommandDescription"), + compactDescription: t("input.compactCommandDescription"), + }), + SETUP_REPO_SLASH_ITEM, + ], + [t] + ); // ── Canvas pill ─────────────────────────────────────────────────────────── @@ -181,7 +194,7 @@ const PinnedActionsBar: React.FC = memo( loading: loadingItems, fetchFresh, } = useSlashItemsCache({ - builtinItems: BUILTIN_SLASH_ITEMS, + builtinItems: builtinSlashItems, workspacePaths: effectiveWorkspacePaths, }); @@ -276,6 +289,18 @@ const PinnedActionsBar: React.FC = memo( handleSetupRepo(); return; } + if (!composerInputRef.current) return; + if ( + insertAtomicSlashActionPill(composerInputRef.current, action.name) + ) { + return; + } + composerInputRef.current + .getEditor() + ?.chain() + .focus() + .insertContent(buildSlashActionCommand(action.name)) + .run(); return; } diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts new file mode 100644 index 0000000000..8482cdac8f --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildSlashActionCommand, + insertAtomicSlashActionPill, +} from "./slashItemUtils"; + +describe("built-in slash action insertion", () => { + it("inserts Canvas and Compact as atomic composer pills", () => { + for (const actionName of ["canvas", "compact"]) { + const composer = { + insertFilePill: vi.fn(), + focus: vi.fn(), + }; + + expect(insertAtomicSlashActionPill(composer, actionName)).toBe(true); + expect(composer.insertFilePill).toHaveBeenCalledWith( + `/${actionName}`, + false, + "skill", + actionName + ); + expect(composer.focus).toHaveBeenCalledOnce(); + } + }); + + it("leaves non-atomic actions to the caller's text fallback", () => { + const composer = { + insertFilePill: vi.fn(), + focus: vi.fn(), + }; + + expect(insertAtomicSlashActionPill(composer, "setup-repo")).toBe(false); + expect(composer.insertFilePill).not.toHaveBeenCalled(); + expect(buildSlashActionCommand("setup-repo")).toBe("/setup-repo "); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts index 57ea861014..8d2de28c2f 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts @@ -2,7 +2,8 @@ * Shared utilities for slash-menu item construction. * Used by useSlashItemsCache, useSlashCommand, PinnedActionsBar, and FlyoutSubmenu. */ -import type { InstalledSkill } from "@src/types/extensions"; +import type { ComposerInputRef } from "@src/components/ComposerInput"; +import { type InstalledSkill, SLASH_ACTIONS } from "@src/types/extensions"; /** * Placeholder description emitted by the Rust skill scanner when a SKILL.md @@ -88,3 +89,29 @@ export function buildMcpToolCommand( const serverSlug = serverName.replace(/-/g, "_"); return `/mcp__${serverSlug}__${toolName} `; } + +/** Build the canonical editable text inserted for a built-in slash action. */ +export function buildSlashActionCommand(actionName: string): string { + return `/${actionName} `; +} + +const ATOMIC_SLASH_ACTIONS = new Set([ + SLASH_ACTIONS.CANVAS, + SLASH_ACTIONS.COMPACT, +]); + +/** + * Insert built-in commands that behave like first-class composer tokens. + * Returning false lets callers preserve the plain-text behavior of other + * built-in actions without duplicating the token registry. + */ +export function insertAtomicSlashActionPill( + composerInput: Pick, + actionName: string +): boolean { + if (!ATOMIC_SLASH_ACTIONS.has(actionName)) return false; + + composerInput.insertFilePill(`/${actionName}`, false, "skill", actionName); + composerInput.focus(); + return true; +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/agentMessageContent.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/agentMessageContent.test.ts new file mode 100644 index 0000000000..979b9c6227 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/agentMessageContent.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAgentMessageContent } from "../agentMessageContent"; + +describe("resolveAgentMessageContent", () => { + it("keeps history text intact while projecting a Canvas creation request", () => { + const displayText = + "canvas [skill:/canvas] build a settings page with two tabs"; + + const agentContent = resolveAgentMessageContent({ + displayText, + agentBase: "/canvas build a settings page with two tabs", + hasTransformedPills: true, + contextBlocks: [], + enableAgentInterceptors: true, + }); + + expect(displayText).toBe( + "canvas [skill:/canvas] build a settings page with two tabs" + ); + expect(agentContent).toContain("render_inline_canvas exactly once"); + expect(agentContent).toContain("build a settings page with two tabs"); + expect(agentContent).not.toContain("[skill:/canvas]"); + }); + + it("appends context after the resolved Canvas contract", () => { + const agentContent = resolveAgentMessageContent({ + displayText: "canvas [skill:/canvas] use this terminal output", + agentBase: "/canvas use this terminal output", + hasTransformedPills: true, + contextBlocks: ["```\nserver ready\n```"], + enableAgentInterceptors: true, + }); + + expect(agentContent).toMatch( + /\[Canvas Creation Request\][\s\S]+\[User Request\][\s\S]+```\nserver ready\n```/ + ); + }); + + it("does not intercept messages when the owning composer opts out", () => { + expect( + resolveAgentMessageContent({ + displayText: "/canvas build a timer", + agentBase: "/canvas build a timer", + hasTransformedPills: false, + contextBlocks: [], + enableAgentInterceptors: false, + }) + ).toBeUndefined(); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/builtinSlashItems.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/builtinSlashItems.test.ts new file mode 100644 index 0000000000..f4ac0c79f2 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/builtinSlashItems.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import type { SlashItem } from "@src/types/extensions"; + +import { buildBuiltinSlashItems } from "../builtinSlashItems"; + +describe("buildBuiltinSlashItems", () => { + it("registers Canvas in the shared composer command list", () => { + const items = buildBuiltinSlashItems({ + canvasDescription: "Create a Canvas", + compactDescription: "Compact context", + }); + + expect(items[0]).toEqual({ + name: "canvas", + description: "Create a Canvas", + category: "action", + source: "builtin", + acceptsArgs: true, + }); + expect(items.map((item) => item.name)).toEqual(["canvas", "compact"]); + }); + + it("keeps optional contextual commands after stable built-ins", () => { + const addressItem: SlashItem = { + name: "address-comments", + description: "Address review comments", + category: "action", + source: "cloud", + acceptsArgs: true, + }; + + const items = buildBuiltinSlashItems({ + canvasDescription: "Create a Canvas", + compactDescription: "Compact context", + addressCommentsItem: addressItem, + }); + + expect(items).toHaveLength(3); + expect(items[2]).toBe(addressItem); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/canvasSlashCommand.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/canvasSlashCommand.test.ts new file mode 100644 index 0000000000..e4fa70dc95 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/canvasSlashCommand.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + parseCanvasSlashCommand, + resolveCanvasSlashAgentContent, +} from "../canvasSlashCommand"; + +describe("Canvas slash command", () => { + it("parses bare, instructed, and multiline commands", () => { + expect(parseCanvasSlashCommand(" /canvas ")).toEqual({}); + expect(parseCanvasSlashCommand("/CANVAS build a coffee order UI")).toEqual({ + instruction: "build a coffee order UI", + }); + expect(parseCanvasSlashCommand("/canvas\n第一行\n第二行")).toEqual({ + instruction: "第一行\n第二行", + }); + }); + + it("does not claim ordinary prose or lookalike commands", () => { + expect(parseCanvasSlashCommand("please use /canvas later")).toBeNull(); + expect(parseCanvasSlashCommand("/canvasish build this")).toBeNull(); + expect(parseCanvasSlashCommand("/canvas/design")).toBeNull(); + }); + + it("resolves an instructed command to the creation tool contract", () => { + const content = resolveCanvasSlashAgentContent( + "/canvas build a stateful timer" + ); + + expect(content).toContain("render_inline_canvas exactly once"); + expect(content).toContain("new Canvas rather than an edit"); + expect(content).toContain("build a stateful timer"); + }); + + it("asks for requirements when a bare command is submitted", () => { + const content = resolveCanvasSlashAgentContent("/canvas"); + + expect(content).toContain("Ask what they want to build"); + expect(content).toContain("Do not call render_inline_canvas yet"); + }); + + it("leaves unrelated messages unchanged", () => { + expect(resolveCanvasSlashAgentContent("draw a canvas bag")).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useInputArea/agentMessageContent.ts b/src/engines/ChatPanel/hooks/useInputArea/agentMessageContent.ts new file mode 100644 index 0000000000..809623174b --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/agentMessageContent.ts @@ -0,0 +1,39 @@ +import { resolveCanvasSlashAgentContent } from "./canvasSlashCommand"; + +interface ResolveAgentMessageContentOptions { + /** Serialized text retained in chat history. */ + displayText: string; + /** Skill-expanded, base64-free text prepared for the Agent. */ + agentBase: string; + hasTransformedPills: boolean; + contextBlocks: string[]; + enableAgentInterceptors: boolean; +} + +/** + * Produce the Agent-only message projection without mutating the history text. + */ +export function resolveAgentMessageContent({ + displayText, + agentBase, + hasTransformedPills, + contextBlocks, + enableAgentInterceptors, +}: ResolveAgentMessageContentOptions): string | undefined { + const canvasContent = enableAgentInterceptors + ? resolveCanvasSlashAgentContent(agentBase) + : null; + const resolvedBase = canvasContent ?? agentBase; + + if (contextBlocks.length > 0) { + return `${resolvedBase}\n\n${contextBlocks.join("\n\n")}`; + } + if ( + canvasContent !== null || + hasTransformedPills || + agentBase !== displayText + ) { + return resolvedBase; + } + return undefined; +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/builtinSlashItems.ts b/src/engines/ChatPanel/hooks/useInputArea/builtinSlashItems.ts new file mode 100644 index 0000000000..3295b81424 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/builtinSlashItems.ts @@ -0,0 +1,32 @@ +import { SLASH_ACTIONS, type SlashItem } from "@src/types/extensions"; + +interface BuildBuiltinSlashItemsOptions { + canvasDescription: string; + compactDescription: string; + addressCommentsItem?: SlashItem | null; +} + +/** Shared built-in command registry for ChatPanel and Session Creator. */ +export function buildBuiltinSlashItems({ + canvasDescription, + compactDescription, + addressCommentsItem, +}: BuildBuiltinSlashItemsOptions): SlashItem[] { + return [ + { + name: SLASH_ACTIONS.CANVAS, + description: canvasDescription, + category: "action", + source: "builtin", + acceptsArgs: true, + }, + { + name: SLASH_ACTIONS.COMPACT, + description: compactDescription, + category: "action", + source: "builtin", + acceptsArgs: true, + }, + ...(addressCommentsItem ? [addressCommentsItem] : []), + ]; +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/canvasSlashCommand.ts b/src/engines/ChatPanel/hooks/useInputArea/canvasSlashCommand.ts new file mode 100644 index 0000000000..535d8ffd38 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/canvasSlashCommand.ts @@ -0,0 +1,32 @@ +interface CanvasSlashCommand { + /** Optional creation request following `/canvas`. */ + instruction?: string; +} + +/** + * Parse a start-anchored `/canvas [request]` command without claiming ordinary + * prose that merely contains the same text. + */ +export function parseCanvasSlashCommand( + text: string +): CanvasSlashCommand | null { + const match = /^\/canvas(?:\s+([\s\S]+))?$/i.exec(text.trim()); + if (!match) return null; + const instruction = match[1]?.trim(); + return instruction ? { instruction } : {}; +} + +/** + * Keep `/canvas …` as the user-visible message while giving the Agent an + * explicit, deterministic Canvas tool contract. + */ +export function resolveCanvasSlashAgentContent(text: string): string | null { + const command = parseCanvasSlashCommand(text); + if (!command) return null; + + if (!command.instruction) { + return `[Canvas Creation Request]\nThe user opened the Canvas creation command without a request. Ask what they want to build before creating anything. Do not call render_inline_canvas yet.`; + } + + return `[Canvas Creation Request]\nCreate a new interactive inline Canvas for the user request below. Call render_inline_canvas exactly once for the finished Canvas. Treat this as a new Canvas rather than an edit to an existing Canvas. Preserve the user's requested behavior and language.\n\n[User Request]\n${command.instruction}`; +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts index 5cde0949ab..f92ef87095 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts @@ -17,7 +17,11 @@ import { execModeForComposerSelection, resolveSessionAgentExecMode, } from "@src/config/sessionCreatorConfig"; -import { buildMcpToolCommand } from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils"; +import { + buildMcpToolCommand, + buildSlashActionCommand, + insertAtomicSlashActionPill, +} from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils"; import { buildAddressCommentsPillPath } from "@src/features/Org2Cloud/addressCommentsSlashToken"; import { ADDRESS_COMMENTS_SLASH_SOURCE, @@ -30,12 +34,13 @@ import { } from "@src/hooks/session/useSessionPatch"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; import { creatorDefaultProductModeAtom } from "@src/store/session/creatorDefaultProductModeAtom"; -import { SLASH_ACTIONS, type SlashItem } from "@src/types/extensions"; +import type { SlashItem } from "@src/types/extensions"; import { isAgentSession, isCliSession, } from "@src/util/session/sessionDispatch"; +import { buildBuiltinSlashItems } from "./builtinSlashItems"; import { useSlashItemsCache } from "./useSlashItemsCache"; interface UseSlashCommandOptions { @@ -175,16 +180,12 @@ export function useSlashCommand( ); const addressCommentsItem = addressComments.item; const builtinSlashItems = useMemo( - () => [ - { - name: SLASH_ACTIONS.COMPACT, - description: t("input.compactCommandDescription"), - category: "action", - source: "builtin", - acceptsArgs: true, - }, - ...(addressCommentsItem ? [addressCommentsItem] : []), - ], + () => + buildBuiltinSlashItems({ + canvasDescription: t("input.canvasCommandDescription"), + compactDescription: t("input.compactCommandDescription"), + addressCommentsItem, + }), [t, addressCommentsItem] ); @@ -286,25 +287,17 @@ export function useSlashCommand( return; } - // The compact command renders as a pill (like skills) so the token - // reads as one unit with the focus text typed after it. The submit - // interceptor recognizes both the pill serialization and plain - // "/compact" text (parseCompactSlashCommand). - if (item.category === "action" && item.name === SLASH_ACTIONS.COMPACT) { - composerInputRef.current.insertFilePill( - `/${SLASH_ACTIONS.COMPACT}`, - false, - "skill", - SLASH_ACTIONS.COMPACT - ); - composerInputRef.current.focus(); + if ( + item.category === "action" && + insertAtomicSlashActionPill(composerInputRef.current, item.name) + ) { setShowSlashMenu(false); setSlashQuery(""); queryRef.current = ""; return; } - composerInputRef.current.setContent(`/${item.name} `); + composerInputRef.current.setContent(buildSlashActionCommand(item.name)); composerInputRef.current.focus(); setShowSlashMenu(false); diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts index b5729c8f05..96f666fd28 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts @@ -36,6 +36,7 @@ import { parseCompactSlashCommand, useManualCompact, } from "../useManualCompact"; +import { resolveAgentMessageContent } from "./agentMessageContent"; import { resolveMcpSlashCommand } from "./mcpSlashCommand"; import type { CiteCodeSnapshot, @@ -313,7 +314,6 @@ export function useSubmitMessage({ const terminalTexts = refs.composerInputRef.current.getTerminalPillTexts(); const terminalEntries = Object.entries(terminalTexts); - let agentContent: string | undefined; // The text the LLM sees must not carry the editor-internal `::base64` // pill payload. `displayText` keeps the full serialized form for history // rendering / re-editing; `base` is the agent-facing copy. @@ -379,14 +379,13 @@ export function useSubmitMessage({ contextBlocks.push(...sessionRefs); } - if (contextBlocks.length > 0) { - agentContent = base + "\n\n" + contextBlocks.join("\n\n"); - } else if (hasSkillPills || base !== displayText) { - // `base !== displayText` means base64 pill payload was stripped — send - // the cleaned copy so the LLM never receives the raw blob even if no - // context/skill block was produced. - agentContent = base; - } + const agentContent = resolveAgentMessageContent({ + displayText, + agentBase: base, + hasTransformedPills: hasSkillPills, + contextBlocks, + enableAgentInterceptors, + }); const imageDataUrls = imageAttachment.images.map((img) => img.dataUrl); const submitKey = JSON.stringify({ diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index 071ae3de68..7dbdfe0d6a 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -846,7 +846,8 @@ "insert": "Einfügen" }, "compactArgHint": "", - "compactCommandDescription": "Älteren Kontext zusammenfassen, um Platz freizugeben. Optional: /compact " + "compactCommandDescription": "Älteren Kontext zusammenfassen, um Platz freizugeben. Optional: /compact ", + "canvasCommandDescription": "Eine neue interaktive Canvas erstellen. Optional: /canvas " }, "listPanel": { "showingOf": "{{filtered}} von {{total}} angezeigt", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 93f0f63114..6501bb59f7 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -902,7 +902,8 @@ "insert": "Insert" }, "compactArgHint": "", - "compactCommandDescription": "Summarize older context to free space. Optional: /compact " + "compactCommandDescription": "Summarize older context to free space. Optional: /compact ", + "canvasCommandDescription": "Create a new interactive Canvas. Optional: /canvas " }, "listPanel": { "showingOf": "Showing {{filtered}} of {{total}}", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index b571963e14..7334a266c9 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -848,7 +848,8 @@ "insert": "Insertar" }, "compactArgHint": "", - "compactCommandDescription": "Resume el contexto anterior para liberar espacio. Opcional: /compact " + "compactCommandDescription": "Resume el contexto anterior para liberar espacio. Opcional: /compact ", + "canvasCommandDescription": "Crea un nuevo Canvas interactivo. Opcional: /canvas " }, "listPanel": { "showingOf": "Mostrando {{filtered}} de {{total}}", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index fc24840646..fe082bf7ba 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -848,7 +848,8 @@ "insert": "Insérer" }, "compactArgHint": "", - "compactCommandDescription": "Résume le contexte ancien pour libérer de l'espace. Facultatif : /compact " + "compactCommandDescription": "Résume le contexte ancien pour libérer de l'espace. Facultatif : /compact ", + "canvasCommandDescription": "Crée un nouveau Canvas interactif. Facultatif : /canvas " }, "listPanel": { "showingOf": "Affichage de {{filtered}} sur {{total}}", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index c45873651b..0204db4c7b 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -847,7 +847,8 @@ "insert": "挿入" }, "compactArgHint": "<任意: 要約の重点>", - "compactCommandDescription": "古いコンテキストを要約して空きを作ります。任意: /compact <要約の重点>" + "compactCommandDescription": "古いコンテキストを要約して空きを作ります。任意: /compact <要約の重点>", + "canvasCommandDescription": "新しいインタラクティブ Canvas を作成します。任意: /canvas <作成する内容>" }, "listPanel": { "showingOf": "{{total}} 件中 {{filtered}} 件を表示", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index f2114da3a6..bb0836ec4b 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -847,7 +847,8 @@ "insert": "삽입" }, "compactArgHint": "<선택: 요약의 초점>", - "compactCommandDescription": "이전 컨텍스트를 요약해 공간을 확보합니다. 선택: /compact <요약의 초점>" + "compactCommandDescription": "이전 컨텍스트를 요약해 공간을 확보합니다. 선택: /compact <요약의 초점>", + "canvasCommandDescription": "새로운 대화형 Canvas를 만듭니다. 선택: /canvas <만들 내용>" }, "listPanel": { "showingOf": "{{total}}개 중 {{filtered}}개 표시", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index ae3a92d5be..847f4623a1 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -849,7 +849,8 @@ "insert": "Wstaw" }, "compactArgHint": "", - "compactCommandDescription": "Podsumuj starszy kontekst, aby zwolnić miejsce. Opcjonalnie: /compact " + "compactCommandDescription": "Podsumuj starszy kontekst, aby zwolnić miejsce. Opcjonalnie: /compact ", + "canvasCommandDescription": "Utwórz nowy interaktywny Canvas. Opcjonalnie: /canvas " }, "listPanel": { "showingOf": "Wyświetlono {{filtered}} z {{total}}", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index cd83de9018..e2fcd20811 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -847,7 +847,8 @@ "insert": "Inserir" }, "compactArgHint": "", - "compactCommandDescription": "Resume o contexto antigo para liberar espaço. Opcional: /compact " + "compactCommandDescription": "Resume o contexto antigo para liberar espaço. Opcional: /compact ", + "canvasCommandDescription": "Crie um novo Canvas interativo. Opcional: /canvas " }, "listPanel": { "showingOf": "Mostrando {{filtered}} de {{total}}", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 8910f1c342..df13e75851 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -852,7 +852,8 @@ "insert": "Вставить" }, "compactArgHint": "<необязательный фокус резюме>", - "compactCommandDescription": "Обобщить старый контекст, чтобы освободить место. Необязательно: /compact <фокус резюме>" + "compactCommandDescription": "Обобщить старый контекст, чтобы освободить место. Необязательно: /compact <фокус резюме>", + "canvasCommandDescription": "Создать новый интерактивный Canvas. Необязательно: /canvas <что создать>" }, "listPanel": { "showingOf": "Показано {{filtered}} из {{total}}", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index 71f067d565..36f18a5278 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -848,7 +848,8 @@ "insert": "Ekle" }, "compactArgHint": "<özet için isteğe bağlı odak>", - "compactCommandDescription": "Eski bağlamı özetleyerek yer açar. İsteğe bağlı: /compact <özet odağı>" + "compactCommandDescription": "Eski bağlamı özetleyerek yer açar. İsteğe bağlı: /compact <özet odağı>", + "canvasCommandDescription": "Yeni bir etkileşimli Canvas oluşturur. İsteğe bağlı: /canvas " }, "listPanel": { "showingOf": "{{total}} içinden {{filtered}} gösteriliyor", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 7b2f9a4179..85768e22ec 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -845,7 +845,8 @@ "insert": "Chèn" }, "compactArgHint": "", - "compactCommandDescription": "Tóm tắt ngữ cảnh cũ để giải phóng dung lượng. Tùy chọn: /compact " + "compactCommandDescription": "Tóm tắt ngữ cảnh cũ để giải phóng dung lượng. Tùy chọn: /compact ", + "canvasCommandDescription": "Tạo một Canvas tương tác mới. Tùy chọn: /canvas " }, "listPanel": { "showingOf": "Hiển thị {{filtered}} / {{total}}", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 8330e570a1..227a62b804 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -862,7 +862,8 @@ "insert": "插入" }, "compactArgHint": "<可選:本次總結的重點>", - "compactCommandDescription": "總結較早的上下文以釋放空間。可選:/compact <本次總結的重點>" + "compactCommandDescription": "總結較早的上下文以釋放空間。可選:/compact <本次總結的重點>", + "canvasCommandDescription": "建立一個新的互動式 Canvas。可選:/canvas <要建立的內容>" }, "listPanel": { "showingOf": "顯示 {{filtered}} / {{total}}", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 8bd60e4c98..88471d4010 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -893,7 +893,8 @@ "insert": "插入" }, "compactArgHint": "<可选:本次总结的重点>", - "compactCommandDescription": "总结较早的上下文以释放空间。可选:/compact <本次总结的重点>" + "compactCommandDescription": "总结较早的上下文以释放空间。可选:/compact <本次总结的重点>", + "canvasCommandDescription": "创建一个新的交互式 Canvas。可选:/canvas <要创建的内容>" }, "listPanel": { "showingOf": "显示 {{filtered}} / {{total}}", diff --git a/src/types/extensions/types.ts b/src/types/extensions/types.ts index ee0563a343..245b096b1d 100644 --- a/src/types/extensions/types.ts +++ b/src/types/extensions/types.ts @@ -169,4 +169,6 @@ export const SLASH_ACTIONS = { * (`parseCompactSlashCommand`) matches the `/compact` token. */ COMPACT: "compact", + /** Creates a new interactive inline Canvas from the following request. */ + CANVAS: "canvas", } as const;