From 79104bbaf268697ce89283a6a34d70baab35e3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 09:36:40 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(webchat):=20PR=20#779=20review=20polish?= =?UTF-8?q?=20=E2=80=94=20MembersTab/Modal/ChatContainer=20=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理 PR #779 三轮 review 残留 P2/P3 finding: - MembersTab: admin disable 自己 confirm (P3-3 自锁) + busyUserId 全局 disable (P3-5 并发) + flash setTimeout cleanup (P3-8) + invitation is_expired fallback (P3-5 时钟漂移) - SettingsModal: activeTab 回落 general (P3-2 空状态) + AnimatePresence exit 动画 (R1 P3-3) - GeneralTab/AIConfigTab: ApiError 替代 (err as any) (P3-4) + success setTimeout cleanup (P3-8) - ChatContainer: dropdown active 项显示 work_dir (P3-4 三轮提 UX 回归) + currentUser 401 区分红点提示 (P3-7) - errors.ts: ApiError 类 (P3-4 类型安全) - workspaces.ts: updateWorkspace 抛 ApiError - auth.ts: Invitation is_expired 接口 + admin* cookie 通道注释 (P3-5) Refs #772 --- .../chat/ChatContainer.assistant-ui.tsx | 27 +++- .../chat/settings-modal/ai-config-tab.tsx | 15 +- .../chat/settings-modal/general-tab.tsx | 16 ++- .../chat/settings-modal/members-tab.tsx | 34 ++++- .../chat/settings-modal/settings-modal.tsx | 129 ++++++++++-------- webchat/lib/api/auth.ts | 10 ++ webchat/lib/api/errors.ts | 22 +++ webchat/lib/api/workspaces.ts | 16 +-- 8 files changed, 183 insertions(+), 86 deletions(-) diff --git a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx index c750309e..e5f87274 100644 --- a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx +++ b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx @@ -78,6 +78,7 @@ export default function ChatContainer() { const [wsDropdownOpen, setWsDropdownOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [currentUser, setCurrentUser] = useState(null); + const [authError, setAuthError] = useState(false); const [sessionMetrics, setSessionMetrics] = useState(null); // nuqs deep link params @@ -119,10 +120,14 @@ export default function ChatContainer() { loadWorkspaces(); }, [loadWorkspaces]); - // Fetch current user profile (Profile tab + Members tab gating) + // Fetch current user profile (Profile tab + Members tab gating). Distinguish + // loading from auth-error (401) so a stale session surfaces a re-login prompt + // instead of silently hiding the Members tab (PR #779 review P3-7). useEffect(() => { let mounted = true; - getMe().then((u) => { if (mounted) setCurrentUser(u); }).catch(() => { /* not logged in or unreachable */ }); + getMe() + .then((u) => { if (mounted) { setCurrentUser(u); setAuthError(false); } }) + .catch(() => { if (mounted) setAuthError(true); }); return () => { mounted = false; }; }, []); @@ -214,10 +219,15 @@ export default function ChatContainer() { onClick={() => handleSwitchWorkspace(ws)} className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${isActive ? 'bg-[var(--accent-gold)]/10 text-[var(--accent-gold)]' : 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'}`} > - + {ws.name.slice(0, 2)} - {ws.name} + + {ws.name} + {isActive && ws.work_dir && ( + {ws.work_dir} + )} + ); })} @@ -271,12 +281,15 @@ export default function ChatContainer() { Docs - {/* Settings — opens SettingsModal (Phase 3) */} + {/* Settings — opens SettingsModal (Phase 3). Red dot on session expiry (PR #779 review P3-7). */} - +
+
+

Settings

+

{workspace?.name ?? 'Workspace'}

+
+ +
-
- {tabs.map((tab) => ( - - ))} -
+
+ {tabs.map((tab) => ( + + ))} +
-
- {activeTab === 'general' && workspace && ( - - )} - {activeTab === 'ai' && workspace && ( - - )} - {activeTab === 'profile' && currentUser && } - {activeTab === 'members' && currentUser?.role === 'admin' && } -
- - +
+ {activeTab === 'general' && workspace && ( + + )} + {activeTab === 'ai' && workspace && ( + + )} + {activeTab === 'profile' && currentUser && } + {activeTab === 'members' && currentUser?.role === 'admin' && ( + + )} +
+ + + )} + ); } diff --git a/webchat/lib/api/auth.ts b/webchat/lib/api/auth.ts index 44e7e01e..2e9a0dcc 100644 --- a/webchat/lib/api/auth.ts +++ b/webchat/lib/api/auth.ts @@ -19,6 +19,9 @@ export interface Invitation { expires_at: number; created_at?: number; used_at?: number; + // Server-computed expiry flag (clock-source-of-truth, avoids client drift; + // PR #779 review P3-5). Undefined on older backends → caller falls back. + is_expired?: boolean; } export interface OAuthProvider { @@ -121,6 +124,13 @@ export async function getOAuthProviders(signal?: AbortSignal): Promise { const res = await fetch(`${BASE}/api/admin/invitations`, { diff --git a/webchat/lib/api/errors.ts b/webchat/lib/api/errors.ts index 0b69ccd5..dd527abc 100644 --- a/webchat/lib/api/errors.ts +++ b/webchat/lib/api/errors.ts @@ -39,3 +39,25 @@ export async function parseApiError(res: Response): Promise { } return { status: res.status, code, message, raw }; } + +/** + * Typed API error — carries the parsed status/code/message so callers branch on + * `instanceof ApiError` instead of probing `(err as any).status`. Replaces the + * ad-hoc field-attachment pattern flagged in PR #779 review P3-4. + */ +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + readonly info: ApiErrorInfo; + constructor(info: ApiErrorInfo, message?: string) { + super(message || info.code || info.message || `API error ${info.status}`); + this.name = 'ApiError'; + this.status = info.status; + this.code = info.code; + this.info = info; + } + /** Build from a Response, parsing the envelope exactly once. */ + static async fromResponse(res: Response): Promise { + return new ApiError(await parseApiError(res)); + } +} diff --git a/webchat/lib/api/workspaces.ts b/webchat/lib/api/workspaces.ts index 80600975..dc7c79f0 100644 --- a/webchat/lib/api/workspaces.ts +++ b/webchat/lib/api/workspaces.ts @@ -1,5 +1,5 @@ import { BASE, authHeaders, authOpts, withAuth, extractApiError } from "@/lib/api/client"; -import { parseApiError } from "@/lib/api/errors"; +import { parseApiError, ApiError } from "@/lib/api/errors"; export interface Workspace { id: string; @@ -123,16 +123,12 @@ export async function updateWorkspace(id: string, opts: UpdateWorkspaceOptions, // message + the WORKSPACE_VERSION_MISMATCH code so UIs can re-fetch+retry. const info = await parseApiError(res); if (res.status === 409) { - const err = new Error('Workspace was modified concurrently — please reopen Settings and retry.'); - (err as any).status = 409; - (err as any).code = info.code || 'WORKSPACE_VERSION_MISMATCH'; - throw err; + throw new ApiError( + { ...info, code: info.code || 'WORKSPACE_VERSION_MISMATCH' }, + 'Workspace was modified concurrently — please reopen Settings and retry.', + ); } - const msg = info.code || info.message || info.raw || `updateWorkspace failed: ${res.status}`; - const err = new Error(msg); - (err as any).status = info.status; - if (info.code) (err as any).code = info.code; - throw err; + throw new ApiError(info); } return normalizeWorkspace(await res.json()); } From d467d69e74b7df23011e4676f8d02ccba514de1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 09:36:40 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(admin,spec):=20PR=20#779=20review=20pol?= =?UTF-8?q?ish=20=E2=80=94=20is=5Fexpired=20=E5=90=8E=E7=AB=AF=20+=20spec?= =?UTF-8?q?=20=E5=8B=98=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ListInvitations 响应加 is_expired (服务器计算,避免客户端时钟漂移,P3-5) invitationView 嵌入 *session.Invitation + IsExpired - spec §3.1 Gap-C1 条件勘误: 三者皆空才跳过 mergeContextUsage,非仅 TotalTokens (漏 MaxTokens/Model 短路,P3-1) - spec status: draft → analysis (P3-7: gap 分析完成,修复在 #776/#777/#778) Refs #772 --- docs/specs/Worker-Turn-Summary-Parity-Spec.md | 4 ++-- internal/admin/user_handlers.go | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/specs/Worker-Turn-Summary-Parity-Spec.md b/docs/specs/Worker-Turn-Summary-Parity-Spec.md index 4ae965d9..a792f6c7 100644 --- a/docs/specs/Worker-Turn-Summary-Parity-Spec.md +++ b/docs/specs/Worker-Turn-Summary-Parity-Spec.md @@ -3,7 +3,7 @@ type: spec tags: - project/HotPlex date: 2026-06-23 -status: draft +status: analysis progress: 0 related_issues: - Turn-Summary-Spec.md (数据流基准) @@ -72,7 +72,7 @@ Worker 产出事件 ──► SessionAccumulator 聚合 ──► snapshot() 注 **Gap-C1:context_usage 管道完全断裂** - `commands.go:22-31` `get_context_usage` 调用 `thread/read` 后返回 `{"raw": string(resp)}`(未解析的原始 JSON 字符串)。 - `pkg/events/helpers.go:60-98` `MapContextUsageResponse` 期望顶层 camelCase 键(`totalTokens`/`maxTokens`/`model`),`"raw"` 不匹配任何键 → `TotalTokens=0`。 -- `bridge_forward.go:913-916` 判断 `cu.TotalTokens <= 0` 跳过 `mergeContextUsage`。 +- `bridge_forward.go:913-916` 的守卫是 `cu.MaxTokens > 0 || cu.TotalTokens > 0 || cu.Model != ""` —— 三者皆空才跳过 `mergeContextUsage`(PR #779 review P3-1 勘误:原描述只提 TotalTokens,漏了 MaxTokens/Model 两个短路条件,可能误导修复者只补 TotalTokens)。 - **后果**:`context_fill` / `context_window` / `context_pct` 三字段恒为 0。 **Gap-C2:token 字段不全且非累计** diff --git a/internal/admin/user_handlers.go b/internal/admin/user_handlers.go index e584ec94..a4bf17a4 100644 --- a/internal/admin/user_handlers.go +++ b/internal/admin/user_handlers.go @@ -113,7 +113,19 @@ func (h *UserAdminHandlers) ListInvitations(w http.ResponseWriter, r *http.Reque web.WriteAppError(w, http.StatusInternalServerError, "INTERNAL", "list failed") return } - respondJSON(w, map[string]any{"invitations": invs, "limit": limit, "offset": offset}) + // Server-computed expiry (PR #779 review P3-5): avoids client clock drift. + // Embedded *session.Invitation serializes its fields plus is_expired. + now := h.nowUnix() + type invitationView struct { + *session.Invitation + IsExpired bool `json:"is_expired"` + } + views := make([]invitationView, len(invs)) + for i := range invs { + inv := invs[i] + views[i] = invitationView{Invitation: inv, IsExpired: inv.UsedAt == nil && inv.ExpiresAt < now} + } + respondJSON(w, map[string]any{"invitations": views, "limit": limit, "offset": offset}) } // DeleteInvitation: DELETE /api/admin/invitations/{id} From 7877dbec324840b70afa9d09d04704be92e78e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 12:24:14 +0800 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20retry=20review=20webhook=20?= =?UTF-8?q?=E2=80=94=20CI-success=20delivery=20for=20cron=20pr-review-hotp?= =?UTF-8?q?lex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #783 push 后 CI 已绿但 review 92min 未触发(远程 cron gateway 无响应, 疑似 CI-success webhook 因 github.com 间歇丢失)。空 commit 触发新 CI run, CI 绿后重发 webhook。 Refs #772 From 418d977f8f0d62ad388b4541f44b389f4ae8afae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 13:18:22 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(webchat):=20PR=20#783=20review=20P2=20?= =?UTF-8?q?=E2=80=94=20getMe=20=E5=8C=BA=E5=88=86=20401/403=20+=20ApiError?= =?UTF-8?q?=20raw=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2: ChatContainer getMe catch 把所有失败等同 session 过期。改为仅 401/403 setAuthError(true),网络/5xx 静默(re-login 解决不了)。 getMe 改用 ApiError.fromResponse 提供带 status 的类型错误。 P3: ApiError 构造补 info.raw 回退(非 JSON body 不再退化为 "API error N")。 ApiError.fromResponse 接入 getMe(消除死代码)。 Refs #772 --- .../app/components/chat/ChatContainer.assistant-ui.tsx | 10 +++++++++- webchat/lib/api/auth.ts | 3 ++- webchat/lib/api/errors.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx index e5f87274..d054b947 100644 --- a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx +++ b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx @@ -24,6 +24,7 @@ import { type Workspace, } from '@/lib/api/workspaces'; import { logout, getMe, type User } from '@/lib/api/auth'; +import { ApiError } from '@/lib/api/errors'; function ChatInterface({ sessionId, @@ -127,7 +128,14 @@ export default function ChatContainer() { let mounted = true; getMe() .then((u) => { if (mounted) { setCurrentUser(u); setAuthError(false); } }) - .catch(() => { if (mounted) setAuthError(true); }); + .catch((err) => { + if (!mounted) return; + // Only surface auth-expired for real 401/403; transient network/5xx + // failures are not solvable by re-login (PR #783 review P2). + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + setAuthError(true); + } + }); return () => { mounted = false; }; }, []); diff --git a/webchat/lib/api/auth.ts b/webchat/lib/api/auth.ts index 2e9a0dcc..766bf9ad 100644 --- a/webchat/lib/api/auth.ts +++ b/webchat/lib/api/auth.ts @@ -1,4 +1,5 @@ import { BASE, authHeaders, authOpts, withAuth, extractApiError } from "@/lib/api/client"; +import { ApiError } from "./errors"; export interface User { id: string; @@ -62,7 +63,7 @@ export async function getMe(signal?: AbortSignal): Promise { signal, }); if (!res.ok) { - throw new Error(await extractApiError(res, `getMe failed: ${res.status}`)); + throw await ApiError.fromResponse(res); } return res.json(); } diff --git a/webchat/lib/api/errors.ts b/webchat/lib/api/errors.ts index dd527abc..ffe2a2cf 100644 --- a/webchat/lib/api/errors.ts +++ b/webchat/lib/api/errors.ts @@ -50,7 +50,7 @@ export class ApiError extends Error { readonly code?: string; readonly info: ApiErrorInfo; constructor(info: ApiErrorInfo, message?: string) { - super(message || info.code || info.message || `API error ${info.status}`); + super(message || info.code || info.message || info.raw || `API error ${info.status}`); this.name = 'ApiError'; this.status = info.status; this.code = info.code; From fc86c22c3fd6d01608538312b04d1d60af7942bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 13:43:29 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(webchat):=20PR=20#784=20review=20P1/P2?= =?UTF-8?q?=20=E2=80=94=20self-disable=20logout=20+=20getMe=20AbortSignal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: handleToggleUser self-disable 成功后只 flash + load(),load 会 403 USER_DISABLED 致红色错误页卡死,与 confirm 承诺的"立即登出"矛盾。 改为 self-disable 成功后 logout + redirect /login(兑现文案)。 P2: ChatContainer getMe 未传 AbortSignal(本 PR abort 主题一致性)。 改用 AbortController + ctrl.signal.aborted 替代 mounted 闭包守卫。 Refs #772 --- .../app/components/chat/ChatContainer.assistant-ui.tsx | 10 +++++----- .../app/components/chat/settings-modal/members-tab.tsx | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx index d054b947..bd7d4ff7 100644 --- a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx +++ b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx @@ -125,18 +125,18 @@ export default function ChatContainer() { // loading from auth-error (401) so a stale session surfaces a re-login prompt // instead of silently hiding the Members tab (PR #779 review P3-7). useEffect(() => { - let mounted = true; - getMe() - .then((u) => { if (mounted) { setCurrentUser(u); setAuthError(false); } }) + const ctrl = new AbortController(); + getMe(ctrl.signal) + .then((u) => { if (!ctrl.signal.aborted) { setCurrentUser(u); setAuthError(false); } }) .catch((err) => { - if (!mounted) return; + if (ctrl.signal.aborted) return; // Only surface auth-expired for real 401/403; transient network/5xx // failures are not solvable by re-login (PR #783 review P2). if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { setAuthError(true); } }); - return () => { mounted = false; }; + return () => { ctrl.abort(); }; }, []); const handleSwitchWorkspace = (ws: Workspace) => { diff --git a/webchat/app/components/chat/settings-modal/members-tab.tsx b/webchat/app/components/chat/settings-modal/members-tab.tsx index 50f6bfcb..75c6d19f 100644 --- a/webchat/app/components/chat/settings-modal/members-tab.tsx +++ b/webchat/app/components/chat/settings-modal/members-tab.tsx @@ -7,6 +7,7 @@ import { adminListInvitations, adminCreateInvitation, adminDeleteInvitation, + logout, type User, type Invitation, } from '@/lib/api/auth'; @@ -83,6 +84,14 @@ export function MembersTab({ currentUser }: MembersTabProps) { setBusyUserId(user.id); try { await adminUpdateUserStatus(user.id, next); + if (currentUser?.id === user.id && next === 'disabled') { + // Honor the confirm promise "logged out immediately" (PR #784 review P1): + // self-disable invalidates the session — redirect instead of reloading + // the list, which would 403 USER_DISABLED and strand the user on an error page. + try { await logout(); } catch { /* session already invalidated */ } + window.location.replace('/login'); + return; + } flash('ok', `${user.username} → ${next}`); load(); } catch (err) { From 16d21e8399879ba095daf0c5b7881dd137ff9321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 24 Jun 2026 14:33:18 +0800 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20retry=20review=20webhook=20?= =?UTF-8?q?=E2=80=94=20CI-success=20delivery=20for=20fc86c22c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fc86c22c push 后 CI 绿但 cron 43min 未生成新 review(webhook 疑似丢失)。 空 commit 触发新 CI run,CI 绿后重发 webhook。 Refs #772