fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts

- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
This commit is contained in:
2026-10-05 17:01:05 +07:00
parent 442f133930
commit 198fbb7980
78 changed files with 2716 additions and 873 deletions
+3 -2
View File
@@ -54,7 +54,7 @@
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate, useParams, useLocation } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "./components/Layout";
@@ -92,8 +92,9 @@ function PageFallback() {
}
function Page({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
return (
<RouteErrorBoundary>
<RouteErrorBoundary resetKey={pathname}>
<Suspense fallback={<PageFallback />}>{children}</Suspense>
</RouteErrorBoundary>
);
+9 -1
View File
@@ -5,7 +5,9 @@
* which the SPA fallback answers with index.html, so the dynamic import
* rejects: that case reloads the page once (a sessionStorage guard stops a
* reload loop). Any other render error — or a second chunk failure — shows a
* themed "Something went wrong — Reload" panel instead of a blank app.
* themed "Something went wrong — Reload" panel instead of a blank app, until the
* route changes (`resetKey`): the lazy routes share one boundary instance, so
* an error on one page would otherwise stay on every other.
* `reloadOnce` is also used by App's `vite:preloadError` listener.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -45,6 +47,8 @@ interface Props {
children: ReactNode;
/** Injected in tests; defaults to `window.location.reload`. */
reload?: () => void;
/** A change (the route's path) clears a caught error. */
resetKey?: string;
}
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
@@ -58,6 +62,10 @@ export class RouteErrorBoundary extends Component<Props, { error: Error | null }
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
}
render() {
if (!this.state.error) return this.props.children;
const reload = this.props.reload ?? (() => window.location.reload());
@@ -49,6 +49,28 @@ describe("RouteErrorBoundary", () => {
expect(reload).not.toHaveBeenCalled();
});
it("clears the error when the route changes, so the next page renders", () => {
const { rerender } = render(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<Boom error={new Error("boom")} />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/settings/general">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("settings page")).toBeInTheDocument();
});
it("reloads on a chunk error the first time", () => {
render(
<RouteErrorBoundary reload={reload}>
@@ -154,12 +154,32 @@ describe("WorkspaceTree", () => {
expect(within(list).getByText("Live one")).toBeInTheDocument();
// Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 20 });
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true,
});
});
it("shows six sessions and loads the rest on See all", async () => {
const all = Array.from({ length: 9 }, (_, i) => session(`s-${i}`, `Session ${i}`));
vi.mocked(api.sessions.list).mockImplementation(async (p) => ({
sessions: all.slice(0, p?.limit),
total: all.length,
limit: p?.limit ?? 50,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
await user.click(screen.getByTestId("workspace-sessions-more-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
const user = userEvent.setup();
renderTree();
+3 -2
View File
@@ -751,7 +751,9 @@ export function LaneDiffView({
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => {
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
if (r) setAnchor({ top: r.bottom + 2, left: r.left });
// A long diff unmounts rows scrolled out of its window, the clicked number
// with them: the box docks under the diff rather than float over other lines.
setAnchor(r ? { top: r.bottom + 2, left: r.left } : null);
}, []);
useEffect(() => {
if (commentAt == null) return;
@@ -764,7 +766,6 @@ export function LaneDiffView({
? {
open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null;
setAnchor(null);
place();
setCommentAt(line);
setUndelivered(false);
@@ -17,7 +17,8 @@
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **Show N more** under them. A
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text.
@@ -39,7 +40,7 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 20;
export const SESSION_LIMIT = 6;
/**
* The live pane holding a session: the run the hooks bound it to
@@ -244,7 +245,7 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Rows shown; Show more adds a page.
// Rows shown; See all raises it to the whole list.
const [limit, setLimit] = useState(SESSION_LIMIT);
// This workspace's live AI tabs. A tab started bare (no prompt) has its
@@ -525,10 +526,10 @@ export function WorkspaceSessionList({
<button
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => setLimit((n) => n + SESSION_LIMIT)}
onClick={() => own && setLimit(own.total)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })}
{t("tree.seeAll", { count: more })}
</button>
)}
{failure}
@@ -528,6 +528,17 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
});
it("docks the box under the diff once its line scrolls out of a windowed diff", () => {
render(<LaneDiffView file={FILE} layout="unified" onComment={() => true} />);
const btn = screen.getByTestId("diff-comment-line-1");
fireEvent.click(btn);
expect(screen.getByTestId("diff-comment-box").style.position).toBe("fixed");
// What windowing does to a row scrolled out of range.
btn.remove();
fireEvent.scroll(screen.getByTestId("lane-diff-view"));
expect(screen.getByTestId("diff-comment-box").style.position).toBe("");
});
it("offers no comment button without onComment", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
+7 -2
View File
@@ -108,6 +108,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
// The current attach: a paste into a socket that is not open would be dropped.
const wsRef = useRef<WebSocket | null>(null);
const { t } = useTranslation("run");
const tRef = useRef(t);
tRef.current = t;
@@ -319,6 +321,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync.
@@ -432,8 +435,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
termRef.current?.paste(text);
termRef.current?.focus();
},
// On screen: a console hidden with CSS measures 0×0.
() => !!containerRef.current?.offsetWidth
// On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting,
// so a caller waits or says the paste failed instead.
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
),
[runId]
);
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
import { pasteToRun } from "../../../lib/panePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -288,6 +289,24 @@ describe("TerminalView", () => {
expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting");
});
it("takes no paste while its socket is not open, so the text is not dropped unseen", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
try {
render(<TerminalView runId="agenthub-lane-7" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.readyState = 0; // CONNECTING
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false);
ws.readyState = 1;
act(() => ws.onopen?.());
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(true);
ws.readyState = 3; // CLOSED: Reconnecting…
act(() => ws.onclose?.());
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false);
} finally {
width.mockRestore();
}
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
+1
View File
@@ -427,6 +427,7 @@
"tree.state.idle": "ended",
"tree.showMore_one": "Show {{count}} more",
"tree.showMore_other": "Show {{count}} more",
"tree.seeAll": "See all ({{count}} more)",
"home.contextPanel": "Workspace context",
"row.state.needsYou": "needs you",
"row.state.error": "error",
+1
View File
@@ -56,6 +56,7 @@
},
"exportCsv": "CSV",
"exportJson": "JSON",
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
"filterDirectory": "Filter by directory",
"allDirectories": "All directories",
"sortLabel": "Sort sessions",
+1
View File
@@ -427,6 +427,7 @@
"tree.state.idle": "đã kết thúc",
"tree.showMore_one": "Hiện thêm {{count}}",
"tree.showMore_other": "Hiện thêm {{count}}",
"tree.seeAll": "Xem tất cả (thêm {{count}})",
"home.contextPanel": "Ngữ cảnh workspace",
"row.state.needsYou": "cần bạn",
"row.state.error": "lỗi",
+1
View File
@@ -55,6 +55,7 @@
},
"exportCsv": "CSV",
"exportJson": "JSON",
"exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.",
"filterDirectory": "Lọc theo thư mục",
"allDirectories": "Tất cả thư mục",
"sortLabel": "Sắp xếp phiên",
@@ -0,0 +1,29 @@
/**
* @file runWorktree.test.ts
* @description The Changes panel's worktree session for a `?run=` pane survives
* a poll that misses the run, and follows every real answer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { runWorktreeSession } from "../runWorktree";
const wt = { worktree: true, sessionId: "s1" };
describe("runWorktreeSession", () => {
it("follows the listed run's worktree session", () => {
expect(runWorktreeSession(null, "r1", wt)).toEqual({ run: "r1", session: "s1" });
expect(runWorktreeSession(null, "r1", { worktree: false, sessionId: "s1" })).toBeNull();
});
it("keeps the last answer through a poll that misses the run", () => {
const prev = runWorktreeSession(null, "r1", wt);
expect(runWorktreeSession(prev, "r1", undefined)).toEqual({ run: "r1", session: "s1" });
});
it("drops it for another run or no run", () => {
const prev = runWorktreeSession(null, "r1", wt);
expect(runWorktreeSession(prev, "r2", undefined)).toBeNull();
expect(runWorktreeSession(prev, null, undefined)).toBeNull();
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @file toast.test.ts
* @description The toast queue: at most four on screen, and an error — which
* stays until dismissed — is never the one pushed out by newer toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, afterEach } from "vitest";
import { toast, subscribeToasts, dismissToast, type ToastItem } from "../toast";
let current: ToastItem[] = [];
const unsubscribe = subscribeToasts((items) => (current = items));
afterEach(() => {
for (const t of current) dismissToast(t.id);
});
describe("toast queue", () => {
it("keeps an unread error when newer toasts overflow the queue", () => {
toast.error("save failed");
for (let i = 0; i < 5; i++) toast.success(`copied ${i}`);
expect(current).toHaveLength(4);
expect(current.map((t) => t.message)).toContain("save failed");
expect(current[current.length - 1]?.message).toBe("copied 4");
});
it("still shows the newest toast when only errors are queued", () => {
for (let i = 0; i < 4; i++) toast.error(`error ${i}`);
toast.info("hello");
expect(current).toHaveLength(4);
expect(current[current.length - 1]?.message).toBe("hello");
});
});
void unsubscribe;
+14 -3
View File
@@ -1255,10 +1255,21 @@ export const api = {
* "all" (which means "no filter"), keeping the default URL param-free.
*
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel.
* @param sections Optional subset of the bundle to compute (`?sections=`);
* the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
* or just the named sections of it.
*/
get: (status?: string) =>
request<WorkflowData>(`/workflows${status && status !== "all" ? `?status=${status}` : ""}`),
get: <K extends keyof WorkflowData = keyof WorkflowData>(
status?: string,
sections?: readonly K[]
) => {
const qs = new URLSearchParams();
if (status && status !== "all") qs.set("status", status);
if (sections?.length) qs.set("sections", sections.join(","));
const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
},
/**
* GET /api/workflows/session/:id - single-session drill-in (agent tree,
* tool timeline, swim lanes).
+29
View File
@@ -0,0 +1,29 @@
/**
* @file runWorktree.ts
* @description Which `claude --worktree` session the Workspace's Changes panel
* follows for the URL's `?run=` pane. A poll that misses the run (one bad read
* lists no runs) keeps the last answer instead of moving the panel to the main
* folder — which, while it holds an unsaved edit, asked to discard it out of
* nowhere.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface RunWorktree {
run: string;
session: string;
}
/**
* @param prev The previous answer.
* @param runParam The URL's `?run=`.
* @param opened That run in the latest list, if the list had it.
*/
export function runWorktreeSession(
prev: RunWorktree | null,
runParam: string | null,
opened: { worktree?: boolean; sessionId: string | null } | undefined
): RunWorktree | null {
if (!runParam) return null;
if (!opened) return prev?.run === runParam ? prev : null;
return opened.worktree && opened.sessionId ? { run: runParam, session: opened.sessionId } : null;
}
+9 -1
View File
@@ -16,6 +16,7 @@ export interface ToastItem {
}
const SUCCESS_MS = 4000;
const MAX_TOASTS = 4;
let items: ToastItem[] = [];
let nextId = 1;
const listeners = new Set<(items: ToastItem[]) => void>();
@@ -33,7 +34,14 @@ export function dismissToast(id: number): void {
function push(kind: ToastKind, message: string): number {
const id = nextId++;
// Same text twice in a row (a double click, a retry) shows once.
items = [...items.filter((t) => t.message !== message), { id, kind, message }].slice(-4);
const next = [...items.filter((t) => t.message !== message), { id, kind, message }];
// Over four: the oldest non-error goes first, so an unread error is not pushed
// out by a few copies or saves; the newest toast always stays.
while (next.length > MAX_TOASTS) {
const older = next.slice(0, -1).findIndex((t) => t.kind !== "error");
next.splice(older === -1 ? 0 : older, 1);
}
items = next;
emit();
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
return id;
+18 -7
View File
@@ -113,6 +113,18 @@ const PAGE_SIZE = 50;
// stream still refreshes, which a trailing debounce never did.
const REFRESH_THROTTLE_MS = 1000;
/** The agents on screen not asked about yet, and the sessions to ask for them. */
export function agentsToFetch(
events: { session_id: string; agent_id?: string | null }[],
asked: ReadonlySet<string>
): { agentIds: string[]; sessionIds: string[] } {
const fresh = events.filter((e) => e.agent_id && !asked.has(e.agent_id));
return {
agentIds: [...new Set(fresh.map((e) => e.agent_id as string))],
sessionIds: [...new Set(fresh.map((e) => e.session_id))],
};
}
export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
const eventCtx = useContextMenu<DashboardEvent>();
const { t } = useTranslation("activity");
@@ -158,7 +170,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
// agent_id → subagent-facing info for the agents of the sessions on screen,
// fetched in one call per new set of sessions (never every agent).
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
const knownAgentSessions = useRef(new Set<string>());
const askedAgents = useRef(new Set<string>());
const pausedRef = useRef(paused);
pausedRef.current = paused;
@@ -223,13 +235,12 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
load();
}, [load]);
// Agent labels for the sessions on screen that we have not seen yet.
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
useEffect(() => {
const missing = [...new Set(events.map((e) => e.session_id))].filter(
(sid) => !knownAgentSessions.current.has(sid)
);
const { agentIds, sessionIds: missing } = agentsToFetch(events, askedAgents.current);
if (missing.length === 0) return;
for (const sid of missing) knownAgentSessions.current.add(sid);
for (const id of agentIds) askedAgents.current.add(id);
api.agents
.list({ session_ids: missing, limit: 5000 })
.then(({ agents }) =>
@@ -247,7 +258,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
)
.catch(() => {
// Non-fatal: subagent pills fall back to the short-id label.
for (const sid of missing) knownAgentSessions.current.delete(sid);
for (const id of agentIds) askedAgents.current.delete(id);
});
}, [events]);
+26 -9
View File
@@ -388,7 +388,7 @@ export function CcConfig() {
// and Back land on it.
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab");
const tab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
const urlTab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
// Editors inside a tab report unsaved edits here (by id), so leaving the tab
// asks first instead of silently dropping the draft.
const dirtyIds = useRef(new Set<string>());
@@ -396,11 +396,8 @@ export function CcConfig() {
if (d) dirtyIds.current.add(id);
else dirtyIds.current.delete(id);
}, []);
const setTab = useCallback(
async (k: TabKey) => {
if (k === tab) return;
if (dirtyIds.current.size > 0 && !(await confirmDiscard())) return;
dirtyIds.current.clear();
const writeTab = useCallback(
(k: TabKey) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
@@ -409,10 +406,30 @@ export function CcConfig() {
return next;
},
{ replace: true }
);
},
[tab, setParams]
),
[setParams]
);
// The tab shown follows the URL, but only once an unsaved edit is let go:
// the URL also changes from outside the tab bar (a settings search result,
// Back), and those must ask too. Kept, the URL goes back to the shown tab.
const [tab, setShownTab] = useState<TabKey>(urlTab);
useEffect(() => {
if (urlTab === tab) return;
let live = true;
void (async () => {
if (dirtyIds.current.size > 0 && !(await confirmDiscard())) {
if (live) writeTab(tab);
return;
}
if (!live) return;
dirtyIds.current.clear();
setShownTab(urlTab);
})();
return () => {
live = false;
};
}, [urlTab, tab, writeTab]);
const setTab = useCallback((k: TabKey) => writeTab(k), [writeTab]);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all");
+17 -2
View File
@@ -1583,15 +1583,30 @@ function AnalyticsTab() {
);
}
// The workflow sections the health cards read; the server computes only these.
const HEALTH_SECTIONS = [
"stats",
"toolFlow",
"effectiveness",
"modelDelegation",
"errorPropagation",
"concurrency",
"compaction",
] as const satisfies readonly (keyof WorkflowData)[];
type HealthWorkflow = Pick<WorkflowData, (typeof HEALTH_SECTIONS)[number]>;
function SystemHealthTab() {
const { t } = useTranslation("dashboard");
const [info, setInfo] = useState<SystemInfo | null>(null);
const [workflow, setWorkflow] = useState<WorkflowData | null>(null);
const [workflow, setWorkflow] = useState<HealthWorkflow | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const loadData = useCallback(async () => {
try {
const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]);
const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
setLoadError(null);
+3
View File
@@ -304,6 +304,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
]),
"text/csv"
);
// The server answers at most 10 000 rows: say the file is not all of them.
if (res.total > res.sessions.length)
toast.info(t("exportCapped", { shown: res.sessions.length, total: res.total }));
} catch (err) {
toast.error(err);
} finally {
+15 -2
View File
@@ -96,6 +96,14 @@ const historyIdx = (): number => {
return typeof idx === "number" ? idx : 0;
};
/**
* How far Back moves: to the entry before the one Settings was entered at, and
* always at least one step — after a browser Back below that entry, the plain
* difference would be 0 (`history.go(0)`, a reload) or a step forward.
*/
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** An unknown section: say so, then show General. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
@@ -142,7 +150,12 @@ export function SettingsHub() {
// Deep link to a row: scroll to the anchor once the page has rendered it
// (content loads async, so poll briefly) and highlight it for a moment.
useEffect(() => {
const id = decodeURIComponent(location.hash.slice(1));
let id = location.hash.slice(1);
try {
id = decodeURIComponent(id);
} catch {
// A malformed escape (`#%`): look the raw text up instead of crashing.
}
if (!id) return;
let timer: number | undefined;
let lit: Element | null = null;
@@ -363,7 +376,7 @@ export function SettingsHub() {
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
navigate(-(historyIdx() - (entryIdx.current ?? 0) + 1));
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
};
return (
+8 -6
View File
@@ -49,6 +49,7 @@
*
* ----------------------------------------------------------------------------- */
import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -687,12 +688,13 @@ export function Workspace() {
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
}, [sessionParam, replayLane?.id]);
const gitSession =
openedRun?.worktree && openedRun.sessionId
? openedRun.sessionId
: sessionParam && replayed?.id === sessionParam && replayed.worktree
? sessionParam
: undefined;
const runWorktree = useRef<RunWorktree | null>(null);
runWorktree.current = runWorktreeSession(runWorktree.current, runParam, openedRun);
const gitSession = runWorktree.current
? runWorktree.current.session
: sessionParam && replayed?.id === sessionParam && replayed.worktree
? sessionParam
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
@@ -0,0 +1,32 @@
/**
* @file ActivityFeed.agents.test.ts
* @description The Activity feed asks for agent labels by agent, not by
* session: a subagent spawned later in a session already on screen is fetched
* too, and an agent already asked about is not asked for again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { agentsToFetch } from "../ActivityFeed";
describe("agentsToFetch", () => {
it("asks again for a session once a new agent of it appears", () => {
const asked = new Set(["s1-main"]);
const events = [
{ session_id: "s1", agent_id: "s1-main" },
{ session_id: "s1", agent_id: "s1-sub-late" },
{ session_id: "s2", agent_id: null },
];
expect(agentsToFetch(events, asked)).toEqual({
agentIds: ["s1-sub-late"],
sessionIds: ["s1"],
});
});
it("asks nothing when every agent on screen is known", () => {
expect(agentsToFetch([{ session_id: "s1", agent_id: "a" }], new Set(["a"]))).toEqual({
agentIds: [],
sessionIds: [],
});
});
});
@@ -10,7 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { eventBus } from "../../lib/eventBus";
@@ -119,6 +119,40 @@ describe("CcConfig — settings editor", () => {
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] });
});
it("a tab change from the URL (a search result) asks before dropping an unsaved edit", async () => {
const user = userEvent.setup();
function Jump() {
const navigate = useNavigate();
const loc = useLocation();
return (
<>
<button onClick={() => navigate("/settings/claude?tab=skills")}>jump</button>
<span data-testid="url">{loc.search}</span>
</>
);
}
render(
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
<CcConfig />
<Jump />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getAllByRole("button", { name: /remove key/i })[0]!);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(screen.getByRole("button", { name: "jump" }));
await settle();
expect(pending).not.toBeNull();
// Keep editing: the draft stays and the URL goes back to its tab.
await act(async () => settleConfirm(false));
await settle();
off();
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
expect(screen.getByTestId("url")).toHaveTextContent("?tab=settings");
});
it("a renamed key is sent under its new name, not dropped", async () => {
const user = userEvent.setup();
render(
@@ -58,7 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
};
});
import { SettingsHub } from "../SettingsHub";
import { SettingsHub, backDelta } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
@@ -243,6 +243,21 @@ describe("SettingsHub", () => {
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("a malformed #anchor is ignored instead of crashing the page", async () => {
renderAt("/settings/data#%E0%A4");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
});
it("Back always goes back: one step past the entry, at least one step", () => {
// Entered at index 2, now two pages further on: back to index 1.
expect(backDelta(4, 2)).toBe(-3);
expect(backDelta(2, 2)).toBe(-1);
// Browser Back moved below the entry with the hub still mounted: one step,
// never history.go(0) (a reload) or a step forward.
expect(backDelta(1, 2)).toBe(-1);
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));