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
+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));