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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user