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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user