+
+
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 69f931a3..ec2c0f10 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -345,6 +345,79 @@ 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.
+
+### 2.17 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. 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 |
+|---|---|---|---|
+| **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 +453,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.17)
+- [ ] hand-rolled error banners / full-page error markup when §2.17 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..e482cbef 100644
--- a/frontend/DESIGN.zh-CN.md
+++ b/frontend/DESIGN.zh-CN.md
@@ -224,6 +224,42 @@ 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.17 错误提示——三级体系
+
+级别由**用户当前工作还剩多少可用**决定,而不是技术严重程度——并且每个错误都
+必须给出口(Retry / Sign in again / Reload / Go back),不能只陈述失败。
+(英文版 §2.13–2.16 暂未镜像,本节编号与英文版对齐。)
+各层级的可交互设计稿(浏览器直接打开,含 live 演示):
+[docs/design/ERROR_NOTIFICATIONS.html](../docs/design/ERROR_NOTIFICATIONS.html)。
+
+| 级别 | 用户状态 | 形态 | 组件 |
+|---|---|---|---|
+| **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 +290,5 @@ Review 时直接打回:
- [ ] `outline-none` 而没有替代的 focus 可见性
- [ ] 原始枚举 / 字段名直接进 UI(`in_progress`、`system_admin`、`bot_id`)
- [ ] §2 已有的模式(tab / 空态 / spinner)又发明新样式
+- [ ] `toast.error(String(e))`——用 `notify.error(messageOf(e))`(§2.17)
+- [ ] §2.17 已有对应级别时手写错误横条 / 整页错误标记;在调用点单独处理 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 (
+
+ );
+ }
+}
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/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 = (
);
- }
-
- // 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/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 (
+
+ {/* 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/RemoteWorkspaceDialog.tsx b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx
index ab90ca85..1afd8b07 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,30 @@ 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 && (
+
+
+
+ )}
);
})}
@@ -1782,15 +1812,18 @@ export function RemoteWorkspaceDialog({
undefined,
path: file.path,
sessionId: effectiveSessionId || undefined,
+ root: treeRoot ?? undefined,
})
);
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 && (
-
+
+ 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/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/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