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:
+3
-2
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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