Skip to content
Merged
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
2 changes: 2 additions & 0 deletions cmd/fakeagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ func brain(system, prompt string) string {
list = append(list, map[string]any{"type": "reply", "of": id, "reply": "收到外部事件:" + firstRunes(text, 60)})
case strings.Contains(text, "代码示例"):
list = append(list, map[string]any{"type": "reply", "of": id, "reply": "可以这样跑测试:\n\n```sh\nmake test\npnpm -C frontend test\n```"})
case strings.Contains(text, "流程图"):
list = append(list, map[string]any{"type": "reply", "of": id, "reply": "消息是这样流转的:\n\n```mermaid\nflowchart LR\n A[收到消息] --> B{要动手吗}\n B -->|是| C[派给 worker]\n B -->|否| D[直接回复]\n```"})
case strings.Contains(lower, "hello") || strings.Contains(text, "你好"):
list = append(list, map[string]any{"type": "reply", "of": id, "reply": "你好!我是 hidane 的主代理。"})
case kind == "recall":
Expand Down
14 changes: 14 additions & 0 deletions frontend/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ card per row. Section labels sit flush with the card edge, `text-sm
font-medium`. Every page-level empty state is an `EmptyState`. Long paths go through
`PathText`, which wraps only after a slash.

### Diagrams

A ```` ```mermaid ```` block in an agent's Markdown is drawn in place of its
code, in the same well (`bg-well`, `rounded-sm`) and with the same copy button,
which still copies the source. `src/lib/mermaid.ts` hands Mermaid the tokens,
resolved to the colors they paint: shapes are `surface-2` with `input-strong`
edges, groups `surface` with `border`, notes `accent-strong`, lines `muted`,
text `foreground` at 13px Geist, corners `rounded-sm` (groups `rounded-md`),
no gradients or shadows. The ember is kept for live state (a Gantt chart's
active work and today line), `danger` for critical work; categories (pie
slices, series, mindmap branches) are steps of `foreground`, never hues. A
wide diagram shrinks to 85% and then scrolls sideways; one Mermaid cannot draw
stays a code block. Mermaid loads with the first diagram, not with the app.

### Status

Badges label state, not category: `default` (ember) for live work or something
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Playwright drives the **real Go backend** (`bin/hidane-nogui serve`, started by
`serve.mjs` on a fresh `HIDANE_HOME`) with `bin/fake/{claude,codex,pi}` standing
in for the agent CLIs (`cmd/fakeagent`: real wire protocols, real guard hook,
scripted answers — a message containing `hello`/`你好` is small talk, `代码示例` gets a reply holding a code block, anything
scripted answers — a message containing `hello`/`你好` is small talk, `代码示例` gets a reply holding a code block, `流程图` one holding a Mermaid flowchart, anything
else becomes a work item whose worker writes `result.txt` (`REPO=<name or path>`,
`FROM=<work item>`, `INPLACE` (`UNASKED`: without quoting the person), `ROUTE=<work item>` steer the Primary's repo choices); `FAKE_FAIL` fails the
CLI; `FAKEAGENT_DELAY_MS` slows each turn, `FAKEAGENT_WORKER_DELAY_MS` only a worker's). Every spec runs in zh and fails on any
Expand Down Expand Up @@ -47,7 +47,7 @@ share one server, so each uses unique text.
| `schedules-status.spec.ts` | an HTTP schedule's "wake Primary" checkbox (ticked through its label text) reaches the saved spec; a prompt schedule created in the UI, run now, its run history and its answer in the conversation, deleted after an in-app confirm; Settings → Runtime status (also reached from `/status`): runtime, roles and detected CLIs |
| `webhook.spec.ts` | a signed webhook is captured, triaged and answered (`rootKind: external`, visible in the UI); unsigned, wrongly signed or replayed-body deliveries are 401 and leave no event |
| `cancel.spec.ts` | Stop on a running card (slow server): the running task is in the sidebar's in-progress list and its menu offers Stop; cancelling the confirm leaves it running; confirming records `execution.cancelled` before `execution.finished` with `cancelled: true`, the "执行已取消。" reply, the card shows 已中止 not 失败 |
| `shell.spec.ts` | the window shell, both flavours: ⌘, opens settings at `/settings/general`, Esc (first letting go of a field, and closing an open select list without leaving), ⌘, again and Back return to the page it was opened from; the work log's day picked from its calendar popover by keyboard, and back with 今天; the ⌘K palette (commands by keyboard, a settings section by English keyword, a task and a message from the search; Esc); ⌘N creates a work item and opens it; ⌘L focuses the composer; the confirm dialog (Cancel, Tab kept inside it, Esc with focus returned, Enter); a message's menu by right click and by ⋯ (copy text via the host clipboard in the desktop UI, no "copy link" there; hide after confirm → `message.redacted`); a code block in a reply copies its code with its 复制代码 button (shown on hover, 已复制 after; the host clipboard in the desktop UI); a task's menu (open, mark done, archive after confirm, reveal workspace in the desktop UI); ⌘B collapse survives a reload; an image dropped on the conversation is attached |
| `shell.spec.ts` | the window shell, both flavours: ⌘, opens settings at `/settings/general`, Esc (first letting go of a field, and closing an open select list without leaving), ⌘, again and Back return to the page it was opened from; the work log's day picked from its calendar popover by keyboard, and back with 今天; the ⌘K palette (commands by keyboard, a settings section by English keyword, a task and a message from the search; Esc); ⌘N creates a work item and opens it; ⌘L focuses the composer; the confirm dialog (Cancel, Tab kept inside it, Esc with focus returned, Enter); a message's menu by right click and by ⋯ (copy text via the host clipboard in the desktop UI, no "copy link" there; hide after confirm → `message.redacted`); a code block in a reply copies its code with its 复制代码 button (shown on hover, 已复制 after; the host clipboard in the desktop UI); a ```mermaid block in a reply is drawn as a diagram in its place, its source still copied by 复制代码; a task's menu (open, mark done, archive after confirm, reveal workspace in the desktop UI); ⌘B collapse survives a reload; an image dropped on the conversation is attached |
| `worktrees.spec.ts` | a message naming a local repo (`REPO=<path>` steers the fake Primary) gives the task its own worktree: the repo's `hidane.json` setup ran first, the worker wrote into the worktree and not the person's checkout, the card names repo and branch; Tasks → Worktrees (`?view=worktrees`) lists it with its uncommitted change, archiving asks, then asks again about the uncommitted change (409 until forced), removes the directory, keeps the branch and records `checkout.archived`; archived ones show with 含已归档 and open their task. A registered repo moved away is marked missing in the list, asked about in the conversation (`escalation` reason `repo_missing`, its own turn labelled 仓库检查) and can be removed (`repo.forgotten`) |
| `desktop.spec.ts` | (desktop only) no token gate, sign-out or live-status, and no browser notification settings; the live stream works without the Wails runtime (SSE fallback); About opens the data directory through the host and copies its path; a task finishing in the background posts a system notification and a Dock badge, cleared when the window comes back; with both switched off the host is left alone; the webview's own context menu is suppressed except in fields; with the host endpoints 404ing, nothing surfaces as an error toast; with the host endpoints failing, a host action the person asked for (About → 在访达中显示) explains "only the desktop app can do this" in a neutral toast, not a red one |

Expand Down
1 change: 1 addition & 0 deletions frontend/e2e/screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ try {
await api("/api/chat", { method: "POST", body: JSON.stringify({ text: "创建一个文件写上 screenshot" }) });
await api("/api/chat", { method: "POST", body: JSON.stringify({ text: "你好" }) });
await api("/api/chat", { method: "POST", body: JSON.stringify({ text: "给我一个代码示例" }) });
await api("/api/chat", { method: "POST", body: JSON.stringify({ text: "画一个流程图" }) });
await api("/api/memories", { method: "POST", body: JSON.stringify({ kind: "preference", content: "回答保持简洁" }) });
await api("/api/policies", { method: "POST", body: JSON.stringify({ pattern: "\\bsudo\\b", reason: "no sudo" }) });
await api("/api/schedules", {
Expand Down
51 changes: 51 additions & 0 deletions frontend/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,57 @@ test("a code block in a reply: its copy button copies the code and says so", asy
await expect(asked.getByRole("button", { name: "复制代码" })).toBeVisible();
});

test("a mermaid block in a reply is drawn as a diagram, its source still copied", async ({ page, api, desktop, hostCalls }) => {
await api.setAllRoles("claude");
if (!desktop) {
await page.addInitScript(() => {
const copied: string[] = [];
(window as unknown as { __copied: string[] }).__copied = copied;
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text: string) => void copied.push(text) } });
});
}
await page.goto("/");
const asked = turn(page, await say(page, `画一个流程图 ${unique("e2e-mermaid")}`));
const diagram = asked.locator("svg[aria-roledescription='flowchart-v2']");
await expect(diagram).toBeVisible();
await expect(diagram).toContainText("收到消息");
await expect(diagram).toContainText("派给 worker");
await expect(asked.locator("pre")).toHaveCount(0);
// Drawn in the theme's colors, not Mermaid's defaults: a node takes surface-2 and its label the foreground.
const node = diagram.locator(".node rect").first();
const colors = await page.evaluate(() => {
const probe = document.createElement("div");
probe.className = "bg-surface-2 text-foreground";
document.body.append(probe);
const canvas = document.createElement("canvas").getContext("2d")!;
const srgb = (color: string) => {
canvas.fillStyle = color;
canvas.fillRect(0, 0, 1, 1);
return [...canvas.getImageData(0, 0, 1, 1).data.slice(0, 3)].join(",");
};
const style = getComputedStyle(probe);
const tokens = { fill: srgb(style.backgroundColor), text: srgb(style.color) };
probe.remove();
return tokens;
});
const drawn = await node.evaluate((rect) => getComputedStyle(rect).fill);
const label = await diagram.getByText("收到消息").evaluate((el) => getComputedStyle(el).color);
const srgb = (color: string) => color.match(/\d+/g)!.slice(0, 3).join(",");
expect(srgb(drawn)).toBe(colors.fill);
expect(srgb(label)).toBe(colors.text);

await expect(async () => {
await diagram.hover();
await asked.getByRole("button", { name: "复制代码" }).click({ timeout: 1_000 });
}).toPass();
const source = "flowchart LR\n A[收到消息] --> B{要动手吗}\n B -->|是| C[派给 worker]\n B -->|否| D[直接回复]";
if (desktop) {
await expect.poll(() => hostCalls).toContainEqual({ path: "/api/desktop/clipboard", body: { text: source } });
} else {
await expect.poll(() => page.evaluate(() => (window as unknown as { __copied: string[] }).__copied)).toEqual([source]);
}
});

test("a task's menu: open, mark done, archive — and in the desktop app, show its workspace", async ({ page, api, desktop, hostCalls }) => {
const title = unique("e2e-task-menu");
const { item } = await api.send<{ item: WorkItem }>("POST", "/api/work-items", { title }, 201);
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dompurify": "^3.4.15",
"i18next": "^26.4.0",
"marked": "^18.0.13",
"mermaid": "^12.1.0",
"svelte": "^5.57.0",
"tailwind-merge": "^3.6.0"
},
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/Markdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { mount, unmount } from "svelte";
import { isMermaidBlock, renderMermaid } from "../lib/mermaid.js";
import { cn } from "../lib/utils.js";
import CodeCopyButton from "./CodeCopyButton.svelte";

Expand Down Expand Up @@ -41,17 +42,31 @@
On a touch screen the button is always shown, so the code makes room for it. */
const CODE_FRAME = "group/code relative my-2 coarse:[&>pre]:min-h-11 coarse:[&>pre]:pr-11";

/** A drawn ```mermaid block, in the place of its code; on a touch screen it clears the copy button above it. */
const DIAGRAM = "overflow-x-auto rounded-sm bg-well p-3 coarse:pt-11 [&>svg]:mx-auto [&>svg]:block [&>svg]:h-auto";

function codeText(pre: HTMLPreElement): string {
return ((pre.querySelector("code") ?? pre).textContent ?? "").replace(/\n$/, "");
}

/** The code stays until the diagram is drawn, and for good when Mermaid cannot draw it. */
async function drawDiagram(pre: HTMLPreElement): Promise<void> {
const drawn = await renderMermaid(codeText(pre)).catch(() => null);
if (!drawn) return;
const diagram = document.createElement("div");
diagram.className = DIAGRAM;
diagram.append(drawn);
pre.replaceWith(diagram);
}

function renderMarkdown(node: HTMLDivElement): () => void {
node.innerHTML = html;
const buttons = [...node.querySelectorAll("pre")].map((pre) => {
const frame = document.createElement("div");
frame.className = CODE_FRAME;
pre.replaceWith(frame);
frame.append(pre);
if (isMermaidBlock(pre)) void drawDiagram(pre);
return mount(CodeCopyButton, { target: frame, props: { text: codeText(pre) } });
});
return () => {
Expand Down
Loading
Loading