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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
580 changes: 580 additions & 0 deletions docs/design/ERROR_NOTIFICATIONS.html

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions frontend/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Tip>`.

### 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
<div className="flex items-center -space-x-2">
{ids.map((id) => (
<button
key={id}
className={cn(
"relative rounded-full ring-2 transition-all",
active ? "ring-indigo-500" : "ring-zinc-900",
dimmed && "opacity-50 hover:opacity-100"
)}
>
<Avatar size="xs" className="!w-6 !h-6" online={m.is_online ?? undefined} … />
</button>
))}
</div>
{overflow > 0 && <span className="ml-1 text-[10px] text-zinc-400">+{overflow}</span>}
```

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 | `<Banner severity icon action onDismiss>` (`src/components/ui/banner.tsx`) |
| **L — blocked** | must resolve before continuing | blocking dialog · panel/full-page state | `<ErrorDialog action?>` · `<ErrorState icon tone title description action secondaryAction>` (`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" `<Banner>` (1.5s grace before showing; auto-clears on
resubscribe; "Retry now" = `reconnectNow`).

Status → tier quick map: `401` → L takeover (automatic) · route-level
`403`/`404` → `<ErrorState>` 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 `<ErrorState>` fits.


---

## 3. Known gaps (extraction roadmap)
Expand Down Expand Up @@ -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)
38 changes: 38 additions & 0 deletions frontend/DESIGN.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 · 中** | 还能看,但上下文已降级 | 常驻软色条,置于受影响区域顶部;反映"状态",状态解除即卸载 | `<Banner severity icon action onDismiss>`(`src/components/ui/banner.tsx`) |
| **L · 重** | 必须先处理 | 阻塞对话框 · 面板/整页错误态 | `<ErrorDialog action?>` · `<ErrorState>`(`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」`<Banner>`(1.5s 宽限再显示;重订阅后自动收起;
"Retry now" = `reconnectNow`)。

状态 → 级别速查:`401` → L 接管(自动)· 路由级 `403`/`404` → 面板内
`<ErrorState>` · 校验 `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))`。`<ErrorState>` 能覆盖时不要手写
整页错误标记。

---

## 3. 已知缺口(组件抽取路线图)
Expand Down Expand Up @@ -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 分类器管)
69 changes: 66 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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"));
Expand All @@ -23,10 +32,63 @@ function Spinner() {

function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!user) return <Navigate to="/login" replace />;
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 (
<Navigate to={`/login?redirect=${encodeURIComponent(here)}`} replace />
);
}
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 <Navigate to="/login"> would otherwise race us and clobber the
// ?redirect= query.
navigate(`/login?redirect=${encodeURIComponent(here)}`, { replace: true });
logout();
};

return (
<div
role="alertdialog"
aria-modal="true"
aria-label="Session expired"
className="fixed inset-0 z-[10000] bg-zinc-950 flex items-center justify-center"
>
<ErrorState
icon={Lock}
tone="warning"
title="Session expired"
description="Sign in again to pick up where you left off."
action={{ label: "Sign in again", onClick: signInAgain }}
/>
</div>
);
}

export default function App() {
return (
<Suspense fallback={<Spinner />}>
Expand Down Expand Up @@ -71,6 +133,7 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/chat" replace />} />
</Routes>
<SessionExpiredTakeover />
</Suspense>
);
}
14 changes: 14 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useAuthStore } from "@/stores/authStore";

const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL ||
"/api/v1";
Expand All @@ -22,6 +24,17 @@ function authHeaders(): Record<string, string> {
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
Expand All @@ -34,6 +47,7 @@ export async function apiFetch(
...(init?.headers as Record<string, string> | undefined),
},
});
classifyAuthFailure(path, res.status);
return res;
}

Expand Down
46 changes: 46 additions & 0 deletions frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 <App />.
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 (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<ErrorState
title="Something broke on our side"
description="The app hit an unexpected error. Reloading usually fixes it."
action={{ label: "Reload", onClick: () => window.location.reload() }}
secondaryAction={{ label: "Copy error details", onClick: this.copyDetails }}
/>
</div>
);
}
}
29 changes: 25 additions & 4 deletions frontend/src/components/ui/ErrorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -26,10 +30,27 @@ export function ErrorDialog({
maxWidth="max-w-sm"
>
<p className="text-sm text-zinc-300 whitespace-pre-wrap break-words">{message}</p>
<div className="flex justify-end pt-1">
<Button variant="secondary" size="sm" onClick={onClose}>
Got it
</Button>
<div className="flex justify-end gap-2 pt-1">
{action ? (
<>
<Button variant="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
onClose();
action.onClick();
}}
>
{action.label}
</Button>
</>
) : (
<Button variant="secondary" size="sm" onClick={onClose}>
Got it
</Button>
)}
</div>
</Dialog>
);
Expand Down
Loading
Loading