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} diff --git a/webchat/app/components/chat/ChatContainer.assistant-ui.tsx b/webchat/app/components/chat/ChatContainer.assistant-ui.tsx index c750309e..bd7d4ff7 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, @@ -78,6 +79,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,11 +121,22 @@ 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 */ }); - return () => { mounted = false; }; + const ctrl = new AbortController(); + getMe(ctrl.signal) + .then((u) => { if (!ctrl.signal.aborted) { setCurrentUser(u); setAuthError(false); } }) + .catch((err) => { + 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 () => { ctrl.abort(); }; }, []); const handleSwitchWorkspace = (ws: Workspace) => { @@ -214,10 +227,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 +289,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..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; @@ -19,6 +20,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 { @@ -59,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(); } @@ -121,6 +125,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..ffe2a2cf 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 || info.raw || `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()); }