From bb77690b117ff5fca57e45fd2af98b9c71d6a06c Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 10:01:24 +0800 Subject: [PATCH 1/6] chore(connector): bump to 0.1.28 (+ mcp 0.1.1) for the workspace.read release Version bump for the connector-v0.1.28 release. The self-update gate compares `CARGO_PKG_VERSION`, so the bump is what marks 0.1.28 as newer for opt-in updaters. Ships (connector-side, reaches prod agents only via this release): - injection-safe XML `` prompt envelope (P1) - remote-workspace `workspace.read` references + note pointing at the new MCP tool - MCP `read_workspace` tool (read another bot's workspace under your own permission) Compat: `[update]` breaks <0.1.27 configs. Co-Authored-By: Claude Opus 4.8 --- packages/cheers-acp-connector-rs/Cargo.lock | 2 +- packages/cheers-acp-connector-rs/Cargo.toml | 2 +- packages/cheers-mcp-server/Cargo.lock | 2 +- packages/cheers-mcp-server/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cheers-acp-connector-rs/Cargo.lock b/packages/cheers-acp-connector-rs/Cargo.lock index fd20a02d..201ecace 100644 --- a/packages/cheers-acp-connector-rs/Cargo.lock +++ b/packages/cheers-acp-connector-rs/Cargo.lock @@ -318,7 +318,7 @@ dependencies = [ [[package]] name = "cce-acp-connector" -version = "0.1.27" +version = "0.1.28" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/packages/cheers-acp-connector-rs/Cargo.toml b/packages/cheers-acp-connector-rs/Cargo.toml index 7b635f99..3b645f88 100644 --- a/packages/cheers-acp-connector-rs/Cargo.toml +++ b/packages/cheers-acp-connector-rs/Cargo.toml @@ -7,7 +7,7 @@ members = [".", "bridge-protocol"] [package] name = "cce-acp-connector" -version = "0.1.27" +version = "0.1.28" edition = "2021" [[bin]] diff --git a/packages/cheers-mcp-server/Cargo.lock b/packages/cheers-mcp-server/Cargo.lock index 61237ce1..781cf3ac 100644 --- a/packages/cheers-mcp-server/Cargo.lock +++ b/packages/cheers-mcp-server/Cargo.lock @@ -62,7 +62,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cheers-mcp-server" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "reqwest", diff --git a/packages/cheers-mcp-server/Cargo.toml b/packages/cheers-mcp-server/Cargo.toml index 96f01630..9274bd5b 100644 --- a/packages/cheers-mcp-server/Cargo.toml +++ b/packages/cheers-mcp-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cheers-mcp-server" -version = "0.1.0" +version = "0.1.1" edition = "2021" [[bin]] From 7cf41f7c8cafcac2497eeab43c7e77b2c1d7ee02 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 10:34:15 +0800 Subject: [PATCH 2/6] fix(context): unify "add to context" vocabulary + broaden composer picker + tree attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three recorded frontend issues about the context-attach UX. #1 — Composer "Add context" only offered Plan + Recent decisions, though the model supports more channel-scoped kinds. Broaden the quick-pick to Plan · Recent decisions · Sessions · Cost (the target-less channel reads), and add a footer note that files / messages / a bot's workspace files attach from their own panels (they need a specific target). #2a + #3 — The same act (attach a resource to the next message's context_bundle) wore three head verbs across five surfaces ("Add context" / "Attach" / "Attach this file|board|the selected lines"). New single source of truth `context/contextLabels.ts`: one head verb "Add … to context", object explicit, scope "your next message". Applied to the composer menu, RemoteWorkspace attach, and Workbench file/board/passage. (File-UPLOAD "Attach file" left as-is — it's a different concept, and the split now disambiguates it.) #2b — RemoteWorkspace file tree had no attach affordance; you had to open a file first. Add a per-row hover "add to context" button on file rows (dirs excluded), reusing AttachContextButton + workspaceContextItem. Cheap because a workspace.read attach is a text-only reference (bot_id + path) — no file read needed. Labels/UX only; no behavior change. tsc (touched files) + vite build clean; #1 verified live on kind (popover items + footer + unified button title). Co-Authored-By: Claude Opus 4.8 --- .../features/chat/RemoteWorkspaceDialog.tsx | 35 +++++++++++++++++-- .../features/chat/context/ContextPickBar.tsx | 29 +++++++++++---- .../features/chat/context/contextLabels.ts | 29 +++++++++++++++ .../chat/workbench/ViewBoardDrawer.tsx | 3 +- .../chat/workbench/panels/FilePanel.tsx | 7 ++-- 5 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/chat/context/contextLabels.ts diff --git a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx index ab90ca85..97079cfb 100644 --- a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx +++ b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx @@ -5,6 +5,12 @@ import { useContextPickStore, workspaceContextItem, } from "@/features/chat/context/contextPick"; +import { AttachContextButton } from "@/features/chat/context/ContextPickBar"; +import { + ADD_TO_CONTEXT, + ADDED_TO_CONTEXT, + addToContextTitle, +} from "@/features/chat/context/contextLabels"; import { GlanceRow, DetailLine } from "@/components/ui/glance-row"; import { ArrowUp, @@ -1619,6 +1625,29 @@ export function RemoteWorkspaceDialog({ )} )} + {/* Per-FILE hover action: add this file to context without + opening it. A workspace.read reference is text-only (bot + + path, no content capture), so the tree row has everything + it needs — no read required. */} + {!ent.is_dir && botId && ( +
+ +
+ )} ); })} @@ -1787,10 +1816,12 @@ export function RemoteWorkspaceDialog({ setAttached(true); window.setTimeout(() => setAttached(false), 1500); }} - title="Attach a reference to this file (which bot + path) as context — the recipient reads the live version on demand" + title={addToContextTitle( + "this workspace file as a live reference — the recipient reads it on demand" + )} className="flex items-center gap-1 px-2 py-0.5 rounded hover:bg-zinc-800 text-zinc-300" > - {attached ? "Attached" : "Attach"} + {attached ? ADDED_TO_CONTEXT : ADD_TO_CONTEXT} )} diff --git a/frontend/src/features/chat/context/ContextPickBar.tsx b/frontend/src/features/chat/context/ContextPickBar.tsx index b186ef96..aa6d9467 100644 --- a/frontend/src/features/chat/context/ContextPickBar.tsx +++ b/frontend/src/features/chat/context/ContextPickBar.tsx @@ -21,6 +21,11 @@ import { type ReplyTargetLike, type FileRef, } from "./contextPick"; +import { + ADD_CONTEXT_MENU, + ADD_CONTEXT_MENU_TITLE, + ADDED_TO_CONTEXT_TITLE, +} from "./contextLabels"; // Composer "add context" bar (docs/design/RESOURCE_CONTEXT.md, F1): renders the // pending resource picks as removable chips and an "add context" menu. In-panel @@ -89,8 +94,12 @@ export function MessageContextChips({ ); } -// v1 quick attaches: channel-scoped Viewboard resources (one click, no browsing). -// File / message picking and in-panel attach arrive in the next slice. +// Quick attaches = the CHANNEL-SCOPED reads that need no target to pick (one click, +// no browsing): plan, recent decisions, sessions, cost. The remaining context kinds +// need a specific target — a file (Workbench), a message (reply), or a remote- +// workspace file (RemoteWorkspace dialog) — so they attach from their own panels, +// not this menu. (Keep in sync with the readable channel verbs in the resource +// registry + the sanitize allowlist.) const QUICK: ContextItem[] = [ { id: "plan", verb: "channel.plan.read", params: {}, label: "Plan", kind: "plan" }, { @@ -100,6 +109,8 @@ const QUICK: ContextItem[] = [ label: "Recent decisions", kind: "activity", }, + { id: "sessions", verb: "channel.sessions.read", params: {}, label: "Sessions", kind: "sessions" }, + { id: "cost", verb: "channel.usage.read", params: {}, label: "Cost", kind: "cost" }, ]; /** In-panel "attach this to my next message" button (Viewboard / Workbench / @@ -129,7 +140,7 @@ export function AttachContextButton({ type="button" disabled={disabled || added} onClick={() => add(channelId, item)} - title={disabled ? disabledTitle ?? "Unavailable" : added ? "Added to context" : title} + title={disabled ? disabledTitle ?? "Unavailable" : added ? ADDED_TO_CONTEXT_TITLE : title} className={ className ?? "rounded p-0.5 text-zinc-500 hover:text-indigo-300 disabled:opacity-40 disabled:hover:text-zinc-500" @@ -221,16 +232,16 @@ export function ContextPickBar({ type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} - title="Attach Cheers resources (plan, decisions, …) as context for this message" + title={ADD_CONTEXT_MENU_TITLE} className="inline-flex items-center gap-1 rounded-lg bg-zinc-800/60 px-2 py-1 text-[11px] text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors" > - Add context + {ADD_CONTEXT_MENU} {open && ( - +

- Attach to this message + Add to context

{QUICK.map((q) => { const Icon = KIND_ICON[q.kind]; @@ -252,6 +263,10 @@ export function ContextPickBar({ ); })} +

+ Files, messages & a bot's workspace files: add them from their own + panels (Workbench, a reply, the Remote workspace). +

)} diff --git a/frontend/src/features/chat/context/contextLabels.ts b/frontend/src/features/chat/context/contextLabels.ts new file mode 100644 index 00000000..2ad54c4c --- /dev/null +++ b/frontend/src/features/chat/context/contextLabels.ts @@ -0,0 +1,29 @@ +// Single source of truth for the "add to context" UI vocabulary. +// +// Before this, the same act — attach a Cheers resource to the next message's +// `context_bundle` — wore three head verbs across five surfaces ("Add context" in +// the composer, "Attach" in the RemoteWorkspace dialog, "Attach this file/board/the +// selected lines" in the Workbench). One head verb now: **"Add … to context"**, the +// object explicit, the scope always "your next message". Every surface reads its +// labels from here. + +/** The composer's umbrella menu button (opens the quick-pick). */ +export const ADD_CONTEXT_MENU = "Add context"; + +/** Tooltip for the composer menu button. */ +export const ADD_CONTEXT_MENU_TITLE = + "Add Cheers resources (plan, decisions, files, …) as context for your next message"; + +/** Post-add state shown on an attach control that has already added its item. */ +export const ADDED_TO_CONTEXT_TITLE = "Added to context"; + +/** Short visible label for a per-item attach control (e.g. RemoteWorkspace file). */ +export const ADD_TO_CONTEXT = "Add to context"; +/** Its post-add visible text. */ +export const ADDED_TO_CONTEXT = "Added"; + +/** Tooltip for attaching a specific object, e.g. `addToContextTitle("this file")` + * → "Add this file to context for your next message". */ +export function addToContextTitle(what: string): string { + return `Add ${what} to context for your next message`; +} diff --git a/frontend/src/features/chat/workbench/ViewBoardDrawer.tsx b/frontend/src/features/chat/workbench/ViewBoardDrawer.tsx index c180a90a..feefc527 100644 --- a/frontend/src/features/chat/workbench/ViewBoardDrawer.tsx +++ b/frontend/src/features/chat/workbench/ViewBoardDrawer.tsx @@ -9,6 +9,7 @@ import { useContextPickStore, type ContextItem, } from "@/features/chat/context/contextPick"; +import { addToContextTitle } from "@/features/chat/context/contextLabels"; import { useLaneWindow } from "@/hooks/useLaneWindow"; import { ResizeGrip } from "@/components/ui/resize-grip"; import { cn } from "@/lib/cn"; @@ -234,7 +235,7 @@ function ViewBoardDrawerImpl({ kind: meta.kind, }); }} - title="Attach this board as context for your next message" + title={addToContextTitle("this board")} className="rounded p-0.5 text-zinc-500 hover:bg-zinc-800 hover:text-indigo-300" > diff --git a/frontend/src/features/chat/workbench/panels/FilePanel.tsx b/frontend/src/features/chat/workbench/panels/FilePanel.tsx index 209a3dbb..8fd1bc6a 100644 --- a/frontend/src/features/chat/workbench/panels/FilePanel.tsx +++ b/frontend/src/features/chat/workbench/panels/FilePanel.tsx @@ -20,6 +20,7 @@ import type { FsEntry } from "../fsClient"; import { errMsg, useFileEditor } from "../jsonFile"; import { PinToggle } from "../PinToggle"; import { AttachContextButton } from "@/features/chat/context/ContextPickBar"; +import { addToContextTitle } from "@/features/chat/context/contextLabels"; import { useContextPickStore, selectionLineRange, @@ -507,7 +508,7 @@ export function FilePanel({ ctx }: { ctx: WorkbenchContext }) { channelId={ctx.channelId} disabled={pinned.includes(selected)} disabledTitle="Already pinned — sent in every prompt" - title="Attach this file as context for your next message" + title={addToContextTitle("this file")} item={{ id: `file:${selected}`, verb: "fs.read", @@ -526,7 +527,7 @@ export function FilePanel({ ctx }: { ctx: WorkbenchContext }) { title={ pinned.includes(selected) ? "Already pinned — sent in every prompt" - : "Attach the selected lines as context (select text first)" + : `${addToContextTitle("the selected lines")} (select text first)` } onClick={() => { const sel = window.getSelection()?.toString() ?? ""; @@ -539,7 +540,7 @@ export function FilePanel({ ctx }: { ctx: WorkbenchContext }) { ctx.channelId, rangedFileContextItem(selected, range.start, range.end) ); - setStatus(`Attached ${selected.split("/").pop()}:${range.start}-${range.end} as context`); + setStatus(`Added ${selected.split("/").pop()}:${range.start}-${range.end} to context`); }} className="rounded p-0.5 text-zinc-500 hover:text-indigo-300 disabled:opacity-40 disabled:hover:text-zinc-500" > From f6fecb542842508ac20fb86e46584baa634a4925 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 10:45:29 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(context):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20resolver=20tools=20for=20plan/sessions/cost=20+=20p?= =?UTF-8?q?reserve=20workspace=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the context-UX PR: [P1] The composer picks reference channel.plan.read / sessions / usage, but the MCP bridge exposed NO tool for them (also true for the pre-existing Plan pick) — so the recipient couldn't resolve them (unknown tools reject). Add read-only MCP tools `read_plan` / `read_sessions` / `read_cost` mapping to those verbs. [P2] A workspace.read tree/opened-file reference recorded only the relative path; when browsing a non-default allowed root the broker passed no root and the connector fell back to its default cwd / first allowed root — resolving a different same-named file or failing. Thread the browse root end-to-end: workspaceContextItem(root) → params.root → MCP read_workspace copies `root` → broker_workspace_read reads it → read_workspace_file_as_bot → workspace_call(root). Root is now part of the reference identity (same path under two roots ≠ same chip). Builds + tests green (MCP, gateway, frontend vitest incl. new root case). Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/chat/RemoteWorkspaceDialog.tsx | 2 ++ .../src/features/chat/context/contextPick.test.ts | 11 ++++++++++- frontend/src/features/chat/context/contextPick.ts | 10 +++++++++- packages/cheers-mcp-server/src/main.rs | 11 +++++++++++ server/src/gateway/ws/agent_bridge.rs | 6 +++++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx index 97079cfb..1afd8b07 100644 --- a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx +++ b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx @@ -1642,6 +1642,7 @@ export function RemoteWorkspaceDialog({ undefined, path: ent.path, sessionId: effectiveSessionId || undefined, + root: treeRoot ?? undefined, })} title={addToContextTitle(`${ent.name} (live reference)`)} className="flex items-center p-0.5 rounded bg-zinc-800 text-zinc-400 hover:text-indigo-300 disabled:opacity-40" @@ -1811,6 +1812,7 @@ export function RemoteWorkspaceDialog({ undefined, path: file.path, sessionId: effectiveSessionId || undefined, + root: treeRoot ?? undefined, }) ); setAttached(true); diff --git a/frontend/src/features/chat/context/contextPick.test.ts b/frontend/src/features/chat/context/contextPick.test.ts index 46b871f6..9df15d10 100644 --- a/frontend/src/features/chat/context/contextPick.test.ts +++ b/frontend/src/features/chat/context/contextPick.test.ts @@ -125,7 +125,7 @@ describe("workspaceContextItem (remote workspace reference)", () => { path: "src/main.rs", sessionId: "sess-1", }); - expect(it.id).toBe("ws:bot-A:src/main.rs"); + expect(it.id).toBe("ws:bot-A::src/main.rs"); // empty root segment when none given expect(it.verb).toBe("workspace.read"); // consumer-governed ref, not a snapshot expect(it.params).toEqual({ bot_id: "bot-A", path: "src/main.rs", session_id: "sess-1" }); expect(it.label).toBe("main.rs (@codex workspace)"); @@ -137,6 +137,15 @@ describe("workspaceContextItem (remote workspace reference)", () => { expect("session_id" in it.params).toBe(false); expect(it.label).toBe("f.txt (@b workspace)"); // botId fallback for name }); + + it("carries the browse root (identity + params) so resolution reads the same file", () => { + const it = workspaceContextItem({ botId: "b", path: "a.md", root: "/home/w/proj" }); + expect(it.id).toBe("ws:b:/home/w/proj:a.md"); // root is part of the identity + expect(it.params).toMatchObject({ bot_id: "b", path: "a.md", root: "/home/w/proj" }); + // same path under a different root must NOT dedup to the same chip + const other = workspaceContextItem({ botId: "b", path: "a.md", root: "/other" }); + expect(other.id).not.toBe(it.id); + }); }); describe("toBundle (references only)", () => { diff --git a/frontend/src/features/chat/context/contextPick.ts b/frontend/src/features/chat/context/contextPick.ts index 06a4d56b..be9a8b56 100644 --- a/frontend/src/features/chat/context/contextPick.ts +++ b/frontend/src/features/chat/context/contextPick.ts @@ -286,15 +286,23 @@ export function workspaceContextItem(args: { botName?: string; path: string; sessionId?: string; + /** The canonical browse root `path` is relative to (the connector's `treeRoot`). + * Carried in the reference so resolution reads the SAME file the picker showed — + * without it the broker passes no root and the connector falls back to its default + * cwd / first allowed root, which can resolve a different same-named file. */ + root?: string; }): ContextItem { const base = args.path.split("/").pop() || args.path; const who = args.botName?.trim() || args.botId; return { - id: `ws:${args.botId}:${args.path}`, + // Root is part of the identity — the same relative path under two roots is two + // different files, so they must not dedup to one chip. + id: `ws:${args.botId}:${args.root ?? ""}:${args.path}`, verb: "workspace.read", params: { bot_id: args.botId, path: args.path, + ...(args.root ? { root: args.root } : {}), ...(args.sessionId ? { session_id: args.sessionId } : {}), }, label: `${base} (@${who} workspace)`, diff --git a/packages/cheers-mcp-server/src/main.rs b/packages/cheers-mcp-server/src/main.rs index 7a206351..efd48bb0 100644 --- a/packages/cheers-mcp-server/src/main.rs +++ b/packages/cheers-mcp-server/src/main.rs @@ -254,6 +254,12 @@ fn build_resource_call( "list_members" => with_channel(client, args, "channel.members"), "messages_index" => with_channel(client, args, "channel.messages.index"), "get_context" => with_channel(client, args, "channel.context"), + // Channel-scoped read verbs the composer's "Add context" quick-pick offers as + // references (plan / sessions / cost). The recipient resolves them through + // these tools; without a mapping the reference is dead (unknown tools reject). + "read_plan" => with_channel(client, args, "channel.plan.read"), + "read_sessions" => with_channel(client, args, "channel.sessions.read"), + "read_cost" => with_channel(client, args, "channel.usage.read"), "inbox_list" => with_channel(client, args, "channel.files"), "read_messages" => { let mut params = Map::new(); @@ -514,6 +520,7 @@ fn build_resource_call( copy_required(args, &mut params, "bot_id", "bot_id")?; copy_required(args, &mut params, "path", "path")?; copy_optional(args, &mut params, "session_id", "session_id"); + copy_optional(args, &mut params, "root", "root"); Ok(ResourceCall { resource: "workspace.read", params, @@ -611,6 +618,9 @@ fn tool_definitions() -> Vec { number_prop("limit", "Default 50, max 200.", Some(1), Some(200)), ], vec!["channel_id"]), true, false), tool("get_context", "Get channel context", "Condensed channel context bundle (topic, pinned info, summary).", object_schema(vec![channel_id_prop()], vec!["channel_id"]), true, false), + tool("read_plan", "Read the channel plan", "Read the channel's live plan / progress board (the agent's task list and status). Use this to resolve a \"plan\" context reference someone handed you.", object_schema(vec![channel_id_prop()], vec!["channel_id"]), true, false), + tool("read_sessions", "List channel agent sessions", "List the bot sessions active in this channel (id, bot, mode). Use this to resolve a \"sessions\" context reference.", object_schema(vec![channel_id_prop()], vec!["channel_id"]), true, false), + tool("read_cost", "Read channel usage / cost", "Read token-usage / cost totals for this channel. Use this to resolve a \"cost\" context reference.", object_schema(vec![channel_id_prop()], vec!["channel_id"]), true, false), tool("leave_channel", "Leave a channel", "Remove yourself from a channel you are a member of (like a human member leaving). Not allowed for DMs. You stop receiving that channel's tasks immediately; a human has to re-invite you to get you back, so only leave when you are sure your work there is done.", object_schema(vec![channel_id_prop()], vec!["channel_id"]), false, true), tool("inbox_list", "List chat attachments (inbox)", "List files people UPLOADED to this channel's chat (pdf/csv/images/docx). Each has a FILE_ID (uuid); open one with inbox_open. Read-only; these are NOT your workspace files — save your own work with desk_* instead.", object_schema(vec![channel_id_prop()], vec!["channel_id"]), true, false), tool("inbox_open", "Open a chat attachment by file_id", "Open a channel attachment by its FILE_ID (from inbox_list). Text files (csv/txt/md/json) return content directly. Binaries (image/pdf/zip/docx) first return kind:\"binary\"; re-open with as_base64:true to get the raw bytes as base64 (<=8MB) through THIS tool, then decode them locally (e.g. write to a file and unzip). Do NOT try to fetch download_url yourself — it is an authenticated human-UI endpoint and you have no gateway session for it (you would just get 401). Attachments are read-only — to edit, copy the content into your workspace with desk_write.", object_schema(vec![ @@ -685,6 +695,7 @@ fn tool_definitions() -> Vec { string_prop("bot_id", "member_id (uuid) of the bot whose workspace file to read — the owner named in the reference."), string_prop("path", "File path within that bot's workspace, from the reference."), string_prop("session_id", "Optional session id from the reference, scoping which workspace root to read."), + string_prop("root", "Optional workspace root the path is relative to, from the reference. Pass it through verbatim so you read the exact file the reference points at."), ], vec!["channel_id", "bot_id", "path"]), true, false), ] } diff --git a/server/src/gateway/ws/agent_bridge.rs b/server/src/gateway/ws/agent_bridge.rs index 983dfdfe..5551738a 100644 --- a/server/src/gateway/ws/agent_bridge.rs +++ b/server/src/gateway/ws/agent_bridge.rs @@ -1997,6 +1997,10 @@ async fn broker_workspace_read(state: &AppState, reader_bot_id: Uuid, frame: &Va return bridge_frames::resource_res_err(req_id, "INVALID_PARAMS", "path required"); }; let session_id = uuid_param("session_id"); + // The root the ref's `path` is relative to (from the picker's `treeRoot`). Without + // it the read falls back to the connector's default cwd / first allowed root, which + // can resolve a different same-named file — so pass it through when present. + let root = str_param("root"); match crate::api::workspace::read_workspace_file_as_bot( state, @@ -2004,7 +2008,7 @@ async fn broker_workspace_read(state: &AppState, reader_bot_id: Uuid, frame: &Va reader_bot_id, channel_id, &path, - None, + root.as_deref(), session_id, ) .await From c9a2e8994645b2ff2c8230a344d0691d7ee33f3b Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 13:17:56 +0800 Subject: [PATCH 4/6] feat(viewboard): redesign Activity as a collaboration flow list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the Activity board's presentation for multi-user / multi-bot channels (data layer activityEpisodes.ts untouched): - Flow list replaces the rail+detail two-pane: one line per episode — relay-chain avatars (trigger human → the bots involved), trigger excerpt, time — newest auto-expanded inline on an indigo tint. - Expanded detail hangs message rows on a left rule: sender names in lightened brand colors (color-mix), consecutive writes/approvals fold into one muted line, hover swaps the timestamp for a jump arrow that scrolls the chat to the original message; @mentions highlight inline. - Participant strip: recency-ordered avatar stack with presence dots and online count; clicking spotlights one member via the shared filter. - Avatar gains an opt-in `online` presence-dot prop (DESIGN.md §2.7); MemberFilter migrates to the shared PopoverPanel/usePopoverDismiss; new §2.16 avatar-stack recipe documented in DESIGN.md. - Lenses: Flow / Highlights / All (All renders every episode expanded); long episode spans format as h/d instead of thousands of minutes. Verified live against the kind stack (typecheck clean; zero console errors; jump/hover/filter/lens flows screenshot-verified). Co-Authored-By: Claude Fable 5 --- frontend/DESIGN.md | 31 + frontend/src/components/ui/avatar.tsx | 56 +- .../chat/workbench/panels/ActivityPanel.tsx | 730 +++++++++--------- 3 files changed, 420 insertions(+), 397 deletions(-) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 69f931a3..95a6048d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -345,6 +345,37 @@ accent fill; the irreversible one gets a `…` suffix (`Delete…`) to signal a confirm step follows (§7 reversibility — prefer a confirm dialog to an inline red button that fires on first click). Consequences go in a ``. +### 2.16 Avatar stack (participant overview) + +"Who's here" at a glance — a channel/board's distinct participants as +overlapping avatars, most-relevant first. Used by the Activity ViewBoard +(`ActivityPanel.tsx`'s `ParticipantStrip`). + +```tsx +
+ {ids.map((id) => ( + + ))} +
+{overflow > 0 && +{overflow}} +``` + +The `ring-zinc-900`/`ring-indigo-500` ring doubles as the overlap separator +(resting) and the selected state (active) — don't add a second selection +treatment. Cap the stack (10 is the Activity precedent) and show a plain +`+N`, never render an unbounded row. If the stack drives a filter, clicking +toggles membership in that filter's own selection state — don't invent a +parallel one. + --- ## 3. Known gaps (extraction roadmap) diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx index 819056fc..81e00a78 100644 --- a/frontend/src/components/ui/avatar.tsx +++ b/frontend/src/components/ui/avatar.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { cn } from "@/lib/cn"; import { initials, avatarColor } from "@/lib/format"; import { agentIconFor, AgentGlyph } from "@/components/ui/agentIcons"; @@ -8,6 +9,8 @@ interface AvatarProps { id?: string; size?: "xs" | "sm" | "md" | "lg"; className?: string; + /** Presence dot (DESIGN.md §2.7): omit for no dot, true/false for online/offline. */ + online?: boolean; } const sizeCls = { @@ -17,11 +20,24 @@ const sizeCls = { lg: "w-11 h-11 text-base", }; -export function Avatar({ name, src, id, size = "md", className }: AvatarProps) { +function PresenceDot({ online }: { online: boolean }) { + return ( + + ); +} + +export function Avatar({ name, src, id, size = "md", className, online }: AvatarProps) { const color = id ? avatarColor(id) : "bg-zinc-700"; + let inner: ReactNode; if (src) { - return ( + inner = ( {name ); - } - - // Well-known agents (claude / codex / gemini / copilot …) get their brand - // glyph instead of text initials, so a channel full of bots reads by logo. - const brand = agentIconFor(name); - if (brand) { - return ( + } else { + // Well-known agents (claude / codex / gemini / copilot …) get their brand + // glyph instead of text initials, so a channel full of bots reads by logo. + const brand = agentIconFor(name); + inner = brand ? ( + ) : ( + + {initials(name)} + ); } + if (online === undefined) return inner; return ( - - {initials(name)} + + {inner} + ); } diff --git a/frontend/src/features/chat/workbench/panels/ActivityPanel.tsx b/frontend/src/features/chat/workbench/panels/ActivityPanel.tsx index b3d57672..7e26042d 100644 --- a/frontend/src/features/chat/workbench/panels/ActivityPanel.tsx +++ b/frontend/src/features/chat/workbench/panels/ActivityPanel.tsx @@ -1,33 +1,33 @@ -// Activity — the channel's HISTORY at two zoom levels (Codex-sidebar-style): -// a NODE RAIL on the left compresses the whole channel into one scannable -// column (one node per episode), and a DETAIL pane on the right expands just the -// selected episode. An "episode" is a causal unit — a human's @mention plus the -// bot turn / approvals / file writes that follow it (see activityEpisodes.ts) — -// so the board reads as "what happened", not a firehose of rows. Three lenses: -// Episodes (one node per episode), Highlights (only episodes that made a -// decision / artifact), All (every event, low-signal bursts collapsed to ×N). -// Clicking a message in the detail pane jumps the chat to it (ctx.onJumpToMessage). -// Sourced from `channel.activity.read` (messages ∪ operations) + REST -// listChannelMembers (id → name + avatar_url). All content is inert text. -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { createPortal } from "react-dom"; +// Activity — the channel's collaboration history as a FLOW LIST. One row per +// episode (a causal unit: a human's @mention plus the bot turns / approvals / +// file writes that follow — see activityEpisodes.ts). The collapsed row is a +// single line: a relay chain of avatars (trigger human → the bots involved), +// the trigger excerpt, and the time — so scanning the column reads as "who +// asked which bots to do what, when". Exactly one episode expands inline +// (indigo-tinted block): a muted outcome summary plus the message rows hung on +// a left rule; hovering a message row reveals a jump arrow that scrolls the +// chat to the original message (ctx.onJumpToMessage). Three lenses: Flow +// (newest auto-expanded), Highlights (only episodes with a decision/artifact), +// All (every episode expanded). Sourced from `channel.activity.read` +// (messages ∪ operations) + REST listChannelMembers. All content is inert text. +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Activity, - AtSign, - FileText, + ArrowRight, + ArrowUpRight, + Check, + ChevronDown, Filter, Paperclip, Pencil, Search, ShieldCheck, - Check, - ChevronDown, X, } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; import { SurfaceSpinner } from "@/components/ui/spinner"; +import { PopoverPanel, usePopoverDismiss } from "@/components/ui/popover"; import { cn } from "@/lib/cn"; -import { avatarColor } from "@/lib/format"; import { listChannelMembers } from "@/api/channels"; import type { MemberItem } from "@/types"; import { Avatar } from "@/components/ui/avatar"; @@ -40,11 +40,9 @@ import { } from "../viewBoard"; import { buildEpisodes, - collapseEpisode, isNotableEpisode, type ActivityEvent, type Episode, - type EventKind, type NormEvent, } from "./activityEpisodes"; @@ -59,14 +57,20 @@ function fmtTime(ts?: string | null): string { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } -/** Whole-minute span between two ISO stamps, e.g. "8 min" / "" if <1min. */ +/** Span between two ISO stamps at a readable unit: "8 min" / "3 h" / "6 d" + * ("" if <1min). Long-running episodes (a bot thread spanning days) would + * otherwise read as "8213 min". */ function fmtSpan(a?: string | null, b?: string | null): string { if (!a || !b) return ""; const t0 = Date.parse(a); const t1 = Date.parse(b); if (Number.isNaN(t0) || Number.isNaN(t1)) return ""; const min = Math.round(Math.abs(t1 - t0) / 60000); - return min >= 1 ? `${min} min` : ""; + if (min < 1) return ""; + if (min < 120) return `${min} min`; + const h = Math.round(min / 60); + if (h < 48) return `${h} h`; + return `${Math.round(h / 24)} d`; } type MemberLookup = (id?: string | null) => MemberItem | undefined; @@ -75,7 +79,7 @@ function nameOf(member: MemberItem | undefined, id?: string | null): string { return member?.display_name || member?.username || short(id) || "unknown"; } -type Lens = "episodes" | "highlights" | "all"; +type Lens = "flow" | "highlights" | "all"; // ── episode helpers that need the member map (kept out of the pure module) ── function episodeTitle(ep: Episode, memberOf: MemberLookup): string { @@ -84,13 +88,15 @@ function episodeTitle(ep: Episode, memberOf: MemberLookup): string { return `${nameOf(dom, ep.dominantActorId)} activity`; } -/** Human-readable one-liner of what an episode produced, for the rail sub-label. */ +/** Outcome one-liner for the expanded header: "6 messages · 2 approvals · 10 writes · 12 min". */ function episodeSummary(ep: Episode): string { const p: string[] = []; - if (ep.counts.messages) p.push(`${ep.counts.messages} msg`); + if (ep.counts.messages) p.push(`${ep.counts.messages} message${ep.counts.messages > 1 ? "s" : ""}`); if (ep.counts.approvals) p.push(`${ep.counts.approvals} approval${ep.counts.approvals > 1 ? "s" : ""}`); if (ep.counts.writes) p.push(`${ep.counts.writes} write${ep.counts.writes > 1 ? "s" : ""}`); if (ep.counts.files) p.push(`${ep.counts.files} file${ep.counts.files > 1 ? "s" : ""}`); + const span = fmtSpan(ep.startTs, ep.endTs); + if (span) p.push(span); return p.join(" · "); } @@ -101,194 +107,175 @@ function episodeTouches(ep: Episode, selected: Set): boolean { return false; } -// ── the node rail ─────────────────────────────────────────────────────────── -// One node per row: a small marker + a SHORT type label ("claude turn", -// "approval", "10 writes"). Everything else (excerpt, counts, time) lives in the -// hover bubble + the detail pane — the rail itself stays scannable at a glance. -interface RailMarker { - bar?: boolean; // gray burst bar (writes / ops) vs. a colored dot - color?: string; // dot fill (brand color) via inline style - cls?: string; // dot fill via tailwind class (per-actor hash / semantic) -} -interface RailTip { - id?: string | null; - name: string; - time: string; - summary: string; -} -interface RailItem { - key: string; - episodeId: string; - marker: RailMarker; - label: string; - tip: RailTip; -} - -/** Dot fill for an actor: brand color for known agents, else a stable hash. */ -function actorMarker(id?: string | null, name?: string): RailMarker { - const brand = agentIconFor(name); - if (brand) return { color: brand.bg }; - return { cls: avatarColor(id ?? name ?? "") }; -} - -function Marker({ marker }: { marker: RailMarker }) { - if (marker.bar) return ; - return ( - - ); -} - -/** One episode → one rail node ("{bot} turn", or the human's name for a - * human-only run). Used by the Episodes + Highlights lenses. */ -function episodeItem(ep: Episode, memberOf: MemberLookup): RailItem { - const dom = memberOf(ep.dominantActorId); - const domName = nameOf(dom, ep.dominantActorId); - const hasBot = !!ep.dominantActorId && dom?.member_type === "bot"; - const trig = memberOf(ep.triggerActorId); - const label = hasBot ? `${domName} turn` : nameOf(trig, ep.triggerActorId); - const summary = [episodeTitle(ep, memberOf), episodeSummary(ep)].filter(Boolean).join(" · "); - return { - key: ep.id, - episodeId: ep.id, - marker: actorMarker(ep.dominantActorId ?? ep.triggerActorId, hasBot ? domName : label), - label, - tip: { - id: ep.dominantActorId ?? ep.triggerActorId, - name: hasBot ? domName : nameOf(trig, ep.triggerActorId), - time: fmtTime(ep.startTs), - summary, - }, +/** The bots in this episode's relay chain, in order of first appearance — + * mentioned-by-the-trigger first (intent), then whoever actually spoke/acted. + * Uses the events' own type tags so a bot that already left the channel + * (missing from the member map) still shows in the chain. */ +function episodeBots(ep: Episode): string[] { + const bots: string[] = []; + const push = (id?: string | null) => { + if (id && !bots.includes(id)) bots.push(id); }; -} - -/** One collapsed run → one rail node. Used by the All lens. */ -function runItem(run: ReturnType[number], memberOf: MemberLookup): RailItem { - const actor = memberOf(run.actorId); - const name = nameOf(actor, run.actorId); - let marker: RailMarker; - let label: string; - switch (run.kind) { - case "write": - marker = { bar: true }; - label = `${run.count} write${run.count > 1 ? "s" : ""}`; - break; - case "op": - marker = { bar: true }; - label = `${run.count} op${run.count > 1 ? "s" : ""}`; - break; - case "approval": - marker = { cls: "bg-emerald-500" }; - label = "approval"; - break; - case "file": - marker = { cls: "bg-sky-500" }; - label = "file"; - break; - case "bot_msg": - marker = actorMarker(run.actorId, name); - label = `${name} turn`; - break; - default: // trigger / user_msg - marker = actorMarker(run.actorId, name); - label = run.count > 1 ? `${name} ×${run.count}` : name; + for (const n of ep.events) { + if (n.kind === "trigger") { + for (const m of n.mentions) if (m.member_type === "bot") push(m.member_id); + } else if (n.actorType === "bot") { + push(n.actorId); + } } - return { - key: run.key, - episodeId: run.episodeId, - marker, - label, - tip: { id: run.actorId, name, time: fmtTime(run.ts), summary: run.sample || label }, - }; + return bots; } -function RailRow({ - item, - active, - onSelect, - onHover, - onLeave, -}: { - item: RailItem; - active: boolean; - onSelect: () => void; - onHover: (rect: DOMRect, item: RailItem) => void; - onLeave: () => void; -}) { +// ── relay chain: trigger avatar → the bots involved ──────────────────────── +const CHAIN_BOT_CAP = 3; + +function ChainAvatars({ ep, memberOf }: { ep: Episode; memberOf: MemberLookup }) { + const lead = ep.triggerActorId ?? ep.dominantActorId; + const bots = episodeBots(ep).filter((id) => id !== lead); + const shown = bots.slice(0, CHAIN_BOT_CAP); + const leadMember = memberOf(lead); return ( - + ); } -/** Fixed-position hover bubble — escapes the rail's scroll clipping. */ -function RailTooltip({ rect, item, memberOf }: { rect: DOMRect; item: RailItem; memberOf: MemberLookup }) { - return ( -
-
- - {item.tip.name} - {item.tip.time && {item.tip.time}} -
- {item.tip.summary &&
{item.tip.summary}
} -
- ); +// ── expanded detail: the episode's events hung on a left rule ─────────────── +interface MutedItem { + kind: "write" | "approval"; + actorId?: string | null; + count: number; } - -// ── detail pane: the selected episode, fully expanded ─────────────────────── type DetailRow = | { type: "event"; n: NormEvent } - | { type: "writeRun"; count: number; actorId?: string | null; seq: number }; + | { type: "muted"; items: MutedItem[]; seq: number }; -/** Fold consecutive write/op events into one summary row; keep messages - * individually rendered (so each stays jump-to-able). */ +/** Fold consecutive non-message events (writes/ops + approvals) into ONE muted + * summary row — "claude wrote 10 files · haowei approved ×2" — and keep + * messages individually rendered (each stays jump-to-able). Chronological — + * the flow reads top-down like the conversation it summarizes. */ function detailRows(ep: Episode): DetailRow[] { const rows: DetailRow[] = []; for (const n of ep.events) { - if (n.kind === "write" || n.kind === "op") { + if (n.kind === "write" || n.kind === "op" || n.kind === "approval") { + const kind: MutedItem["kind"] = n.kind === "approval" ? "approval" : "write"; const last = rows[rows.length - 1]; - if (last && last.type === "writeRun" && last.actorId === n.actorId) { - last.count += 1; + if (last && last.type === "muted") { + const item = last.items.find((it) => it.kind === kind && it.actorId === n.actorId); + if (item) item.count += 1; + else last.items.push({ kind, actorId: n.actorId, count: 1 }); last.seq = n.seq; } else { - rows.push({ type: "writeRun", count: 1, actorId: n.actorId, seq: n.seq }); + rows.push({ type: "muted", items: [{ kind, actorId: n.actorId, count: 1 }], seq: n.seq }); } } else { rows.push({ type: "event", n }); } } - return rows.reverse(); // newest-first, matching the rest of the board + return rows; +} + +/** Bot senders read by brand color (claude clay, codex green, …) so a + * multi-bot thread scans by name color without repeating avatars per row. + * Lightened toward white so the darker brand hues clear the contrast floor + * on the zinc-900 surface. */ +function senderColor(name: string): string | undefined { + const bg = agentIconFor(name)?.bg; + return bg ? `color-mix(in srgb, ${bg} 62%, white)` : undefined; } -function KindIcon({ kind }: { kind: EventKind }) { - if (kind === "approval") return ; - if (kind === "file") return ; - if (kind === "trigger") return ; - return ; +/** Inline-highlight @mentions inside an excerpt (the handoff cue: a bot + * @-ing another bot reads at a glance). Pure text styling — still inert. */ +const MENTION_RE = /(@[\p{L}\p{N}_.-]+)/gu; +function renderExcerpt(text: string): ReactNode { + if (!text.includes("@")) return text; + return text.split(MENTION_RE).map((part, i) => + part.startsWith("@") ? ( + + {part} + + ) : ( + part + ) + ); +} + +function MessageRow({ + n, + memberOf, + onJump, +}: { + n: NormEvent; + memberOf: MemberLookup; + onJump?: (msgId: string) => void; +}) { + const actor = memberOf(n.actorId); + const name = nameOf(actor, n.actorId); + const brand = n.actorType === "bot" ? senderColor(name) : undefined; + const clickable = Boolean(n.msgId && onJump); + + return ( + + ); } function EpisodeDetail({ @@ -300,123 +287,140 @@ function EpisodeDetail({ memberOf: MemberLookup; onJump?: (msgId: string) => void; }) { - const trigger = memberOf(ep.triggerActorId); - const dom = memberOf(ep.dominantActorId); const rows = useMemo(() => detailRows(ep), [ep]); - const span = fmtSpan(ep.startTs, ep.endTs); - - // The first bot the trigger addressed, for the "X → @Y" header line. - const firstMention = ep.events.find((n) => n.kind === "trigger")?.mentions[0]; - const mentioned = firstMention ? memberOf(firstMention.member_id) : dom; - + const summary = episodeSummary(ep); return ( -
-
- - - - {nameOf(trigger ?? dom, ep.triggerActorId ?? ep.dominantActorId)} - - {ep.triggerActorId && mentioned && ( - <> - - @{nameOf(mentioned, firstMention?.member_id)} - - )} - - - {fmtTime(ep.startTs)} - {span && ` · ${span}`} - -
- -
+
+ {summary &&
{summary}
} +
{rows.map((row, i) => { - if (row.type === "writeRun") { - const actor = memberOf(row.actorId); + if (row.type === "muted") { + const Icon = row.items.some((it) => it.kind === "write") ? Pencil : ShieldCheck; return ( -
- - - - - {nameOf(actor, row.actorId)} wrote{" "} - {row.count} file{row.count > 1 ? "s" : ""} +
+ + + {row.items.map((it, j) => ( + + {j > 0 && " · "} + {nameOf(memberOf(it.actorId), it.actorId)}{" "} + {it.kind === "write" + ? `wrote ${it.count} file${it.count > 1 ? "s" : ""}` + : `approved${it.count > 1 ? ` ×${it.count}` : ""}`} + + ))}
); } - const n = row.n; - const actor = memberOf(n.actorId); - const isMsg = n.kind === "trigger" || n.kind === "user_msg" || n.kind === "bot_msg" || n.kind === "file"; - const clickable = Boolean(n.msgId && onJump); + return ; + })} +
+
+ ); +} + +// ── one episode in the flow list: collapsed line + inline expansion ───────── +function FlowEpisode({ + ep, + memberOf, + expanded, + onToggle, + onJump, +}: { + ep: Episode; + memberOf: MemberLookup; + expanded: boolean; + onToggle?: () => void; + onJump?: (msgId: string) => void; +}) { + return ( +
+ + {expanded && } +
+ ); +} + +// ── participant strip: "who's here", recency-ordered avatar stack ────────── +// A collaboration-focused overview the flow list alone doesn't give you: every +// human/bot that's touched this channel, most-recently-active first, with +// presence — and a one-click way to spotlight just their activity (reuses the +// same `selected` filter the list/MemberFilter already read). +const PARTICIPANT_STRIP_CAP = 10; + +function ParticipantStrip({ + ids, + memberOf, + selected, + onToggle, +}: { + ids: string[]; + memberOf: MemberLookup; + selected: Set; + onToggle: (id: string) => void; +}) { + const shown = ids.slice(0, PARTICIPANT_STRIP_CAP); + const overflow = ids.length - shown.length; + const dim = selected.size > 0; + const online = ids.reduce((n, id) => n + (memberOf(id)?.is_online ? 1 : 0), 0); + + return ( +
+
+ {shown.map((id) => { + const mem = memberOf(id); + const name = nameOf(mem, id); + const active = selected.has(id); return ( ); })}
+ {overflow > 0 && ( + +{overflow} + )} + {online > 0 && {online} online}
); } @@ -426,10 +430,10 @@ function ActivityBody({ ctx }: { ctx: ViewBoardContext }) { const [events, setEvents] = useState(null); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(false); - const [lens, setLens] = useState("episodes"); - const [selectedId, setSelectedId] = useState(null); + const [lens, setLens] = useState("flow"); + // undefined = auto (newest episode expanded); null = user collapsed everything. + const [expandedId, setExpandedId] = useState(undefined); const [selected, setSelected] = useState>(() => new Set()); - const [hover, setHover] = useState<{ rect: DOMRect; item: RailItem } | null>(null); const toggleMember = useCallback((id: string) => { setSelected((prev) => { @@ -478,6 +482,23 @@ function ActivityBody({ ctx }: { ctx: ViewBoardContext }) { [events, isBot] ); + // Distinct participants across the WHOLE channel (not lens-filtered), most- + // recently-active first — episodes are already newest-first, so the first + // time an id shows up while walking them is its most recent activity. + const participantIds = useMemo(() => { + const seen = new Set(); + const order: string[] = []; + for (const ep of allEpisodes) { + for (const id of ep.participants) { + if (!seen.has(id)) { + seen.add(id); + order.push(id); + } + } + } + return order; + }, [allEpisodes]); + // Member filter → then the lens filter (Highlights drops chatter-only episodes). const episodes = useMemo(() => { let eps = allEpisodes; @@ -486,32 +507,28 @@ function ActivityBody({ ctx }: { ctx: ViewBoardContext }) { return eps; }, [allEpisodes, selected, lens]); - // Keep a valid selection: default to the newest episode; re-point if it vanished. - useEffect(() => { - if (!episodes.length) { - if (selectedId !== null) setSelectedId(null); - return; - } - if (!selectedId || !episodes.some((ep) => ep.id === selectedId)) { - setSelectedId(episodes[0].id); - } - }, [episodes, selectedId]); - - const selectedEpisode = episodes.find((ep) => ep.id === selectedId) ?? episodes[0] ?? null; - - // Rail items: one node per episode (episodes/highlights), or one per collapsed - // run (all). Each is a short typed label; details live in the hover bubble. - const railItems = useMemo(() => { - if (lens === "all") return episodes.flatMap((ep) => collapseEpisode(ep).map((run) => runItem(run, memberOf))); - return episodes.map((ep) => episodeItem(ep, memberOf)); - }, [episodes, lens, memberOf]); - - const onHover = useCallback((rect: DOMRect, item: RailItem) => setHover({ rect, item }), []); - const onLeave = useCallback(() => setHover(null), []); + // Auto-expand the newest episode until the user picks/collapses one themselves. + const effectiveExpanded = + lens === "all" ? null : expandedId === undefined ? (episodes[0]?.id ?? null) : expandedId; + const toggleEpisode = useCallback( + (id: string) => setExpandedId((prev) => { + const cur = prev === undefined ? (episodes[0]?.id ?? null) : prev; + return cur === id ? null : id; + }), + [episodes] + ); return ( void load()}>
+ {participantIds.length > 1 && ( + + )} {events == null ? (
@@ -533,41 +550,24 @@ function ActivityBody({ ctx }: { ctx: ViewBoardContext }) { />
) : ( -
- {/* Rail — the whole channel, one short node per row. */} -
-
- rail · whole channel -
- {railItems.map((item) => ( - setSelectedId(item.episodeId)} - onHover={onHover} - onLeave={onLeave} - /> - ))} -
- - {/* Detail — the selected episode, expanded. */} -
- {selectedEpisode ? ( - - ) : ( -
- Select an episode to see its detail. -
- )} -
+
+ {episodes.map((ep) => ( + toggleEpisode(ep.id)} + onJump={ctx.onJumpToMessage} + /> + ))}
)} - {/* Footer: lens tabs (left) + member filter (right) — mirrors the mock. */} + {/* Footer: lens tabs (left) + member filter (right). */}
- {(["episodes", "highlights", "all"] as Lens[]).map((l) => ( + {(["flow", "highlights", "all"] as Lens[]).map((l) => (
- {/* Portalled to : the ViewBoard window is a transformed, - overflow-hidden containing block that would otherwise clip a fixed - child (and re-anchor its coordinates). */} - {hover && - createPortal( - , - document.body - )} ); } -// ── member filter (unchanged behavior; avatars from last redesign) ────────── +// ── member filter (shared popover primitive; avatars with presence) ───────── function FilterChip({ active, onClick, @@ -619,11 +611,12 @@ function FilterChip({ return ( @@ -647,25 +640,8 @@ function MemberFilter({ }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); - - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (!(e.target as HTMLElement).closest("[data-activity-filter-root]")) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - } - }; - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + const rootRef = useRef(null); + usePopoverDismiss(open, () => setOpen(false), rootRef); const ql = q.trim().toLowerCase(); const shown = useMemo( @@ -679,7 +655,7 @@ function MemberFilter({ ); return ( -
+
{open && ( -
+
@@ -776,7 +748,7 @@ function MemberFilter({
)} -
+ )}
); From 596dda441300ca906a6df9c72e06e985c18d84a4 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 13:32:52 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(frontend):=20global=20error-notificati?= =?UTF-8?q?on=20system=20=E2=80=94=20401=20takeover,=20ErrorBoundary,=20Ba?= =?UTF-8?q?nner/ErrorState/notify=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors were toast-only (87 sites) with no M/L tiers: an expired token stranded the user on a page looping 401 toasts, render crashes blanked the app, and a dropped websocket froze silently. Three-tier system (documented in DESIGN.md §2.16): - P0 session expiry: api client classifies 401s (non-/auth/*, token present) into authStore.sessionExpired; App renders a full-screen "Session expired" takeover whose Sign-in-again round-trips through /login?redirect=…; RequireAuth now preserves the redirect too. WS auth_err flips the same flag and stops reconnecting with a dead token. - P0 crashes: top-level ErrorBoundary → ErrorState (Reload + copy details). - P1 tier components: ui/banner.tsx (soft status strip) and ui/error-state.tsx (panel/full-page error canon); useChatRealtime exposes status/reconnectNow driving a debounced "Connection lost" banner in ChannelView; ChatLayout bootstrap panel, ChannelView load error and WorkbenchManager strip now use the shared components. - P2: lib/notify.tsx semantic toasts with an optional action; 23 toast.error(String(e)) sites → notify.error(messageOf(e)) so humanized ApiError messages stop degrading; ErrorDialog optional action. Verified end-to-end on the kind stack (headless Chrome): corrupt token → takeover → sign in → redirected back; killed gateway → banner → retry-while-down persists → auto-clears on restore. Two race fixes came out of e2e: navigate-before-logout (RequireAuth's was clobbering ?redirect=) and takeover z-index above the Toaster. Co-Authored-By: Claude Fable 5 --- frontend/DESIGN.md | 40 +++++++ frontend/DESIGN.zh-CN.md | 36 +++++++ frontend/src/App.tsx | 69 +++++++++++- frontend/src/api/client.ts | 14 +++ frontend/src/components/ErrorBoundary.tsx | 46 ++++++++ frontend/src/components/ui/ErrorDialog.tsx | 29 ++++- frontend/src/components/ui/banner.tsx | 81 ++++++++++++++ frontend/src/components/ui/error-state.tsx | 68 ++++++++++++ .../src/features/bots/BotActivitySection.tsx | 4 +- .../bots/BotConnectionHistorySection.tsx | 4 +- .../src/features/bots/BotOnboardingWizard.tsx | 16 +-- .../bots/BotPermissionGrantsSection.tsx | 6 +- .../src/features/bots/BotPostureSection.tsx | 10 +- .../features/bots/BotToBotGrantsSection.tsx | 6 +- frontend/src/features/bots/BotsManager.tsx | 5 +- frontend/src/features/chat/ChannelView.tsx | 49 ++++++--- frontend/src/features/chat/ChatLayout.tsx | 16 ++- .../src/features/chat/ComposerBotSettings.tsx | 3 +- .../src/features/chat/NewSessionDialog.tsx | 3 +- .../features/chat/hooks/useChatRealtime.ts | 63 +++++++---- .../src/features/chat/hooks/useUserSocket.ts | 9 +- .../chat/workbench/panels/SessionsPanel.tsx | 5 +- .../features/workbench/WorkbenchManager.tsx | 28 +++-- frontend/src/lib/notify.tsx | 102 ++++++++++++++++++ frontend/src/main.tsx | 5 +- frontend/src/stores/authStore.ts | 13 ++- 26 files changed, 632 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ui/banner.tsx create mode 100644 frontend/src/components/ui/error-state.tsx create mode 100644 frontend/src/lib/notify.tsx diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 69f931a3..9c487134 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -345,6 +345,44 @@ accent fill; the irreversible one gets a `…` suffix (`Delete…`) to signal a confirm step follows (§7 reversibility — prefer a confirm dialog to an inline red button that fires on first click). Consequences go in a ``. +### 2.16 Error notifications — three tiers + +Pick the tier by **how much of the user's current work is unusable**, not by +technical severity — and every error names an exit (Retry / Sign in again / +Reload / Go back), never just a statement of failure. + +| Tier | User state | Form | Component | +|---|---|---|---| +| **S — routine failure** | can keep working | toast, bottom-right, auto-dismisses | `notify.error/warning/success/info` (`src/lib/notify.tsx`) — carries one optional action (`{ label, onClick }`) | +| **M — degraded context** | still readable, but the context is impaired | persistent soft strip atop the affected region; reflects a *state*, unmounts when it clears | `` (`src/components/ui/banner.tsx`) | +| **L — blocked** | must resolve before continuing | blocking dialog · panel/full-page state | `` · `` (`src/components/ui/error-state.tsx`) | + +Global wiring that already exists — extend it, don't rebuild it: + +- **Session expiry**: a 401 on any authenticated request (`api/client.ts` + classifier, `/auth/*` exempt) or a ws `auth_err` flips + `authStore.sessionExpired` → `App` renders the full-screen **Session + expired** takeover, whose "Sign in again" round-trips through + `/login?redirect=…`. Never handle 401 at a call site. +- **Render crashes**: the top-level `ErrorBoundary` (`main.tsx`) renders an + `ErrorState` with Reload + copy-details. Don't add per-page boundaries + without a reason. +- **Connection loss**: `useChatRealtime().status` drives the ChannelView + "Connection lost" `` (1.5s grace before showing; auto-clears on + resubscribe; "Retry now" = `reconnectNow`). + +Status → tier quick map: `401` → L takeover (automatic) · route-level +`403`/`404` → `` in the panel · validation `409`/`422` → inline +field error first (§2.3 error ring + `text-red-400` line), toast only without +a form · `429`/`5xx`/network → `notify.error` with a Retry action when the +caller can retry · ws drop → M banner. Inline beats toast when the error has +an anchor (a message, a field): keep `MessageItem`-style "Failed to send + +Retry" rows. + +**Don't**: `toast.error(String(e))` — it re-degrades the already-humanized +`ApiError` message to `Error: …`; use `notify.error(messageOf(e))`. Don't +hand-roll full-page error markup when `` fits. + --- ## 3. Known gaps (extraction roadmap) @@ -380,3 +418,5 @@ Reject in review: - [ ] `outline-none` without a replacement focus affordance - [ ] raw enum / field names in UI copy (`in_progress`, `system_admin`, `bot_id`) - [ ] new tab / empty-state / spinner styles when §2 already has one +- [ ] `toast.error(String(e))` — use `notify.error(messageOf(e))` (§2.16) +- [ ] hand-rolled error banners / full-page error markup when §2.16 has a tier for it; 401 handling at a call site (the client classifier owns it) diff --git a/frontend/DESIGN.zh-CN.md b/frontend/DESIGN.zh-CN.md index 66a617c2..6caf9065 100644 --- a/frontend/DESIGN.zh-CN.md +++ b/frontend/DESIGN.zh-CN.md @@ -224,6 +224,40 @@ className="rounded-lg bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:te 选中 `bg-zinc-800 text-zinc-100`(导航列表可按 §2.8 的激活胶囊加 indigo 着色)。 所有可交互行必须有 hover 态。 +### 2.16 错误提示——三级体系 + +级别由**用户当前工作还剩多少可用**决定,而不是技术严重程度——并且每个错误都 +必须给出口(Retry / Sign in again / Reload / Go back),不能只陈述失败。 +(英文版 §2.13–2.15 暂未镜像,本节编号与英文版对齐。) + +| 级别 | 用户状态 | 形态 | 组件 | +|---|---|---|---| +| **S · 轻** | 可以继续工作 | toast,右下角,自动消失 | `notify.error/warning/success/info`(`src/lib/notify.tsx`),可带一个动作 `{ label, onClick }` | +| **M · 中** | 还能看,但上下文已降级 | 常驻软色条,置于受影响区域顶部;反映"状态",状态解除即卸载 | ``(`src/components/ui/banner.tsx`) | +| **L · 重** | 必须先处理 | 阻塞对话框 · 面板/整页错误态 | `` · ``(`src/components/ui/error-state.tsx`) | + +已有的全局接线——扩展它,不要重建: + +- **登录过期**:任何带 token 请求的 401(`api/client.ts` 分类器,`/auth/*` 豁免) + 或 ws `auth_err` 都会置位 `authStore.sessionExpired` → `App` 渲染全屏 + **Session expired** 接管页,"Sign in again" 经 `/login?redirect=…` 回跳。 + **不要在调用点单独处理 401。** +- **渲染崩溃**:顶层 `ErrorBoundary`(`main.tsx`)渲染 `ErrorState` + (Reload + 复制错误详情)。无理由不要加页面级 boundary。 +- **连接断开**:`useChatRealtime().status` 驱动 ChannelView 的 + 「Connection lost」``(1.5s 宽限再显示;重订阅后自动收起; + "Retry now" = `reconnectNow`)。 + +状态 → 级别速查:`401` → L 接管(自动)· 路由级 `403`/`404` → 面板内 +`` · 校验 `409`/`422` → 优先字段旁 inline(§2.3 错误 ring + +`text-red-400`),无表单才 toast · `429`/`5xx`/网络 → `notify.error`,可重试 +就带 Retry 动作 · ws 断开 → M 级 banner。错误有锚点(某条消息、某个字段)时 +inline 优先于 toast——保留 `MessageItem` 式的「Failed to send + Retry」行。 + +**不要**:`toast.error(String(e))`——会把已人性化的 `ApiError` 文案退化回 +`Error: …`;用 `notify.error(messageOf(e))`。`` 能覆盖时不要手写 +整页错误标记。 + --- ## 3. 已知缺口(组件抽取路线图) @@ -254,3 +288,5 @@ Review 时直接打回: - [ ] `outline-none` 而没有替代的 focus 可见性 - [ ] 原始枚举 / 字段名直接进 UI(`in_progress`、`system_admin`、`bot_id`) - [ ] §2 已有的模式(tab / 空态 / spinner)又发明新样式 +- [ ] `toast.error(String(e))`——用 `notify.error(messageOf(e))`(§2.16) +- [ ] §2.16 已有对应级别时手写错误横条 / 整页错误标记;在调用点单独处理 401(归 client 分类器管) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46a1002c..43a57d5a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,15 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useEffect } from "react"; +import { Lock } from "lucide-react"; +import toast from "react-hot-toast"; import { Spinner as LoadingIcon } from "@/components/ui/spinner"; -import { Routes, Route, Navigate } from "react-router-dom"; +import { + Routes, + Route, + Navigate, + useNavigate, + useLocation, +} from "react-router-dom"; +import { ErrorState } from "@/components/ui/error-state"; import { useAuthStore } from "@/stores/authStore"; const LoginPage = lazy(() => import("@/features/auth/LoginPage")); @@ -23,10 +32,63 @@ function Spinner() { function RequireAuth({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (!user) return ; + const location = useLocation(); + if (!user) { + // Carry the intended destination so signing in lands back here instead of + // dumping the user at the default /chat (LoginPage consumes ?redirect=). + const here = location.pathname + location.search; + return ( + + ); + } return <>{children}; } +// Tier-L takeover for an expired session (set by the api client / ws hooks on a +// rejected token). Covers the whole app so the user can't keep operating a dead +// session; "Sign in again" bounces through /login and back to where they were. +function SessionExpiredTakeover() { + const expired = useAuthStore((s) => s.sessionExpired && s.user !== null); + const logout = useAuthStore((s) => s.logout); + const navigate = useNavigate(); + const location = useLocation(); + + // The 401s that tripped this takeover usually also fired their call sites' + // error toasts (some land after we mount). Sweep the existing ones; the + // overlay's z-index sits above the Toaster (9999) so stragglers stay hidden. + useEffect(() => { + if (expired) toast.dismiss(); + }, [expired]); + + if (!expired) return null; + + const signInAgain = () => { + const here = location.pathname + location.search; + // Navigate BEFORE clearing auth: logout() re-renders RequireAuth with a null + // user, whose would otherwise race us and clobber the + // ?redirect= query. + navigate(`/login?redirect=${encodeURIComponent(here)}`, { replace: true }); + logout(); + }; + + return ( +
+ +
+ ); +} + export default function App() { return ( }> @@ -71,6 +133,7 @@ export default function App() { /> } /> + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 116fe902..f3bcf56b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { useAuthStore } from "@/stores/authStore"; + const API_BASE = (import.meta as { env?: Record }).env?.VITE_API_BASE_URL || "/api/v1"; @@ -22,6 +24,17 @@ function authHeaders(): Record { return headers; } +// Global session-expiry classifier: a 401 on any authenticated request means the +// token is dead — flip the auth store so App shows the full-screen "Session +// expired" takeover (with a sign-in exit), instead of stranding the user on a +// page that keeps failing. `/auth/*` is exempt: there a 401 is a credential +// error (wrong password, bad reset code), not an expired session. +function classifyAuthFailure(path: string, status: number): void { + if (status !== 401 || path.startsWith("/auth/")) return; + const auth = useAuthStore.getState(); + if (auth.token) auth.markSessionExpired(); +} + export async function apiFetch( path: string, init?: RequestInit @@ -34,6 +47,7 @@ export async function apiFetch( ...(init?.headers as Record | undefined), }, }); + classifyAuthFailure(path, res.status); return res; } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..f091adbc --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import toast from "react-hot-toast"; +import { ErrorState } from "@/components/ui/error-state"; + +// Top-level render-crash net (tier L of the global error system). Without it a +// single render throw blanks the whole app; with it the user gets a Reload exit +// and can hand us the stack. Mounted once in main.tsx around . +export class ErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // The one deliberate console.error in the app: a crash here has no other + // sink, and the stack is what makes a bug report actionable. + console.error("Unhandled render error:", error, info.componentStack); + } + + copyDetails = () => { + const err = this.state.error; + const details = `${err?.name ?? "Error"}: ${err?.message ?? "unknown"}\n${err?.stack ?? ""}`; + navigator.clipboard + .writeText(details) + .then(() => toast.success("Error details copied")) + .catch(() => toast.error("Couldn't copy — see the browser console")); + }; + + render() { + if (!this.state.error) return this.props.children; + return ( +
+ window.location.reload() }} + secondaryAction={{ label: "Copy error details", onClick: this.copyDetails }} + /> +
+ ); + } +} diff --git a/frontend/src/components/ui/ErrorDialog.tsx b/frontend/src/components/ui/ErrorDialog.tsx index e822352c..38fc0d98 100644 --- a/frontend/src/components/ui/ErrorDialog.tsx +++ b/frontend/src/components/ui/ErrorDialog.tsx @@ -5,13 +5,17 @@ import { Button } from "./button"; // The shared error popup for BLOCKING failures — when the user must read and // acknowledge before continuing (e.g. their message was rejected). Routine // operation failures use `toast.error` instead; that is the app-wide default. +// Pass `action` when there is a concrete next step (edit, retry, open settings): +// it becomes the primary button, with Cancel as the quiet exit. export function ErrorDialog({ message, title = "Something went wrong", + action, onClose, }: { message: string; title?: string; + action?: { label: string; onClick: () => void }; onClose: () => void; }) { return ( @@ -26,10 +30,27 @@ export function ErrorDialog({ maxWidth="max-w-sm" >

{message}

-
- +
+ {action ? ( + <> + + + + ) : ( + + )}
); diff --git a/frontend/src/components/ui/banner.tsx b/frontend/src/components/ui/banner.tsx new file mode 100644 index 00000000..9cce4b5c --- /dev/null +++ b/frontend/src/components/ui/banner.tsx @@ -0,0 +1,81 @@ +import type { ComponentType, ReactNode } from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Severity = "error" | "warning" | "info" | "success"; + +// Soft fills per DESIGN.md §1 color semantics — the banner is a tinted strip in +// the document flow, never an overlay. +const severityCls: Record = { + error: "bg-red-950/45 text-red-300", + warning: "bg-amber-900/40 text-amber-200", + info: "bg-indigo-600/15 text-indigo-200", + success: "bg-emerald-500/10 text-emerald-400", +}; + +// The action chip sits ON the tinted fill, so it's one step stronger than the +// §2.1 soft-button recipes (which assume a zinc surface underneath). +const actionCls: Record = { + error: "bg-red-900/60 text-red-100 hover:bg-red-900/90", + warning: "bg-amber-900/70 text-amber-100 hover:bg-amber-900", + info: "bg-indigo-600/25 text-indigo-100 hover:bg-indigo-600/40", + success: "bg-emerald-900/60 text-emerald-100 hover:bg-emerald-900/90", +}; + +// Tier M of the global error system: a persistent status strip pinned to the top +// of the affected region (chat area, dialog form, settings section). A banner +// reflects an ongoing STATE, not an event — mount it while the state holds and +// unmount when it clears; one-off failures belong in a toast instead. Ongoing +// states ("reconnecting…") should omit `onDismiss` and clear themselves. +export function Banner({ + severity, + icon: Icon, + action, + onDismiss, + className, + children, +}: { + severity: Severity; + icon?: ComponentType<{ className?: string }>; + action?: { label: string; onClick: () => void }; + onDismiss?: () => void; + className?: string; + children: ReactNode; +}) { + return ( +
+ {Icon && } +
{children}
+ {action && ( + + )} + {onDismiss && ( + + )} +
+ ); +} diff --git a/frontend/src/components/ui/error-state.tsx b/frontend/src/components/ui/error-state.tsx new file mode 100644 index 00000000..f0a09914 --- /dev/null +++ b/frontend/src/components/ui/error-state.tsx @@ -0,0 +1,68 @@ +import type { ComponentType } from "react"; +import { AlertCircle } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { Button } from "./button"; + +// The canonical error state (tier L of the global error system): centered glyph + +// one-line title + one-line explanation + a primary exit action. Mirrors the +// EmptyState skeleton (DESIGN.md §2.9) in error semantics. Use it when the current +// view is unusable (load failed, no access, session expired, crashed) — routine +// operation failures use toast, degraded-but-still-usable states use . +export function ErrorState({ + icon: Icon = AlertCircle, + tone = "error", + title, + description, + action, + secondaryAction, + className, +}: { + icon?: ComponentType<{ className?: string }>; + /** error = it broke (red) · warning = needs attention, nothing lost (amber). */ + tone?: "error" | "warning"; + title: string; + description?: string; + action?: { label: string; onClick: () => void }; + /** Quiet text link next to the primary action (e.g. "Copy error details"). */ + secondaryAction?: { label: string; onClick: () => void }; + className?: string; +}) { + return ( +
+ +

{title}

+ {description && ( +

{description}

+ )} + {(action || secondaryAction) && ( +
+ {action && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/features/bots/BotActivitySection.tsx b/frontend/src/features/bots/BotActivitySection.tsx index c59f5318..317aa88b 100644 --- a/frontend/src/features/bots/BotActivitySection.tsx +++ b/frontend/src/features/bots/BotActivitySection.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import toast from "react-hot-toast"; +import { notify, messageOf } from "@/lib/notify"; import { RefreshCw } from "lucide-react"; import { getBotAcpEvents, type AcpEventRow } from "@/api/bots"; @@ -33,7 +33,7 @@ export function BotActivitySection({ botId }: { botId: string }) { try { setEvents((await getBotAcpEvents(botId, 80)).events); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setLoading(false); } diff --git a/frontend/src/features/bots/BotConnectionHistorySection.tsx b/frontend/src/features/bots/BotConnectionHistorySection.tsx index 1d84de9d..451636bb 100644 --- a/frontend/src/features/bots/BotConnectionHistorySection.tsx +++ b/frontend/src/features/bots/BotConnectionHistorySection.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import toast from "react-hot-toast"; +import { notify, messageOf } from "@/lib/notify"; import { RefreshCw, ArrowUpCircle, ArrowDownCircle } from "lucide-react"; import { listBotConnectionEvents, type BotConnectionEvent } from "@/api/bots"; @@ -33,7 +33,7 @@ export function BotConnectionHistorySection({ botId }: { botId: string }) { try { setEvents(await listBotConnectionEvents(botId, 50)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setLoading(false); } diff --git a/frontend/src/features/bots/BotOnboardingWizard.tsx b/frontend/src/features/bots/BotOnboardingWizard.tsx index 472d8790..a6e88216 100644 --- a/frontend/src/features/bots/BotOnboardingWizard.tsx +++ b/frontend/src/features/bots/BotOnboardingWizard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; +import { notify, messageOf } from "@/lib/notify"; import { Bot, Terminal, @@ -29,7 +30,6 @@ import { type EnrollmentGuidance, type IssuedToken, } from "@/api/bots"; -import toast from "react-hot-toast"; import { Dialog } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import type { BotItem } from "@/types"; @@ -199,7 +199,7 @@ export function BotOnboardingWizard({ setBot(resolved); setStep(1); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -211,7 +211,7 @@ export function BotOnboardingWizard({ try { setConfig(await getConnectorConfig(bot.bot_id, agentType)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -223,7 +223,7 @@ export function BotOnboardingWizard({ try { setToken(await issueBotToken(bot.bot_id)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -641,7 +641,7 @@ function ScriptPanel({ try { setCode(await mintEnrollmentCode(bot.bot_id, agentType)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -653,7 +653,7 @@ function ScriptPanel({ await revokeEnrollmentCodes(bot.bot_id); setCode(null); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -760,7 +760,7 @@ function AgentPanel({ try { setCode(await mintEnrollmentCode(bot.bot_id, agentType)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } @@ -772,7 +772,7 @@ function AgentPanel({ await revokeEnrollmentCodes(bot.bot_id); setCode(null); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } diff --git a/frontend/src/features/bots/BotPermissionGrantsSection.tsx b/frontend/src/features/bots/BotPermissionGrantsSection.tsx index 6a8cffc9..5895099f 100644 --- a/frontend/src/features/bots/BotPermissionGrantsSection.tsx +++ b/frontend/src/features/bots/BotPermissionGrantsSection.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; -import toast from "react-hot-toast"; +import { notify, messageOf } from "@/lib/notify"; import { X, Plus } from "lucide-react"; import { getEventAccess, @@ -59,7 +59,7 @@ export function BotPermissionGrantsSection({ botId }: { botId: string }) { ); setMembersByChannel(Object.fromEntries(lists)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } }, [botId]); useEffect(() => { @@ -72,7 +72,7 @@ export function BotPermissionGrantsSection({ botId }: { botId: string }) { await fn(); await load(); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(null); } diff --git a/frontend/src/features/bots/BotPostureSection.tsx b/frontend/src/features/bots/BotPostureSection.tsx index 9c99cbb2..680c60ff 100644 --- a/frontend/src/features/bots/BotPostureSection.tsx +++ b/frontend/src/features/bots/BotPostureSection.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import toast from "react-hot-toast"; +import { notify, messageOf } from "@/lib/notify"; import { getBotPermissions, setBotPosture, @@ -27,14 +27,14 @@ export function BotPostureSection({ botId }: { botId: string }) { }, [botId]); useEffect(() => { - load().catch((e) => toast.error(String(e))); + load().catch((e) => notify.error(messageOf(e))); }, [load]); const changePosture = (mode: string) => { setBusy(true); setBotPosture(botId, mode) .then(load) - .catch((e) => toast.error(String(e))) + .catch((e) => notify.error(messageOf(e))) .finally(() => setBusy(false)); }; @@ -42,7 +42,7 @@ export function BotPostureSection({ botId }: { botId: string }) { setBusy(true); setBotConfigOption(botId, configId, value) .then(load) - .catch((e) => toast.error(String(e))) + .catch((e) => notify.error(messageOf(e))) .finally(() => setBusy(false)); }; @@ -57,7 +57,7 @@ export function BotPostureSection({ botId }: { botId: string }) { setManualConfigValue(""); return load(); }) - .catch((e) => toast.error(String(e))) + .catch((e) => notify.error(messageOf(e))) .finally(() => setBusy(false)); }; diff --git a/frontend/src/features/bots/BotToBotGrantsSection.tsx b/frontend/src/features/bots/BotToBotGrantsSection.tsx index 121765cd..f8fe5e9b 100644 --- a/frontend/src/features/bots/BotToBotGrantsSection.tsx +++ b/frontend/src/features/bots/BotToBotGrantsSection.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import toast from "react-hot-toast"; +import { notify, messageOf } from "@/lib/notify"; import { X, Plus } from "lucide-react"; import { getBotGrants, @@ -41,7 +41,7 @@ export function BotToBotGrantsSection({ botId }: { botId: string }) { try { setData(await getBotGrants(botId)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } }, [botId]); useEffect(() => { @@ -54,7 +54,7 @@ export function BotToBotGrantsSection({ botId }: { botId: string }) { await fn(); await load(); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(null); } diff --git a/frontend/src/features/bots/BotsManager.tsx b/frontend/src/features/bots/BotsManager.tsx index dac79e14..b6f41265 100644 --- a/frontend/src/features/bots/BotsManager.tsx +++ b/frontend/src/features/bots/BotsManager.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from "react"; +import { notify, messageOf } from "@/lib/notify"; import toast from "react-hot-toast"; import { Bot, KeyRound, RefreshCw, Circle, CircleDot, Ban, Wand2 } from "lucide-react"; import { @@ -83,7 +84,7 @@ export function BotsManager() { // Background polls stay quiet — a transient blip shouldn't toast. if (!opts?.silent) { setLoadFailed(true); - toast.error(String(e)); + notify.error(messageOf(e)); } } finally { if (!opts?.silent) setLoading(false); @@ -112,7 +113,7 @@ export function BotsManager() { try { setIssued(await issueBotToken(botId)); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } } diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index f4119a66..e3b0a1e7 100644 --- a/frontend/src/features/chat/ChannelView.tsx +++ b/frontend/src/features/chat/ChannelView.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef, useMemo, lazy, Suspense } from "react"; -import { ArrowLeft, Hash, Users, Loader2, PanelRight, PanelLeftClose, PanelLeftOpen, Paperclip, FolderTree, Settings, LayoutDashboard, Reply, X, Copy, Forward, AlertCircle } from "lucide-react"; +import { ArrowLeft, Hash, Users, Loader2, PanelRight, PanelLeftClose, PanelLeftOpen, Paperclip, FolderTree, Settings, LayoutDashboard, Reply, X, Copy, Forward, WifiOff } from "lucide-react"; import toast from "react-hot-toast"; import { listMessages, sendMessage } from "@/api/messages"; import { useContextPickStore, toBundle } from "./context/contextPick"; @@ -25,6 +25,8 @@ import { LaneBoundsContext } from "@/hooks/useLaneWindow"; import { LaneZones } from "./workbench/LaneZones"; import { LaneResizer } from "./workbench/LaneResizer"; import { ErrorDialog } from "@/components/ui/ErrorDialog"; +import { Banner } from "@/components/ui/banner"; +import { ErrorState } from "@/components/ui/error-state"; import { Button } from "@/components/ui/button"; import { usePopoverDismiss } from "@/components/ui/popover"; // Click-gated dialogs — kept out of the eager ChatLayout chunk. RemoteWorkspaceDialog @@ -489,7 +491,7 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P void loadCommands(); }, [catchUp, loadCommands]); - const { sendResourceReq, sendPresenceFocus } = useChatRealtime( + const { sendResourceReq, sendPresenceFocus, status: rtStatus, reconnectNow } = useChatRealtime( // Preview (not yet a member) → don't subscribe; the gateway gates realtime // frames on channel membership anyway. !channel || isPreview ? null : channel.channel_id, @@ -559,6 +561,18 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P // without re-subscribing the realtime hook. sendResourceReqRef.current = sendResourceReq; + // Tier-M connection banner: a dropped socket only surfaces after a short grace + // period (most blips heal within a retry or two), but a dead one (retry budget + // spent) shows immediately. Clears itself the moment the socket is back. + const [showConnBanner, setShowConnBanner] = useState(false); + useEffect(() => { + if (rtStatus === "reconnecting") { + const t = setTimeout(() => setShowConnBanner(true), 1500); + return () => clearTimeout(t); + } + setShowConnBanner(rtStatus === "offline"); + }, [rtStatus]); + // Re-flatten the palette when bot labels resolve after the initial fetch. useEffect(() => { void loadCommands(); @@ -1165,24 +1179,31 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P }`} >
+ {/* Live-connection banner (tier M): the channel is readable but frozen. */} + {showConnBanner && ( + + {rtStatus === "offline" + ? "Connection lost — new messages are paused." + : "Connection lost — reconnecting…"} + + )} {/* Messages */} {loading ? (
) : loadError ? ( -
- -

- Couldn't load messages. Check your connection and try again. -

- -
+ ) : ( -

- Couldn't load your workspaces. Check the gateway connection, then retry. -

- -
+ ); if (isMobile) { diff --git a/frontend/src/features/chat/ComposerBotSettings.tsx b/frontend/src/features/chat/ComposerBotSettings.tsx index b8d3842c..afe5d14c 100644 --- a/frontend/src/features/chat/ComposerBotSettings.tsx +++ b/frontend/src/features/chat/ComposerBotSettings.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { notify, messageOf } from "@/lib/notify"; import toast from "react-hot-toast"; import { SlidersHorizontal, Lock } from "lucide-react"; import { @@ -127,7 +128,7 @@ function BotInlineSettings({ onApplied?.(); toast.success("Applied"); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } diff --git a/frontend/src/features/chat/NewSessionDialog.tsx b/frontend/src/features/chat/NewSessionDialog.tsx index 9f4ce874..1bc5a2fd 100644 --- a/frontend/src/features/chat/NewSessionDialog.tsx +++ b/frontend/src/features/chat/NewSessionDialog.tsx @@ -4,6 +4,7 @@ // directory / extra roots, with allowed-root suggestions from the connector's // workspace policy. import { useEffect, useState } from "react"; +import { notify, messageOf } from "@/lib/notify"; import toast from "react-hot-toast"; import { Plus } from "lucide-react"; import { createChannelBotSession } from "@/api/sessionControl"; @@ -68,7 +69,7 @@ export function NewSessionDialog({ onCreated({ session_id: created.session_id, bot_id: botId }); onClose(); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setBusy(false); } diff --git a/frontend/src/features/chat/hooks/useChatRealtime.ts b/frontend/src/features/chat/hooks/useChatRealtime.ts index ac82d29d..87b1f151 100644 --- a/frontend/src/features/chat/hooks/useChatRealtime.ts +++ b/frontend/src/features/chat/hooks/useChatRealtime.ts @@ -1,8 +1,14 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; import { buildWsUrl } from "@/api/client"; import { useAuthStore } from "@/stores/authStore"; import type { Message, WsEvent } from "@/types"; +/** Live-connection state for the open channel, driving the tier-M banner: + * connecting → first attempt (no banner; the channel is still loading anyway), + * online → subscribed, reconnecting → dropped and retrying with backoff, + * offline → retry budget exhausted (manual `reconnectNow` is the exit). */ +export type RealtimeStatus = "connecting" | "online" | "reconnecting" | "offline"; + /** Workspace-presence entry inside a `presence` frame: WHO is currently viewing * WHICH bot's workspace, and at what `path` (a file, else a directory/cwd). */ export interface PresenceFocus { @@ -117,6 +123,10 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { const cbsRef = useRef(cbs); cbsRef.current = cbs; const pendingRef = useRef>(new Map()); + const [status, setStatus] = useState("connecting"); + // The server rejected our token (auth_err): reconnecting with the same token + // would just loop, so stop; the session-expired takeover is the exit. + const authFailedRef = useRef(false); const connect = useCallback(() => { if (!channelId || !token || !mountedRef.current) return; @@ -165,10 +175,15 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { return; } if (type === "auth_err") { + // Dead token → tier L: flip the global session-expired takeover and stop + // the reconnect loop (retrying with the same token can never succeed). + authFailedRef.current = true; + useAuthStore.getState().markSessionExpired(); ws.close(); return; } if (type === "subscribed") { + if (mountedRef.current) setStatus("online"); // (Re)subscribe ack — trigger REST since-seq catch-up to heal any gap // from a dropped connection (write-before-deliver self-heal). cbsRef.current.onReady?.(); @@ -271,8 +286,12 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { p.reject(new ResourceError("DISCONNECTED", "socket closed")); } pendingRef.current.clear(); - if (!mountedRef.current) return; - if (retryRef.current >= MAX_RETRIES) return; + if (!mountedRef.current || authFailedRef.current) return; + if (retryRef.current >= MAX_RETRIES) { + setStatus("offline"); + return; + } + setStatus("reconnecting"); const delay = Math.min(BASE_DELAY * 2 ** retryRef.current, MAX_DELAY); retryRef.current += 1; timerRef.current = setTimeout(connect, delay); @@ -283,25 +302,33 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { }; }, [channelId, token]); + // Recovery: the exponential-backoff loop caps out after MAX_RETRIES (~2.5min), + // which after a laptop sleep or a network blip would otherwise leave the channel + // silently frozen forever. Coming back online, refocusing the tab, or the + // banner's "Retry now" is the escape hatch — reset the retry budget and + // reconnect now if the socket has died. + const reconnectNow = useCallback(() => { + if (!mountedRef.current || authFailedRef.current) return; + const ws = wsRef.current; + if (ws && ws.readyState !== WebSocket.CLOSED) return; // already up or mid-connect + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + retryRef.current = 0; + setStatus("reconnecting"); + connect(); + }, [connect]); + useEffect(() => { mountedRef.current = true; + authFailedRef.current = false; // fresh channel/token → the ban no longer applies + setStatus("connecting"); // fresh (re)mount — don't carry a stale banner across channels connect(); - // Recovery: the exponential-backoff loop caps out after MAX_RETRIES (~2.5min), - // which after a laptop sleep or a network blip would otherwise leave the channel - // silently frozen forever. Coming back online or refocusing the tab is the escape - // hatch — reset the retry budget and reconnect now if the socket has died. const revive = () => { - if (!mountedRef.current) return; if (document.visibilityState === "hidden") return; - const ws = wsRef.current; - if (ws && ws.readyState !== WebSocket.CLOSED) return; // already up or mid-connect - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - retryRef.current = 0; - connect(); + reconnectNow(); }; window.addEventListener("online", revive); document.addEventListener("visibilitychange", revive); @@ -313,7 +340,7 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { document.removeEventListener("visibilitychange", revive); wsRef.current?.close(); }; - }, [connect]); + }, [connect, reconnectNow]); // Imperative resource client: send a resource_req on the live channel socket and // resolve when the matching resource_res (by req_id) returns. Used by the workbench @@ -355,5 +382,5 @@ export function useChatRealtime(channelId: string | null, cbs: Callbacks) { [] ); - return { sendResourceReq, sendPresenceFocus }; + return { sendResourceReq, sendPresenceFocus, status, reconnectNow }; } diff --git a/frontend/src/features/chat/hooks/useUserSocket.ts b/frontend/src/features/chat/hooks/useUserSocket.ts index 3762fea3..27ebe294 100644 --- a/frontend/src/features/chat/hooks/useUserSocket.ts +++ b/frontend/src/features/chat/hooks/useUserSocket.ts @@ -30,6 +30,9 @@ export function useUserSocket(onNotification: (data: unknown) => void) { const mountedRef = useRef(true); const cbRef = useRef(onNotification); cbRef.current = onNotification; + // Token rejected → reconnecting with it would just loop; stop and let the + // session-expired takeover (flipped below) be the exit. + const authFailedRef = useRef(false); const connect = useCallback(() => { if (!token || !mountedRef.current) return; @@ -53,6 +56,8 @@ export function useUserSocket(onNotification: (data: unknown) => void) { return; } if (frame.type === "auth_err") { + authFailedRef.current = true; + useAuthStore.getState().markSessionExpired(); ws.close(); return; } @@ -63,7 +68,8 @@ export function useUserSocket(onNotification: (data: unknown) => void) { }; ws.onclose = () => { - if (!mountedRef.current || retryRef.current >= MAX_RETRIES) return; + if (!mountedRef.current || authFailedRef.current) return; + if (retryRef.current >= MAX_RETRIES) return; const delay = Math.min(BASE_DELAY * 2 ** retryRef.current, MAX_DELAY); retryRef.current += 1; timerRef.current = setTimeout(connect, delay); @@ -74,6 +80,7 @@ export function useUserSocket(onNotification: (data: unknown) => void) { useEffect(() => { mountedRef.current = true; + authFailedRef.current = false; // fresh token → the ban no longer applies connect(); return () => { mountedRef.current = false; diff --git a/frontend/src/features/chat/workbench/panels/SessionsPanel.tsx b/frontend/src/features/chat/workbench/panels/SessionsPanel.tsx index f7823b3e..d15af12f 100644 --- a/frontend/src/features/chat/workbench/panels/SessionsPanel.tsx +++ b/frontend/src/features/chat/workbench/panels/SessionsPanel.tsx @@ -16,6 +16,7 @@ // the caller holds a session_create grant for) + optional working directory / // extra roots. All mutations refetch the board. import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { notify, messageOf } from "@/lib/notify"; import toast from "react-hot-toast"; import { Layers, CircleDot, Plus, X, Bot as BotIcon, Info, Folder, ArrowUp } from "lucide-react"; import { @@ -127,7 +128,7 @@ function SessionCard({ refetch(); toast.success("Applied"); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setLocalBusy(false); } @@ -407,7 +408,7 @@ function BotGroup({ refetch(); toast.success("Applied"); } catch (e) { - toast.error(String(e)); + notify.error(messageOf(e)); } finally { setPromoting(false); } diff --git a/frontend/src/features/workbench/WorkbenchManager.tsx b/frontend/src/features/workbench/WorkbenchManager.tsx index 4ee5b82a..dd51cad6 100644 --- a/frontend/src/features/workbench/WorkbenchManager.tsx +++ b/frontend/src/features/workbench/WorkbenchManager.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Blocks, Package, Puzzle, Trash2, Upload, X } from "lucide-react"; +import { AlertCircle, Blocks, CircleCheck, Package, Puzzle, Trash2, Upload } from "lucide-react"; +import { Banner } from "@/components/ui/banner"; import { useIsAdmin } from "@/stores/authStore"; import { installPlugin, @@ -94,22 +95,17 @@ export function WorkbenchManager() { {(error || notice) && ( -
{ + setError(null); + setNotice(null); + }} > - {error ?? notice} - -
+ {error ?? notice} +
)}
diff --git a/frontend/src/lib/notify.tsx b/frontend/src/lib/notify.tsx new file mode 100644 index 00000000..0544a290 --- /dev/null +++ b/frontend/src/lib/notify.tsx @@ -0,0 +1,102 @@ +import toast from "react-hot-toast"; +import { AlertCircle, CircleCheck, Info, TriangleAlert, X } from "lucide-react"; +import type { ComponentType } from "react"; + +// Tier S of the global error system: a thin semantic layer over react-hot-toast. +// Adds the severity icon and an optional ACTION (the exit — "Retry", "Reload"), +// which the stock toast.error string form can't carry. Prefer these over raw +// toast.* in new code; existing call sites migrate progressively. +// +// Durations: success is read-and-gone (3s); errors linger (6s), and an error +// with an action stays long enough to actually click it (10s). + +type Severity = "error" | "warning" | "success" | "info"; + +interface NotifyOpts { + /** At most one exit per toast; clicking it dismisses the toast. */ + action?: { label: string; onClick: () => void }; + /** Override the per-severity default (ms). */ + duration?: number; +} + +const ICONS: Record> = { + error: AlertCircle, + warning: TriangleAlert, + success: CircleCheck, + info: Info, +}; + +const ICON_CLS: Record = { + error: "text-red-400", + warning: "text-amber-400", + success: "text-emerald-400", + info: "text-indigo-400", +}; + +const DURATION: Record = { + error: 6000, + warning: 6000, + success: 3000, + info: 5000, +}; + +function show(severity: Severity, message: string, opts?: NotifyOpts): string { + const Icon = ICONS[severity]; + const duration = + opts?.duration ?? + (opts?.action && severity !== "success" ? 10000 : DURATION[severity]); + return toast.custom( + (t) => ( +
+ +
+ {message} + {opts?.action && ( +
+ +
+ )} +
+ +
+ ), + { duration } + ); +} + +export const notify = { + error: (message: string, opts?: NotifyOpts) => show("error", message, opts), + warning: (message: string, opts?: NotifyOpts) => show("warning", message, opts), + success: (message: string, opts?: NotifyOpts) => show("success", message, opts), + info: (message: string, opts?: NotifyOpts) => show("info", message, opts), +}; + +/** The human message of an unknown thrown value. Unlike `String(e)`, never + * yields "Error: …" / "[object Object]" — the api client already humanizes + * ApiError messages, so `e.message` is the renderable sentence. */ +export function messageOf(e: unknown, fallback = "Something went wrong"): string { + if (e instanceof Error) return e.message || fallback; + if (typeof e === "string" && e.trim()) return e.trim(); + return fallback; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fa9c8766..eefed06f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "react-hot-toast"; import App from "./App"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import "./index.css"; const queryClient = new QueryClient({ @@ -19,7 +20,9 @@ createRoot(document.getElementById("root")!).render( - + + + void; /** Swap just the token (e.g. the fresh token returned after a password change). */ setToken: (token: string) => void; + markSessionExpired: () => void; logout: () => void; } @@ -16,9 +21,11 @@ export const useAuthStore = create()( (set) => ({ user: null, token: null, - setAuth: (user, token) => set({ user, token }), - setToken: (token) => set({ token }), - logout: () => set({ user: null, token: null }), + sessionExpired: false, + setAuth: (user, token) => set({ user, token, sessionExpired: false }), + setToken: (token) => set({ token, sessionExpired: false }), + markSessionExpired: () => set({ sessionExpired: true }), + logout: () => set({ user: null, token: null, sessionExpired: false }), }), { name: "auth" } ) From 38a266b8cacbb30d40efa7683ecee5115fce2051 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 13:35:50 +0800 Subject: [PATCH 6/6] docs(design): archive the interactive error-notifications mockup in-repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-tier design spec (live demos per tier, status→tier decision table, adoption inventory) previously lived only as a hosted artifact; docs/design/ERROR_NOTIFICATIONS.html makes it reviewable offline and versioned with the code. DESIGN.md §2.16 (en + zh) now links to it. Header comment documents the ?only=/?demo= params used for automated screenshots. Co-Authored-By: Claude Fable 5 --- docs/design/ERROR_NOTIFICATIONS.html | 580 +++++++++++++++++++++++++++ frontend/DESIGN.md | 5 +- frontend/DESIGN.zh-CN.md | 2 + 3 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 docs/design/ERROR_NOTIFICATIONS.html diff --git a/docs/design/ERROR_NOTIFICATIONS.html b/docs/design/ERROR_NOTIFICATIONS.html new file mode 100644 index 00000000..5bf7dc11 --- /dev/null +++ b/docs/design/ERROR_NOTIFICATIONS.html @@ -0,0 +1,580 @@ + + +Cheers 全局错误提示系统 — 设计稿 + + +
+
+
Cheers · Design Proposal · 2026-07
+

全局错误提示系统

+

+ 一套按干扰程度分级的错误提示体系:轻(Toast)、中(Banner)、重(阻塞层)。 + 核心原则只有一条——每个错误都必须给用户一个出口:重试、重新登录、刷新,而不是只陈述失败。 + 所有样式严格沿用 DESIGN.md 的深色 zinc/indigo 体系,演示均为 1:1 还原。 +

+
+ + +
+

0 · 分级原则

+

级别由「用户还能不能继续当前工作」决定,而不是由错误在技术上多严重决定:

+ + + + + + + + + + + + + + + + + + + + + + +
级别用户状态形态典型场景
S · 轻可以继续工作,操作局部失败Toast,右下角,自动消失,可带一个动作保存失败、复制失败、发送失败(可重试)
M · 中还能看,但当前上下文已降级Banner,常驻在受影响区域顶部,状态解除自动收起连接断开重连中、会话即将过期、只读降级
L · 重必须先处理,才能继续阻塞对话框 / 整页接管登录已过期、应用崩溃、无访问权限
+

同一事件按状态升级:WebSocket 掉线 → M 级「重连中」banner;重连成功 → banner 自动消失 + S 级成功 toast;token 失效 → 直接 L 级整页接管。

+
+ + +
+
+ S

Toast — 轻提示

+ react-hot-toast 增强,现有依赖零新增 +
+

在现有 react-hot-toast 上包一层 notify:统一四种语义色图标 + 可选动作按钮(现在的 toast 只能"看",不能"做")。错误类 6s 后消失,带动作的错误 10s,成功 3s。

+ +
+ 试一试 + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+

位置沿用现状(右下),不遮挡输入框;同屏最多 3 条,后来者顶掉最旧的。

+ +

规格

+ + + + + + + +
表面bg-zinc-900 rounded-xl shadow-xl shadow-black/40 — 无边框,阴影分层(§2.4)
图标error AlertCircle text-red-400 · warning TriangleAlert text-amber-400 · success CircleCheck text-emerald-400 · info Info text-indigo-400
动作最多一个文字按钮,text-indigo-400 font-semibold;点击后 toast 立即关闭
文案说清「什么失败了 + 怎么办」:"Couldn't save — check your connection",不要裸抛 Request failed with status 500
+
+ + +
+
+ M

Banner — 状态条

+ 新组件 <Banner>,常驻直到状态解除 +
+

挂在受影响区域顶部(全局的挂在聊天区顶部,对话框内的挂在表单上方)。软色填充(danger/warning/indigo soft,§2.1),不打断输入,但一直可见。Banner 反映的是"状态"而非"事件"——状态解除时自动消失。

+ +
+ 试一试 + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+

三种语义:error(红,已经坏了)/ warning(琥珀,快要坏 / 降级中)/ info(靛蓝,中性通知)。

+ +

规格

+ + + + + + + +
表面error bg-red-950/45 text-red-300 · warning bg-amber-900/40 text-amber-200 · info bg-indigo-600/15 text-indigo-200(全部无边框)
动作同色系 soft 按钮(§2.1 danger/warning soft 配方),一条 banner 最多一个动作
关闭可选 X;"重连中"这类进行时状态不给关闭按钮,解除时自动收起
层级普通文档流(推挤内容),不用 overlay——用户必须能继续操作下面的界面
+
+ + +
+
+ L

阻塞层 — 对话框 & 整页接管

+ 升级现有 ErrorDialog + 新组件 <ErrorState> +
+

两种形态:对话框用于"这一步被拒绝,读完确认"(现有 ErrorDialog,增加可选动作按钮);整页接管用于"当前视图已经无效"——登录过期、崩溃、无权限。你提的痛点在这里解决:401 不再是死路,整页接管自带「重新登录」按钮,并记住回跳地址

+ +
+ 试一试 + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+

整页接管 = 图标 + 一句话标题 + 一句话解释 + 主行动按钮(沿用 §2.9 EmptyState 的骨架,错误语义色)。

+ +

规格

+ + + + + + + +
对话框现有 ErrorDialog 增加 action?: {label, onClick} — 有 action 时主按钮在右,"Cancel" 在左;无 action 保持现在的 "Got it"
整页<ErrorState icon title description action/>:glyph w-8 h-8 语义色,标题 text-sm font-semibold text-zinc-100,说明 text-xs text-zinc-400,主按钮 <Button>
登录过期全屏 overlay(bg-zinc-950)盖住整个 app,防止误操作已失效的界面;「Sign in again」跳 /login?next=当前路径
崩溃顶层 React ErrorBoundary 渲染同一个 ErrorState,动作为「Reload」,附错误摘要(可折叠)
+
+ + +
+

1 · 什么错误走哪一级

+

关键基础设施:API 层加一个全局错误分类器,HTTP 状态 → 级别 + 出口,一次映射,处处生效:

+ + + + + + + + + + + + + + + +
来源级别呈现出口
401 token 失效L整页接管「Session expired」Sign in again(带 ?next= 回跳)
403 无权限(路由级)L面板级 ErrorState「No access」Go back
403 无权限(操作级)Serror toast—(说明缺什么权限)
404 资源不存在L面板级 ErrorStateGo back / 回首页
409/422 校验、冲突S字段旁 inline 错误优先;无表单上下文时 toast修正输入
429 限流Swarning toast「Too fast — retry in Ns」自动倒计时
5xx 服务端错误Serror toastRetry
网络失败 / fetch 抛错S→M单次失败 toast;连续失败升级为 bannerRetry
WebSocket 断开M「Reconnecting…」banner(进行时,无关闭钮)自动重连;恢复后自动收起
消息被网关拒绝LErrorDialog(现状保留)Got it / 修改后重发
React 渲染崩溃LErrorBoundary → ErrorStateReload
+
+ + +
+

2 · 代码形态(草图)

+

三个新文件 + 一处改造,全部薄封装:

+
// lib/notify.tsx — Tier S:react-hot-toast 之上的语义层
+notify.error("Couldn't save changes", { action: { label: "Retry", onClick: retry } });
+notify.warning("Rate limited — retrying in 5s");
+notify.success("Invite link copied");
+
+// components/ui/banner.tsx — Tier M:受控状态条
+<Banner severity="warning" icon={WifiOff}
+        action={{ label: "Retry now", onClick: reconnect }}>
+  Connection lost — reconnecting…
+</Banner>
+
+// components/ui/error-state.tsx — Tier L:面板/整页错误态(EmptyState 的错误语义版)
+<ErrorState icon={Lock} title="Session expired"
+  description="Sign in again to pick up where you left off."
+  action={{ label: "Sign in again", onClick: goLogin }} />
+
+// api 层改造 — 全局分类器:一次映射,处处生效
+function handleApiError(err) {
+  if (err.status === 401) return authStore.sessionExpired();  // → 整页接管,记住 next
+  if (err.status === 429) return notify.warning(rateLimitMsg(err));
+  if (err.status >= 500)  return notify.error("Server error", { action: retryAction });
+  throw err;  // 业务层自己处理 4xx 语义
+}
+

改动量:notify ~80 行,Banner ~40 行,ErrorState ~40 行,api 分类器 ~30 行。ErrorDialog 加 action prop ~10 行。无新依赖。

+
+ + +
+

3 · 现有代码哪些能换上

+
+

frontend/src 的全量审计结论(2026-07-16):toast 已是事实标准(toast.error 87 处 / 39 个文件),但 M 级和 L 级完全缺失——没有任何全局 401 处理、没有 ErrorBoundary、没有 Banner 组件。ApiError.status 在 client.ts 里已经存在,只是没人消费它,所以全局分类器可以只改一处就接管全部 18 个 API 模块。

+ +

体检结果 → 对应层级

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现状位置去处
token 过期后原地反复弹 401 toast,不跳登录(RequireAuth 只看本地缓存 user)api/client.ts · App.tsx:24L · 整页接管 + Sign in again(P0)
渲染崩溃 = 白屏,全应用无 ErrorBoundaryApp.tsx(缺失)L · ErrorBoundary → ErrorState(P0)
WebSocket 拿着死 token 静默重连,~2.5 分钟后彻底冻结,零提示useChatRealtime.ts:267 · useUserSocket.ts:65M · 「Reconnecting…」banner;auth 拒绝时升 L(P1)
workspace 引导失败的手写红字面板ChatLayout.tsx:184L · 面板级 ErrorState + Retry(P1)
手写 ad-hoc 错误横条(bg-red-500/10,不合 DESIGN.md 规范)WorkbenchManager.tsx:99M · 换成共享 Banner(P1)
87 处 toast.error,其中 bots 系列 20+ 处是 toast.error(String(e)),把已人性化的报错又变回 Error: …39 个文件(bots/*、chat/*、settings/*、friends/*)S · 换 notify.error;可重试操作补 action(P2,渐进)
发送失败的消息旁 inline「Failed to send + Retry」MessageItem.tsx:136✓ 已是正确形态,保留(inline 优先于 toast)
表单/字段级红字(~15 个文件,部分带 role="alert")PermissionCardRegisterPageFleetPage✓ 保留 inline;仅统一样式为 text-red-400 配方
静默吞错 .catch(() => {})(mark-read、后台轮询等)~20 处逐个判断:best-effort 的保留静默,用户主动操作的补 S 级
+ +

实施顺序

+ + + + + + + +
阶段内容解决的痛点
P0client.ts 全局分类器 + authStore.sessionExpired + 整页接管 + 顶层 ErrorBoundary登录过期死路、崩溃白屏
P1新增 <Banner> / <ErrorState>;WS 状态接 banner;ChatLayout / WorkbenchManager 换用断线无感知、错误面板不统一
P2notify 薄封装,87 处 toast 渐进迁移(先修 String(e) 系列),ErrorDialog 加 actiontoast 只能看不能做、报错文案劣化
+

P0 不需要动 18 个 API 模块的任何调用点——分类器加在 apiJson 一处即可生效。

+
+
+
+ + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 9c487134..3b28306d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -349,7 +349,10 @@ inline red button that fires on first click). Consequences go in a ``. Pick the tier by **how much of the user's current work is unusable**, not by technical severity — and every error names an exit (Retry / Sign in again / -Reload / Go back), never just a statement of failure. +Reload / Go back), never just a statement of failure. Interactive mockup with +live demos of every tier: +[docs/design/ERROR_NOTIFICATIONS.html](../docs/design/ERROR_NOTIFICATIONS.html) +(open in a browser). | Tier | User state | Form | Component | |---|---|---|---| diff --git a/frontend/DESIGN.zh-CN.md b/frontend/DESIGN.zh-CN.md index 6caf9065..9be25b90 100644 --- a/frontend/DESIGN.zh-CN.md +++ b/frontend/DESIGN.zh-CN.md @@ -229,6 +229,8 @@ className="rounded-lg bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:te 级别由**用户当前工作还剩多少可用**决定,而不是技术严重程度——并且每个错误都 必须给出口(Retry / Sign in again / Reload / Go back),不能只陈述失败。 (英文版 §2.13–2.15 暂未镜像,本节编号与英文版对齐。) +各层级的可交互设计稿(浏览器直接打开,含 live 演示): +[docs/design/ERROR_NOTIFICATIONS.html](../docs/design/ERROR_NOTIFICATIONS.html)。 | 级别 | 用户状态 | 形态 | 组件 | |---|---|---|---|