diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx
deleted file mode 100644
index 90c359d..0000000
--- a/frontend/web/src/components/chat.tsx
+++ /dev/null
@@ -1,551 +0,0 @@
-import { useState } from "react";
-import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
-import { useAppStore } from "../store/useAppStore";
-import { useCurrentPersona } from "../store/personaSelectors";
-import { personaAvatar, resolveMessagePersona } from "../lib/persona";
-import { labelFor } from "../lib/enums";
-import { Tag } from "./primitives";
-import { Icon, type IconName } from "./Icon";
-import { WakePath } from "./status";
-import { modeBody } from "./messages";
-import { DocumentList } from "./messages/Documents";
-import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
-import { UserMessageActions } from "./messages/MessageActions";
-import { CARD_RENDER, renderContent } from "../lib/renderContent";
-import { splitBubble, type ContentPart } from "../lib/parseContent";
-
-/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
- * + label, spinner when still thinking, and a single-line muted preview of the
- * reasoning while collapsed so the block reads at a glance. */
-export function ThinkingBlock({
- text,
- streaming,
-}: {
- text?: string;
- streaming?: boolean;
-}): React.ReactNode | null {
- const [open, setOpen] = useState(false);
- if (!text && !streaming) return null;
-
- return (
-
-
- {open && text ? (
-
- {text}
-
- ) : !open && text ? (
-
{text}
- ) : null}
-
- );
-}
-
-/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary
- * on the left, a color-coded result tag on the right, and collapsible detail. */
-export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
- const [open, setOpen] = useState(false);
- const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
- return (
-
-
- {(open || tool.detail) && tool.detail ? (
-
- {tool.detail}
-
- ) : null}
-
- );
-}
-
-type ToolStatus = "ok" | "err" | "neutral";
-
-function statusBadge(status: ToolStatus): string {
- switch (status) {
- case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]";
- case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]";
- default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]";
- }
-}
-function statusIcon(status: ToolStatus): IconName {
- switch (status) {
- case "ok": return "check";
- case "err": return "close";
- default: return "refresh";
- }
-}
-function statusLabel(status: ToolStatus): string {
- switch (status) {
- case "ok": return "成功";
- case "err": return "失败";
- default: return "运行中";
- }
-}
-
-/** Small muted source label for a persisted message (design/16 §4.5): shows the
- * concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
- * only when a deviceId is present, so legacy/unlabelled messages stay clean. The
- * Web client defaults to this label; other clients decide themselves. */
-function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
- if (!deviceId) return null;
- // A user message is received FROM a client; an assistant reply goes TO the
- // client that prompted it. Semantic direction differs — "来自" vs "发送至".
- const dir = role === "assistant" ? "发送至" : "来自";
- return (
-
- {dir} {deviceId}
-
- );
-}
-
-/** Small sender label for a third-party message (design/16 §4.6): shows the
- * sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
- * (no senderName), so owner bubbles stay unlabelled. */
-function SenderLabel({ name }: { name?: string }): React.ReactNode {
- if (!name) return null;
- return (
-
- 第三方 · {name}
-
- );
-}
-
-/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
- * §7-5): which role spoke this message, and whether it differs from the role the
- * session speaks as now. */
-function useSpokenPersona(personaId?: string): {
- persona: PersonaRecord | null;
- switched: boolean;
-} {
- const personas = useAppStore((s) => s.personas);
- const active = useCurrentPersona();
- return resolveMessagePersona(personaId, personas, active);
-}
-
-/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
- * the session speaks as now. */
-function PersonaLabel({ name }: { name?: string }): React.ReactNode {
- if (!name) return null;
- return (
-
` 口径一个字节不动。 */
-function splitBubbleBreaks(segment: string): string[] {
- if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
- return segment
- .split(BR_SEPARATOR_RE)
- .map((part) => part.trim())
- .filter((part) => part.length > 0);
-}
-
-/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
- * each. Two peers delimit bubbles: `
…
` (contract §4.16, one `
` = one
- * bubble) and ` ` (主人 2026-10-07: ` ` is a message separator, never an
- * in-bubble line break). A body with no `
` wrapper falls back to splitting on
- * `\n\n` runs; a body with none of the three yields an empty list so the caller
- * handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a
- * still-streaming, unclosed `
` simply does not match and stays out until it
- * closes. */
-function splitParagraphs(content: string): string[] {
- // Preferred: the agent wraps each paragraph in
…
(design/07 §2). When it
- // emits plain \n\n runs instead (the model often does), split on those too so
- // each logical paragraph becomes its own bubble. `content` here is the
- // annotation-stripped body from `splitBubble`, so no / block
- // can be cut across; never split on a single \n (that is an in-line break, kept
- // inside the paragraph). Every produced segment then goes through
- // `splitBubbleBreaks`, so a inside a paragraph cuts it into several bubbles.
- const pRe = /
- );
-}
-
-/** Chat-only entry: a `chat`-mode reply's ``/`` blocks are
- * peeled off by `splitBubble` into head/tail attachment notes, and the remaining
- * plain-text body is split into stacked bubbles by `splitParagraphs` — two peers
- * delimit a bubble: `
…
` (§4.16) and ` ` (主人 2026-10-07: a ` ` is a
- * **message separator**, so it must never render as an in-bubble line break).
- * First segment = full bubble, the rest compact. Any other mode — code / home /
- * work / docs / vision / voice / agents / settings — is passed straight through
- * to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
- * 所以那边不走气泡切分,` ` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
- * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
-export function SplitChatBubble({
- message,
- active = false,
-}: {
- message: ChatMessage;
- active?: boolean;
-}): React.ReactNode {
- const mode = message.mode ?? "chat";
- if (mode !== "chat") return ;
-
- const { body, head, tail } = splitBubble(message.content ?? "");
- const segments = splitParagraphs(body);
-
- if (segments.length === 0 && head.length === 0 && tail.length === 0) {
- // No visible content: hand off so the empty / still-streaming reply keeps its
- // usual chrome (avatar, mood, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
- // (只剩 ` ` 分隔符,或只剩空 `
` 块)时**不能**把原文交下去 —— 那会把字面量
- // ` ` / `
` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
- // 与空气泡丢弃的口径一致。
- const bodyExhausted = body.length > 0;
- return (
-
- );
- }
-
- // Group the paragraphs of one reply into a tighter column so intra-reply
- // segments read as one bubble while separate replies keep the 16px list gap.
- // The annotation notes (head/tail) attach to the whole group — above the first
- // bubble and below the last — never inside a bubble's body.
- const align = message.role === "user" ? "right" : "left";
- // 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
- // 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
- // 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
- const chromeOnly =
- segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
- return (
-
+ );
+}
+
+/** Chat-only entry: a `chat`-mode reply's ``/`` blocks are
+ * peeled off by `splitBubble` into head/tail attachment notes, and the remaining
+ * plain-text body is split into stacked bubbles by `splitParagraphs` — two peers
+ * delimit a bubble: `
…
` (§4.16) and ` ` (主人 2026-10-07: a ` ` is a
+ * **message separator**, so it must never render as an in-bubble line break).
+ * First segment = full bubble, the rest compact. Any other mode — code / home /
+ * work / docs / vision / voice / agents / settings — is passed straight through
+ * to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
+ * 所以那边不走气泡切分,` ` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
+ * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
+export function SplitChatBubble({
+ message,
+ active = false,
+}: {
+ message: ChatMessage;
+ active?: boolean;
+}): React.ReactNode {
+ const mode = message.mode ?? "chat";
+ if (mode !== "chat") return ;
+
+ const { body, head, tail } = splitBubble(message.content ?? "");
+ const segments = splitParagraphs(body);
+
+ if (segments.length === 0 && head.length === 0 && tail.length === 0) {
+ // No visible content: hand off so the empty / still-streaming reply keeps its
+ // usual chrome (avatar, mood, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
+ // (只剩 ` ` 分隔符,或只剩空 `
` 块)时**不能**把原文交下去 —— 那会把字面量
+ // ` ` / `
` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
+ // 与空气泡丢弃的口径一致。
+ const bodyExhausted = body.length > 0;
+ return (
+
+ );
+ }
+
+ // Group the paragraphs of one reply into a tighter column so intra-reply
+ // segments read as one bubble while separate replies keep the 16px list gap.
+ // The annotation notes (head/tail) attach to the whole group — above the first
+ // bubble and below the last — never inside a bubble's body.
+ const align = message.role === "user" ? "right" : "left";
+ // 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
+ // 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
+ // 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
+ const chromeOnly =
+ segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
+ return (
+
+ );
+}
+
+function modeLabel(mode: Mode): string {
+ const map: Record = {
+ chat: "对话", code: "代码", home: "家居", work: "工作",
+ docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
+ };
+ return map[mode];
+}
+function modeIcon(mode: Mode): IconName {
+ const map: Record = {
+ chat: "chat", code: "terminal", home: "home", work: "folder",
+ docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
+ };
+ return map[mode];
+}
diff --git a/frontend/web/src/components/chat/labels.tsx b/frontend/web/src/components/chat/labels.tsx
new file mode 100644
index 0000000..687f495
--- /dev/null
+++ b/frontend/web/src/components/chat/labels.tsx
@@ -0,0 +1,70 @@
+import type { ChatMessage, PersonaRecord } from "../../lib/types";
+import { useAppStore } from "../../store/useAppStore";
+import { useCurrentPersona } from "../../store/personaSelectors";
+import { resolveMessagePersona } from "../../lib/persona";
+import { DeliveryChip, TurnTag } from "../messages/TurnMeta";
+
+/** Small muted source label for a persisted message (design/16 §4.5): shows the
+ * concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
+ * only when a deviceId is present, so legacy/unlabelled messages stay clean. The
+ * Web client defaults to this label; other clients decide themselves. */
+export function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
+ if (!deviceId) return null;
+ // A user message is received FROM a client; an assistant reply goes TO the
+ // client that prompted it. Semantic direction differs — "来自" vs "发送至".
+ const dir = role === "assistant" ? "发送至" : "来自";
+ return (
+
+ {dir} {deviceId}
+
+ );
+}
+
+/** Small sender label for a third-party message (design/16 §4.6): shows the
+ * sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
+ * (no senderName), so owner bubbles stay unlabelled. */
+export function SenderLabel({ name }: { name?: string }): React.ReactNode {
+ if (!name) return null;
+ return (
+
+ 第三方 · {name}
+
+ );
+}
+
+/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
+ * §7-5): which role spoke this message, and whether it differs from the role the
+ * session speaks as now. */
+export function useSpokenPersona(personaId?: string): {
+ persona: PersonaRecord | null;
+ switched: boolean;
+} {
+ const personas = useAppStore((s) => s.personas);
+ const active = useCurrentPersona();
+ return resolveMessagePersona(personaId, personas, active);
+}
+
+/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
+ * the session speaks as now. */
+export function PersonaLabel({ name }: { name?: string }): React.ReactNode {
+ if (!name) return null;
+ return (
+
` 口径一个字节不动。 */
+export function splitBubbleBreaks(segment: string): string[] {
+ if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
+ return segment
+ .split(BR_SEPARATOR_RE)
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0);
+}
+
+/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
+ * each. Two peers delimit bubbles: `
…
` (contract §4.16, one `
` = one
+ * bubble) and ` ` (主人 2026-10-07: ` ` is a message separator, never an
+ * in-bubble line break). A body with no `
` wrapper falls back to splitting on
+ * `\n\n` runs; a body with none of the three yields an empty list so the caller
+ * handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a
+ * still-streaming, unclosed `
` simply does not match and stays out until it
+ * closes. */
+export function splitParagraphs(content: string): string[] {
+ // Preferred: the agent wraps each paragraph in
…
(design/07 §2). When it
+ // emits plain \n\n runs instead (the model often does), split on those too so
+ // each logical paragraph becomes its own bubble. `content` here is the
+ // annotation-stripped body from `splitBubble`, so no / block
+ // can be cut across; never split on a single \n (that is an in-line break, kept
+ // inside the paragraph). Every produced segment then goes through
+ // `splitBubbleBreaks`, so a inside a paragraph cuts it into several bubbles.
+ const pRe = /