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
4 changes: 2 additions & 2 deletions docs/specs/Worker-Turn-Summary-Parity-Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (数据流基准)
Expand Down Expand Up @@ -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 字段不全且非累计**
Expand Down
14 changes: 13 additions & 1 deletion internal/admin/user_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
39 changes: 30 additions & 9 deletions webchat/app/components/chat/ChatContainer.assistant-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,6 +79,7 @@ export default function ChatContainer() {
const [wsDropdownOpen, setWsDropdownOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [authError, setAuthError] = useState(false);
const [sessionMetrics, setSessionMetrics] = useState<SessionMetrics | null>(null);

// nuqs deep link params
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)]'}`}
>
<span className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black uppercase ${isActive ? 'bg-[var(--accent-gold)] text-black' : 'bg-[var(--bg-surface)] text-[var(--text-muted)] border border-[var(--border-subtle)]'}`}>
<span className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black uppercase flex-shrink-0 ${isActive ? 'bg-[var(--accent-gold)] text-black' : 'bg-[var(--bg-surface)] text-[var(--text-muted)] border border-[var(--border-subtle)]'}`}>
{ws.name.slice(0, 2)}
</span>
<span className="truncate font-medium">{ws.name}</span>
<span className="min-w-0 flex-1">
<span className="truncate font-medium block">{ws.name}</span>
{isActive && ws.work_dir && (
<span className="block text-[10px] font-mono text-[var(--text-faint)] truncate" title={ws.work_dir}>{ws.work_dir}</span>
)}
</span>
</button>
);
})}
Expand Down Expand Up @@ -271,12 +289,15 @@ export default function ChatContainer() {
</svg>
<span className="hidden md:inline">Docs</span>
</a>
{/* Settings — opens SettingsModal (Phase 3) */}
{/* Settings — opens SettingsModal (Phase 3). Red dot on session expiry (PR #779 review P3-7). */}
<button
onClick={() => setSettingsOpen(true)}
className="p-2 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-all"
title="Settings"
className={`relative p-2 rounded-lg transition-all ${authError ? 'text-[var(--accent-coral)] hover:bg-[var(--accent-coral)]/10' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'}`}
title={authError ? 'Session expired — please re-login to access Settings' : 'Settings'}
>
{authError && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-[var(--accent-coral)]" />
)}
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
Expand Down
15 changes: 12 additions & 3 deletions webchat/app/components/chat/settings-modal/ai-config-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { updateWorkspace, getWorkspace, type Workspace } from '@/lib/api/workspaces';
import { ApiError } from '@/lib/api/errors';
import { AgentConfigEditor } from '@/components/admin/agent-config-editor';

const WORKER_OPTIONS: { value: string; label: string }[] = [
Expand All @@ -22,13 +23,19 @@ export function AIConfigTab({ workspace, onUpdated }: AIConfigTabProps) {
const [savingWorker, setSavingWorker] = useState(false);
const [workerError, setWorkerError] = useState<string | null>(null);
const [workerSaved, setWorkerSaved] = useState(false);
// Tracked so unmount clears the pending setState (PR #779 review P3-8).
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
setWorker(workspace.worker_preference || '');
setWorkerError(null);
setWorkerSaved(false);
}, [workspace.id, workspace.worker_preference, workspace.updated_at]);

useEffect(() => () => {
if (savedTimer.current) clearTimeout(savedTimer.current);
}, []);

const dirty = worker !== (workspace.worker_preference || '');

const handleSaveWorker = async () => {
Expand All @@ -39,9 +46,11 @@ export function AIConfigTab({ workspace, onUpdated }: AIConfigTabProps) {
const updated = await updateWorkspace(workspace.id, { workerPreference: worker });
onUpdated?.(updated);
setWorkerSaved(true);
setTimeout(() => setWorkerSaved(false), 2000);
if (savedTimer.current) clearTimeout(savedTimer.current);
savedTimer.current = setTimeout(() => setWorkerSaved(false), 2000);
} catch (err) {
if ((err as { status?: number }).status === 409) {
// CAS 409 (PR #779 review P2-2): re-fetch latest and repopulate.
if (err instanceof ApiError && err.status === 409) {
setWorkerError('Workspace was modified elsewhere — refreshed to latest, please retry.');
try {
onUpdated?.(await getWorkspace(workspace.id));
Expand Down
16 changes: 13 additions & 3 deletions webchat/app/components/chat/settings-modal/general-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { updateWorkspace, getWorkspace, type Workspace } from '@/lib/api/workspaces';
import { ApiError } from '@/lib/api/errors';

interface GeneralTabProps {
workspace: Workspace;
Expand All @@ -13,6 +14,8 @@ export function GeneralTab({ workspace, onUpdated }: GeneralTabProps) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Tracked so unmount clears the pending setState (PR #779 review P3-8).
const successTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

// Resync when the workspace prop changes (e.g. after a parent re-fetch).
useEffect(() => {
Expand All @@ -21,6 +24,10 @@ export function GeneralTab({ workspace, onUpdated }: GeneralTabProps) {
setSuccess(false);
}, [workspace.id, workspace.name, workspace.updated_at]);

useEffect(() => () => {
if (successTimer.current) clearTimeout(successTimer.current);
}, []);

const dirty = name.trim() !== workspace.name && name.trim().length > 0;

const handleSave = async () => {
Expand All @@ -31,9 +38,12 @@ export function GeneralTab({ workspace, onUpdated }: GeneralTabProps) {
const updated = await updateWorkspace(workspace.id, { name: name.trim() });
onUpdated?.(updated);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
if (successTimer.current) clearTimeout(successTimer.current);
successTimer.current = setTimeout(() => setSuccess(false), 2000);
} catch (err) {
if ((err as { status?: number }).status === 409) {
// CAS 409 (PR #779 review P2-2): re-fetch latest and repopulate so the
// form does not sit on a stale updated_at and 409 again on retry.
if (err instanceof ApiError && err.status === 409) {
setError('Workspace was modified elsewhere — refreshed to latest, please retry.');
try {
onUpdated?.(await getWorkspace(workspace.id));
Expand Down
43 changes: 37 additions & 6 deletions webchat/app/components/chat/settings-modal/members-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,34 @@ import {
adminListInvitations,
adminCreateInvitation,
adminDeleteInvitation,
logout,
type User,
type Invitation,
} from '@/lib/api/auth';

const DEFAULT_INVITE_TTL = 7 * 24 * 3600; // 7 days, matches backend default

export function MembersTab() {
interface MembersTabProps {
currentUser: User | null;
}

export function MembersTab({ currentUser }: MembersTabProps) {
const [users, setUsers] = useState<User[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
// busyUserId !== null disables every toggle button globally (PR #779 review
// P3-5): a second concurrent PATCH on a different user would mutate against a
// stale list snapshot. Serializing writes one-at-a-time is the simple fix.
const [busyUserId, setBusyUserId] = useState<string | null>(null);

// Flash timer ref so unmount clears the pending setState (PR #779 review P3-8).
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (kind: 'ok' | 'err', text: string) => {
setActionMsg({ kind, text });
setTimeout(() => setActionMsg(null), 2500);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setActionMsg(null), 2500);
};

const abortRef = useRef<AbortController | null>(null);
Expand Down Expand Up @@ -55,14 +66,32 @@ export function MembersTab() {

useEffect(() => {
load();
return () => abortRef.current?.abort();
return () => {
abortRef.current?.abort();
if (flashTimer.current) clearTimeout(flashTimer.current);
};
}, [load]);

const handleToggleUser = async (user: User) => {
// Self-lock guard (PR #779 review P3-3): disabling your own account logs
// you out and blocks sign-in. Confirm before the destructive action.
if (currentUser?.id === user.id && user.status === 'active') {
if (!window.confirm('Disable your own account? You will be logged out immediately and unable to sign back in.')) {
return;
}
}
const next = user.status === 'active' ? 'disabled' : 'active';
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) {
Expand Down Expand Up @@ -114,12 +143,12 @@ export function MembersTab() {
{users.map((u) => (
<div key={u.id} className="flex items-center justify-between py-2 px-3 rounded-[var(--radius-md)] bg-[var(--bg-elevated)] border border-[var(--border-subtle)] gap-3">
<div className="min-w-0">
<div className="text-sm font-bold text-[var(--text-primary)] truncate">{u.username}{u.display_name ? ` · ${u.display_name}` : ''}</div>
<div className="text-sm font-bold text-[var(--text-primary)] truncate">{u.username}{u.display_name ? ` · ${u.display_name}` : ''}{currentUser?.id === u.id ? ' (you)' : ''}</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono">{u.role} · {u.status}</div>
</div>
<button
onClick={() => handleToggleUser(u)}
disabled={busyUserId === u.id}
disabled={busyUserId !== null}
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed ${u.status === 'active' ? 'bg-[var(--accent-coral)]/10 text-[var(--accent-coral)] hover:bg-[var(--accent-coral)]/20' : 'bg-[var(--accent-emerald)]/10 text-[var(--accent-emerald)] hover:bg-[var(--accent-emerald)]/20'}`}
>
{busyUserId === u.id ? '…' : u.status === 'active' ? 'Disable' : 'Enable'}
Expand All @@ -144,7 +173,9 @@ export function MembersTab() {
<div className="space-y-1">
{invitations.map((inv) => {
const used = !!inv.used_at;
const expired = !used && inv.expires_at * 1000 < Date.now();
// Prefer server-computed is_expired (clock-source-of-truth, PR #779
// review P3-5); fall back to client calc for older backends.
const expired = !used && (inv.is_expired ?? inv.expires_at * 1000 < Date.now());
const state = used ? 'used' : expired ? 'expired' : 'active';
return (
<div key={inv.id} className="flex items-center justify-between py-2 px-3 rounded-[var(--radius-md)] bg-[var(--bg-elevated)] border border-[var(--border-subtle)] gap-3">
Expand Down
Loading