Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/frontend-ui-audit-2026-08-10/CanvasSlashCommand.md
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions docs/plans/2026-08-10-canvas-slash-command.md
Original file line number Diff line number Diff line change
@@ -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 <request>` | 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.
19 changes: 19 additions & 0 deletions src/components/ComposerInput/CanvasCommandPillIcon.tsx
Original file line number Diff line number Diff line change
@@ -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(() => (
<Layout
size={PILL_SIZE.iconSize}
strokeWidth={1.75}
style={{ color: EDITOR_FILE_PILL_TEXT_COLOR }}
/>
));
CanvasCommandPillIcon.displayName = "CanvasCommandPillIcon";

export default CanvasCommandPillIcon;
6 changes: 6 additions & 0 deletions src/components/ComposerInput/ComposerPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -345,6 +348,9 @@ const ComposerPill: React.FC<ComposerPillProps> = ({
case "dom-component":
return <MousePointer2 {...ICON_PROPS} />;
case "skill":
if (isCanvasCommandPillPath(filePath)) {
return <CanvasCommandPillIcon />;
}
return <Toolbox {...ICON_PROPS} />;
case "member":
return <AtSign {...ICON_PROPS} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SVGElement>(
".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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -386,6 +389,9 @@ const PillIcon: React.FC<{
case "issue":
return <ListChecks {...ICON_PROPS} />;
case "skill":
if (isCanvasCommandPillPath(path)) {
return <CanvasCommandPillIcon />;
}
return <Toolbox {...ICON_PROPS} />;
case "pr":
return <GitPullRequest {...ICON_PROPS} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SVGElement>(
".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();
});
});
Loading
Loading