-
- Needs attention
-
-
- {visible.map((item) => (
-
-
{item.title}
- {item.summary ? (
-
{item.summary}
- ) : null}
- {item.links.length > 0 ? (
-
- {item.links.map((link, index) => (
-
+
+ {parts.map((part) => {
+ const links = part.links.slice(0, maxLinks);
+ const overflowLabel = overflowPartLabel(
+ (part.linkTotal ?? part.links.length) - links.length,
+ part.overflowNoun,
+ );
+ return (
+
+
+ {part.label}{" "}
+ {part.status}
+ {part.summary ? · {part.summary} : null}
+
+ {links.length > 0 || overflowLabel ? (
+
+ {links.map((link, index) => (
+
))}
- {item.overflowLabel ? {item.overflowLabel} : null}
+ {overflowLabel ? {overflowLabel} : null}
) : null}
- ))}
- {extra > 0 ?
+{extra} more
: null}
-
+ );
+ })}
);
}
-function AttentionLink({ interactive, link }: { interactive: boolean; link: PRAttentionLink }) {
+function overflowPartLabel(extra: number, noun?: string): string | undefined {
+ if (extra <= 0) {
+ return undefined;
+ }
+ return noun ? `+${extra} ${pluralize(noun, extra)}` : `+${extra}`;
+}
+
+function SummaryLink({ interactive, link }: { interactive: boolean; link: PRSummaryLink }) {
if (interactive && link.href) {
return (
{
+ void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId });
// PUT replaces the whole config; merge the edited fields over what loaded
// so we don't drop env/symlinks/postCreate the form doesn't expose.
const next: ProjectConfig = {
@@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
) {
try {
- await spawnOrchestrator(projectId, true);
+ await spawnOrchestrator(projectId, "settings", true);
} catch (error) {
return {
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
@@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
return { replacementError: null };
},
onSuccess: (result) => {
+ void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId });
setSavedAt(Date.now());
setReplacementError(result.replacementError);
setValidationError(null);
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved();
},
+ onError: () => {
+ void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId });
+ },
});
return (
diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx
index 8bc76ab49..062c11b19 100644
--- a/frontend/src/renderer/components/PullRequestsPage.tsx
+++ b/frontend/src/renderer/components/PullRequestsPage.tsx
@@ -13,7 +13,7 @@ import type { WorkspaceSession } from "../types/workspace";
import { DashboardSubhead } from "./DashboardSubhead";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
-import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
+import { PRSummaryParts } from "./PRSummaryDisplay";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import { cn } from "../lib/utils";
@@ -145,8 +145,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
.filter(Boolean)
.join(" · ")}
-
-
+
diff --git a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
index d5f99d338..f568cc01d 100644
--- a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
+++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
@@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
setBusy(true);
setError(undefined);
try {
- const id = await spawnOrchestrator(session.workspaceId, true);
+ const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true);
onOpenChange(false);
onRecreated(id);
} catch (err) {
diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx
index b3a64b397..b849ea046 100644
--- a/frontend/src/renderer/components/SessionInspector.tsx
+++ b/frontend/src/renderer/components/SessionInspector.tsx
@@ -6,7 +6,7 @@ import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { formatTimeCompact } from "../lib/format-time";
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
-import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
+import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display";
import type { SessionActivityState, WorkspaceSession } from "../types/workspace";
import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace";
import { BrowserPanelView } from "./BrowserPanel";
@@ -14,7 +14,7 @@ import type { BrowserViewModel } from "../hooks/useBrowserView";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
-import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay";
+import { PRSummaryMeta, PRSummaryParts } from "./PRSummaryDisplay";
type ProjectConfig = components["schemas"]["ProjectConfig"];
type PRReviewState = components["schemas"]["PRReviewState"];
@@ -127,7 +127,15 @@ export function SessionInspector({
))}
-
+
{view === "summary" ? : null}
{view === "reviews" ? : null}
{view === "browser" ? (
@@ -225,40 +233,11 @@ function PRSummaryCard({ pr }: { pr: SessionPRSummary }) {
{pr.title ?
{pr.title}
: null}
-
-
+
);
}
-function PRStatusStack({ className, pr }: { className?: string; pr: SessionPRSummary }) {
- return (
-
- {prStatusRows(pr).map((row) => (
-
- {row.label}{" "}
- {row.value}
-
- ))}
-
- );
-}
-
-function inspectorStatusToneClass(tone: PRDisplayTone): string {
- switch (tone) {
- case "success":
- return "text-success";
- case "warning":
- return "text-warning";
- case "error":
- return "text-error";
- case "neutral":
- return "text-muted-foreground";
- case "passive":
- return "text-passive";
- }
-}
-
type TimelineTone = "now" | "good" | "warn" | "neutral";
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx
index 8a5cb42c0..0103a5c30 100644
--- a/frontend/src/renderer/components/SessionsBoard.tsx
+++ b/frontend/src/renderer/components/SessionsBoard.tsx
@@ -18,9 +18,8 @@ import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
-import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
+import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display";
import { cn } from "../lib/utils";
-import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
import { useUiStore } from "../stores/ui-store";
type SessionsBoardProps = {
@@ -118,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
}
setIsSpawning(true);
try {
- const sessionId = await spawnOrchestrator(projectId);
+ const sessionId = await spawnOrchestrator(projectId, "board");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
@@ -320,73 +319,103 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
onOpen();
};
return (
-
-
-
-
- {badge.label}
-
- {issueId && (
-
- {issueId}
+
+
+
+
+
+ {badge.label}
- )}
-
- {agentLabel(session.provider)}
-
+ {issueId && (
+
+ {issueId}
+
+ )}
+
+ {agentLabel(session.provider)}
+
+
+
+ {session.title}
+
+ {showBranch &&
{branch}
}
event.stopPropagation()}
>
- {session.title}
-
- {showBranch &&
{branch}
}
-
- {prSummaries.length > 0 ? (
-
- {prSummaries.map((prSummary, index) => (
-
0 && "border-t border-border pt-2")}
- key={prSummary.number}
- pr={prSummary}
- />
+ {prSummaries.length === 0 ? (
+ "no PR yet"
+ ) : (
+
+ {groupPRsByLifecycle(prSummaries).map((group) => (
+
))}
- ) : (
- "no PR yet"
)}
);
}
-function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSummary }) {
- const diffSummary = prDiffSummary(pr);
+type BoardPRLifecycleStatus = { label: "closed" | "open" | "draft" | "merged"; className: string };
+type BoardPRGroup = { status: BoardPRLifecycleStatus; prs: SessionPRSummary[] };
+
+function BoardPRGroup({ group }: { group: BoardPRGroup }) {
return (
-
-
- PR #{pr.number} · {pr.state}
-
- {diffSummary ?
{diffSummary} : null}
-
-
-
+
`#${pr.number}`).join(", ")} ${group.status.label}`}
+ className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1"
+ >
+ PR
+ {group.prs.map((pr, index) => (
+
+
+ #{pr.number}
+
+ {index < group.prs.length - 1 ? "," : null}
+
+ ))}
+ {group.status.label}
+
);
}
+function groupPRsByLifecycle(prs: SessionPRSummary[]): BoardPRGroup[] {
+ const groups = new Map
();
+ for (const pr of prs) {
+ const status = prLifecycleStatus(pr);
+ const group = groups.get(status.label);
+ if (group) {
+ group.prs.push(pr);
+ } else {
+ groups.set(status.label, { status, prs: [pr] });
+ }
+ }
+ return Array.from(groups.values());
+}
+
+function prLifecycleStatus(pr: SessionPRSummary): BoardPRLifecycleStatus {
+ if (pr.state === "draft") return { label: "draft", className: "text-passive" };
+ if (pr.state === "merged") return { label: "merged", className: "text-accent" };
+ if (pr.state === "closed") return { label: "closed", className: "text-error" };
+ return { label: "open", className: "text-success" };
+}
+
function sameLabel(a: string, b: string): boolean {
const normalize = (value: string) =>
value
diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx
index 2929475c7..892fcabbd 100644
--- a/frontend/src/renderer/components/ShellTopbar.tsx
+++ b/frontend/src/renderer/components/ShellTopbar.tsx
@@ -112,7 +112,7 @@ export function ShellTopbar() {
}
setIsSpawning(true);
try {
- const sessionId = await spawnOrchestrator(projectId);
+ const sessionId = await spawnOrchestrator(projectId, "topbar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
@@ -252,16 +252,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
const kill = useMutation({
mutationFn: async () => {
+ void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId });
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: session.id } },
});
if (apiError) throw new Error(apiErrorMessage(apiError));
},
onSuccess: () => {
+ void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId });
setConfirming(false);
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
},
- onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
+ onError: (e) => {
+ void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId });
+ setError(e instanceof Error ? e.message : "Kill failed");
+ },
});
if (confirming) {
diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx
index d405fa7c3..89c480fe9 100644
--- a/frontend/src/renderer/components/Sidebar.tsx
+++ b/frontend/src/renderer/components/Sidebar.tsx
@@ -445,7 +445,7 @@ function ProjectItem({
}
setIsSpawning(true);
try {
- const sessionId = await spawnOrchestrator(workspace.id);
+ const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
selection.goSession(workspace.id, sessionId);
} catch (err) {
@@ -754,7 +754,7 @@ function CreateProjectListItem({ onCreateProject }: Pick
- {label}
+ {label}
)}
diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx
index 3e237c2f9..79367e1a3 100644
--- a/frontend/src/renderer/components/TerminalPane.tsx
+++ b/frontend/src/renderer/components/TerminalPane.tsx
@@ -117,6 +117,11 @@ function reviewerPreviewLines(session: WorkspaceSession | undefined): string[] {
];
}
+// Agents whose full-screen TUI keeps its own transcript and scrolls it only by
+// keyboard, ignoring SGR wheel reports. The terminal routes the wheel to
+// PageUp/PageDown for these (see XtermTerminal's paneScrollsByKeyboard).
+const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode"]);
+
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
if (state === "reattaching") return "Terminal disconnected — reattaching…";
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
@@ -139,6 +144,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
const queryClient = useQueryClient();
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = attachSession?.terminalHandleId;
+ const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : session?.provider;
const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
@@ -219,6 +225,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
fontSize={fontSize}
onError={handleInitError}
onReady={handleReady}
+ paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false}
theme={theme}
/>
{showEmptyState && (
diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx
index 59892b552..affdc8f10 100644
--- a/frontend/src/renderer/components/XtermTerminal.test.tsx
+++ b/frontend/src/renderer/components/XtermTerminal.test.tsx
@@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({
wheelHandler?: (event: WheelEvent) => boolean;
selection: string;
options: Record;
- modes: { bracketedPasteMode: boolean };
+ modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>;
@@ -31,7 +31,7 @@ vi.mock("@xterm/xterm", () => ({
selection = "";
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
- modes = { bracketedPasteMode: false };
+ modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
dataListeners = new Set<(data: string) => void>();
keyListeners = new Set<(event: { key: string }) => void>();
selectionListeners = new Set<() => void>();
@@ -492,6 +492,46 @@ describe("XtermTerminal", () => {
expect(onInput).not.toHaveBeenCalled();
});
+ it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => {
+ const onInput = vi.fn();
+ render( terminal.onUserInput(onInput)} />);
+ state.lastTerminal!.modes.mouseTrackingMode = "none";
+
+ // A keyboard-scroll TUI: one page key per notch regardless of line count,
+ // so 3 lines up => a single PageUp.
+ expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
+ expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
+
+ expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
+ expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
+ });
+
+ it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => {
+ setNavigatorPlatform("Win32");
+ const onInput = vi.fn();
+ render( terminal.onUserInput(onInput)} />);
+ // opencode enables full mouse tracking but scrolls its transcript only by
+ // keyboard; with no mux to consume SGR reports, Windows must use page keys.
+ state.lastTerminal!.modes.mouseTrackingMode = "any";
+
+ expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
+ expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
+
+ expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
+ expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
+ });
+
+ it("sends PageUp/PageDown for keyboard-scroll panes even under a mux (opencode on macOS/Linux)", () => {
+ const onInput = vi.fn();
+ render( terminal.onUserInput(onInput)} />);
+ // Linux (beforeEach) + mouse tracking on: without the paneScrollsByKeyboard
+ // hint this would send SGR reports; the hint forces page keys.
+ state.lastTerminal!.modes.mouseTrackingMode = "any";
+
+ expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
+ expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
+ });
+
it("opens terminal links via window.open so Electron routes them to the OS browser", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
render();
diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx
index 48de2c8df..102a642b6 100644
--- a/frontend/src/renderer/components/XtermTerminal.tsx
+++ b/frontend/src/renderer/components/XtermTerminal.tsx
@@ -37,6 +37,13 @@ export type XtermTerminalProps = {
className?: string;
fontSize?: number;
theme: Theme;
+ /**
+ * The pane app scrolls its transcript by keyboard (PageUp/PageDown) rather
+ * than acting on SGR wheel reports — e.g. opencode, which enables mouse
+ * tracking but never scrolls on wheel reports. Routes the wheel to page keys
+ * on every platform (see the wheel handler), fixing it under a mux too.
+ */
+ paneScrollsByKeyboard?: boolean;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
/**
@@ -176,6 +183,16 @@ function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count);
}
+// PageUp (CSI 5~) / PageDown (CSI 6~) for pane apps that scroll their transcript
+// by keyboard rather than mouse reports. One page key per wheel notch: a page
+// already scrolls a full screen, so scaling by line count would over-scroll.
+const PAGE_UP = "\x1b[5~";
+const PAGE_DOWN = "\x1b[6~";
+
+function pageKeyReport(lines: number): string {
+ return lines < 0 ? PAGE_UP : PAGE_DOWN;
+}
+
function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService;
@@ -486,6 +503,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
wheelAccumPx -= lines * rowHeight;
}
if (lines === 0) return false;
+ // The SGR wheel path exists to drive tmux/zellij copy-mode on
+ // macOS/Linux. It cannot scroll a full-screen TUI that keeps its own
+ // transcript and only scrolls on PageUp/PageDown (opencode): the report
+ // is either consumed by the mux or handed to an app that ignores it.
+ // Send page keys for such apps (paneScrollsByKeyboard), on Windows
+ // (conpty has no mux, so SGR reaches the app and is ignored), and for
+ // any pane app with mouse tracking fully off.
+ if (
+ callbacksRef.current.paneScrollsByKeyboard ||
+ isWindowsPlatform() ||
+ term.modes.mouseTrackingMode === "none"
+ ) {
+ emitUserInput(pageKeyReport(lines), "wheel");
+ return false;
+ }
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
return false;
diff --git a/frontend/src/renderer/hooks/useSessionScmSummary.ts b/frontend/src/renderer/hooks/useSessionScmSummary.ts
index 5b26eb8fd..cca1a6694 100644
--- a/frontend/src/renderer/hooks/useSessionScmSummary.ts
+++ b/frontend/src/renderer/hooks/useSessionScmSummary.ts
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { components } from "../../api/schema";
import { apiClient } from "../lib/api-client";
+import { mockSessionScmSummaries } from "../lib/mock-data";
export type SessionPRSummary = components["schemas"]["SessionPRSummary"];
@@ -20,8 +21,9 @@ export async function fetchSessionScmSummary(sessionId: string): Promise fetchSessionScmSummary(sessionId),
+ enabled: Boolean(sessionId),
+ queryFn: () =>
+ usePreviewData ? Promise.resolve(mockSessionScmSummaries[sessionId] ?? []) : fetchSessionScmSummary(sessionId),
retry: 1,
};
}
@@ -29,8 +31,9 @@ export function sessionScmSummaryQueryOptions(sessionId: string) {
export function useSessionScmSummary(sessionId?: string) {
return useQuery({
queryKey: sessionScmSummaryQueryKey(sessionId),
- enabled: Boolean(sessionId) && !usePreviewData,
- queryFn: () => fetchSessionScmSummary(sessionId!),
+ enabled: Boolean(sessionId),
+ queryFn: () =>
+ usePreviewData ? Promise.resolve(mockSessionScmSummaries[sessionId!] ?? []) : fetchSessionScmSummary(sessionId!),
retry: 1,
});
}
diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts
index 70365e350..0ecfcfc21 100644
--- a/frontend/src/renderer/hooks/useTerminalSession.ts
+++ b/frontend/src/renderer/hooks/useTerminalSession.ts
@@ -10,6 +10,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { getApiBaseUrl } from "../lib/api-client";
+import { captureRendererEvent } from "../lib/telemetry";
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
import type { WorkspaceSession } from "../types/workspace";
import { workspaceQueryKey } from "./useWorkspaceQuery";
@@ -213,6 +214,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
setError(message);
transition("error");
+ void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" });
invalidateWorkspaces();
}),
mux.onConnectionChange((connectionState) => {
@@ -268,6 +270,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
r.openTimer = setTimeout(() => {
if (!isCurrentAttachment(generation, handle, mux)) return;
r.openTimer = null;
+ // Only the first timeout of a reattach sequence is reported; the
+ // backoff loop retrying against a restarting daemon is not news.
+ if (r.attempts === 0) {
+ void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" });
+ }
transition("reattaching");
teardownMux();
scheduleReattach();
diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts
index df7898253..fe527981f 100644
--- a/frontend/src/renderer/lib/api-client.test.ts
+++ b/frontend/src/renderer/lib/api-client.test.ts
@@ -1,12 +1,20 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiClient,
apiErrorMessage,
getApiBaseUrl,
hasTrustedApiBaseUrl,
+ normalizeApiOperation,
setApiBaseUrl,
subscribeApiBaseUrl,
} from "./api-client";
+import { captureRendererEvent } from "./telemetry";
+
+vi.mock("./telemetry", () => ({
+ captureRendererEvent: vi.fn().mockResolvedValue(undefined),
+}));
+
+const captureMock = vi.mocked(captureRendererEvent);
describe("apiClient runtime base URL", () => {
afterEach(() => {
@@ -162,6 +170,123 @@ describe("subscribeApiBaseUrl", () => {
});
});
+describe("normalizeApiOperation", () => {
+ it("replaces identifier segments after resource collections", () => {
+ expect(normalizeApiOperation("get", "/api/v1/projects/my project id")).toBe("GET /api/v1/projects/:id");
+ expect(normalizeApiOperation("POST", "/api/v1/sessions/ao-42/kill")).toBe("POST /api/v1/sessions/:id/kill");
+ expect(normalizeApiOperation("PUT", "/api/v1/projects/p1/config")).toBe("PUT /api/v1/projects/:id/config");
+ });
+
+ it("leaves collection and non-resource paths untouched", () => {
+ expect(normalizeApiOperation("GET", "/api/v1/projects")).toBe("GET /api/v1/projects");
+ expect(normalizeApiOperation("POST", "/api/v1/orchestrators")).toBe("POST /api/v1/orchestrators");
+ });
+
+ it("keeps static child routes instead of treating them as ids", () => {
+ // These match an exact OpenAPI template, so the trailing segment must not
+ // be collapsed to :id (which would break aggregation and hide the route).
+ expect(normalizeApiOperation("POST", "/api/v1/notifications/read-all")).toBe("POST /api/v1/notifications/read-all");
+ expect(normalizeApiOperation("POST", "/api/v1/sessions/cleanup")).toBe("POST /api/v1/sessions/cleanup");
+ });
+
+ it("normalizes ids for resources a collection heuristic would miss", () => {
+ expect(normalizeApiOperation("GET", "/api/v1/orchestrators/orch-abc")).toBe("GET /api/v1/orchestrators/:id");
+ expect(normalizeApiOperation("POST", "/api/v1/prs/pr-1/merge")).toBe("POST /api/v1/prs/:id/merge");
+ });
+});
+
+describe("api error telemetry", () => {
+ // The dedupe window keys off Date.now(); jump the clock far past any
+ // earlier test's reports so each test starts with a clean window.
+ let clock = Date.UTC(2100, 0, 1);
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ clock += 10 * 60_000;
+ vi.setSystemTime(clock);
+ captureMock.mockClear();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ setApiBaseUrl("http://127.0.0.1:3001");
+ });
+
+ it("reports http_5xx with a normalized operation", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 }));
+ setApiBaseUrl("http://127.0.0.1:3037");
+
+ await apiClient.GET("/api/v1/projects");
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
+ operation: "GET /api/v1/projects",
+ error_category: "http_5xx",
+ status: 500,
+ });
+ });
+
+ it("reports http_4xx with ids stripped from the operation", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 404 }));
+ setApiBaseUrl("http://127.0.0.1:3037");
+
+ await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
+ params: { path: { sessionId: "ao-raw-id" } },
+ });
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
+ operation: "POST /api/v1/sessions/:id/kill",
+ error_category: "http_4xx",
+ status: 404,
+ });
+ });
+
+ it("reports network_error and rethrows", async () => {
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
+ setApiBaseUrl("http://127.0.0.1:3037");
+
+ await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Failed to fetch");
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
+ operation: "GET /api/v1/projects",
+ error_category: "network_error",
+ status: undefined,
+ });
+ });
+
+ it("does not report caller-initiated aborts", async () => {
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("Aborted", "AbortError"));
+ setApiBaseUrl("http://127.0.0.1:3037");
+
+ await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Aborted");
+
+ expect(captureMock).not.toHaveBeenCalled();
+ });
+
+ it("reports daemon_unavailable when the base URL is untrusted", async () => {
+ setApiBaseUrl(null);
+
+ await apiClient.GET("/api/v1/projects");
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
+ operation: "GET /api/v1/projects",
+ error_category: "daemon_unavailable",
+ status: 503,
+ });
+ });
+
+ it("dedupes repeated identical failures within the 30s window", async () => {
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("oops", { status: 502 }));
+ setApiBaseUrl("http://127.0.0.1:3037");
+
+ await apiClient.GET("/api/v1/projects");
+ await apiClient.GET("/api/v1/projects");
+ expect(captureMock).toHaveBeenCalledTimes(1);
+
+ vi.setSystemTime(clock + 31_000);
+ await apiClient.GET("/api/v1/projects");
+ expect(captureMock).toHaveBeenCalledTimes(2);
+ });
+});
+
describe("apiErrorMessage", () => {
it("preserves daemon error codes next to human messages", () => {
expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe(
diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts
index 8bc2551aa..6afdecde1 100644
--- a/frontend/src/renderer/lib/api-client.ts
+++ b/frontend/src/renderer/lib/api-client.ts
@@ -1,5 +1,6 @@
import createClient from "openapi-fetch";
import type { paths } from "../../api/schema";
+import { captureRendererEvent } from "./telemetry";
function devApiBaseUrl(): string {
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
@@ -39,43 +40,179 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void {
baseUrlListeners.forEach((listener) => listener());
}
+// Route templates from the generated OpenAPI schema (frontend/src/api/schema.ts).
+// Operation strings sent to telemetry must never contain raw IDs (project IDs
+// are user-chosen strings), so we match each request path against these
+// templates and report the template — collapsing `{param}` to `:id` — rather
+// than guessing which segments are identifiers. Matching from the schema keeps
+// static child routes (notifications/read-all, sessions/cleanup) intact and
+// still normalizes IDs for every resource, including ones a segment heuristic
+// would miss (orchestrators/{id}). Keep in sync with schema.ts.
+const ROUTE_TEMPLATES = [
+ "/api/v1/events",
+ "/api/v1/import",
+ "/api/v1/notifications",
+ "/api/v1/notifications/{id}",
+ "/api/v1/notifications/read-all",
+ "/api/v1/notifications/stream",
+ "/api/v1/orchestrators",
+ "/api/v1/orchestrators/{id}",
+ "/api/v1/projects",
+ "/api/v1/projects/{id}",
+ "/api/v1/projects/{id}/config",
+ "/api/v1/prs/{id}/merge",
+ "/api/v1/prs/{id}/resolve-comments",
+ "/api/v1/sessions",
+ "/api/v1/sessions/{sessionId}",
+ "/api/v1/sessions/{sessionId}/activity",
+ "/api/v1/sessions/{sessionId}/kill",
+ "/api/v1/sessions/{sessionId}/pr",
+ "/api/v1/sessions/{sessionId}/pr/claim",
+ "/api/v1/sessions/{sessionId}/preview",
+ "/api/v1/sessions/{sessionId}/preview/files/*",
+ "/api/v1/sessions/{sessionId}/restore",
+ "/api/v1/sessions/{sessionId}/reviews",
+ "/api/v1/sessions/{sessionId}/reviews/submit",
+ "/api/v1/sessions/{sessionId}/reviews/trigger",
+ "/api/v1/sessions/{sessionId}/rollback",
+ "/api/v1/sessions/{sessionId}/send",
+ "/api/v1/sessions/cleanup",
+] as const;
+
+// Resource collections whose next path segment is an identifier. Only used as a
+// defensive fallback for paths not covered by ROUTE_TEMPLATES; keeps IDs out of
+// telemetry for known collections even if a route is ever missed above.
+const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs", "orchestrators"]);
+
+// Match a path against one template. `{param}` matches any single segment
+// (reported as `:id`), a trailing `*` matches the remaining path, and every
+// other segment must match literally. Returns the normalized template plus a
+// score = number of literal segments matched, so the most specific template
+// wins when several match (e.g. `read-all` beats `{id}`).
+function matchRouteTemplate(pathname: string, template: string): { normalized: string; score: number } | null {
+ const pathSegs = pathname.split("/");
+ const tmplSegs = template.split("/");
+ const out: string[] = [];
+ let score = 0;
+ for (let i = 0; i < tmplSegs.length; i += 1) {
+ const t = tmplSegs[i];
+ if (t === "*") {
+ out.push("*");
+ return { normalized: out.join("/"), score };
+ }
+ const p = pathSegs[i];
+ if (p === undefined) return null;
+ if (t.startsWith("{") && t.endsWith("}")) {
+ out.push(":id");
+ } else if (t === p) {
+ out.push(t);
+ score += 1;
+ } else {
+ return null;
+ }
+ }
+ if (pathSegs.length !== tmplSegs.length) return null;
+ return { normalized: out.join("/"), score };
+}
+
+function fallbackNormalize(pathname: string): string {
+ const segments = pathname.split("/");
+ for (let i = 0; i < segments.length - 1; i += 1) {
+ if (RESOURCE_SEGMENTS.has(segments[i]) && segments[i + 1]) {
+ segments[i + 1] = ":id";
+ i += 1;
+ }
+ }
+ return segments.join("/");
+}
+
+export function normalizeApiOperation(method: string, pathname: string): string {
+ let best: { normalized: string; score: number } | null = null;
+ for (const template of ROUTE_TEMPLATES) {
+ const match = matchRouteTemplate(pathname, template);
+ if (match && (best === null || match.score > best.score)) best = match;
+ }
+ return `${method.toUpperCase()} ${best?.normalized ?? fallbackNormalize(pathname)}`;
+}
+
+type ApiErrorCategory = "daemon_unavailable" | "network_error" | "http_4xx" | "http_5xx";
+
+// One event per (operation, category, status) per window: a daemon outage
+// makes every polling query fail at once and on every retry — the failure
+// signal matters, the storm does not.
+const API_ERROR_DEDUPE_MS = 30_000;
+const lastApiErrorAt = new Map();
+
+function reportApiError(operation: string, category: ApiErrorCategory, status?: number): void {
+ const key = `${operation}|${category}|${status ?? ""}`;
+ const now = Date.now();
+ const last = lastApiErrorAt.get(key);
+ if (last !== undefined && now - last < API_ERROR_DEDUPE_MS) return;
+ lastApiErrorAt.set(key, now);
+ void captureRendererEvent("ao.renderer.api_error", {
+ operation,
+ error_category: category,
+ status,
+ });
+}
+
async function runtimeFetch(input: Request): Promise {
+ const operation = normalizeApiOperation(input.method, new URL(input.url).pathname);
const baseUrl = runtimeApiBaseUrl;
if (baseUrl === null) {
+ reportApiError(operation, "daemon_unavailable", 503);
return new Response(JSON.stringify({ message: "AO daemon is not ready." }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
- if (!baseUrl) {
- return fetch(input);
- }
- const url = new URL(input.url);
- const target = new URL(url.pathname + url.search + url.hash, baseUrl);
- if (target.href === input.url) {
- return fetch(input);
- }
+ const send = async (): Promise => {
+ if (!baseUrl) {
+ return fetch(input);
+ }
- // Rebase onto the runtime base URL by copying fields explicitly and
- // buffering the body. `new Request(target, input)` reads the source
- // request's `duplex` getter, which Electron's Chromium lacks — it throws
- // "The duplex member must be specified" for any request with a body, so
- // every POST would fail in the packaged app. API bodies are small JSON;
- // buffering sidesteps streaming-duplex semantics entirely.
- const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
- return fetch(target, {
- method: input.method,
- headers: input.headers,
- body,
- signal: input.signal,
- credentials: input.credentials,
- cache: input.cache,
- redirect: input.redirect,
- referrerPolicy: input.referrerPolicy,
- integrity: input.integrity,
- keepalive: input.keepalive,
- });
+ const url = new URL(input.url);
+ const target = new URL(url.pathname + url.search + url.hash, baseUrl);
+ if (target.href === input.url) {
+ return fetch(input);
+ }
+
+ // Rebase onto the runtime base URL by copying fields explicitly and
+ // buffering the body. `new Request(target, input)` reads the source
+ // request's `duplex` getter, which Electron's Chromium lacks — it throws
+ // "The duplex member must be specified" for any request with a body, so
+ // every POST would fail in the packaged app. API bodies are small JSON;
+ // buffering sidesteps streaming-duplex semantics entirely.
+ const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
+ return fetch(target, {
+ method: input.method,
+ headers: input.headers,
+ body,
+ signal: input.signal,
+ credentials: input.credentials,
+ cache: input.cache,
+ redirect: input.redirect,
+ referrerPolicy: input.referrerPolicy,
+ integrity: input.integrity,
+ keepalive: input.keepalive,
+ });
+ };
+
+ let response: Response;
+ try {
+ response = await send();
+ } catch (error) {
+ // Caller-initiated aborts (unmounted components cancelling queries) are not failures.
+ if (!(error instanceof DOMException && error.name === "AbortError")) {
+ reportApiError(operation, "network_error");
+ }
+ throw error;
+ }
+ if (!response.ok) {
+ reportApiError(operation, response.status >= 500 ? "http_5xx" : "http_4xx", response.status);
+ }
+ return response;
}
export const apiClient = createClient({
diff --git a/frontend/src/renderer/lib/daemon-telemetry.test.ts b/frontend/src/renderer/lib/daemon-telemetry.test.ts
new file mode 100644
index 000000000..718f120cd
--- /dev/null
+++ b/frontend/src/renderer/lib/daemon-telemetry.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { DaemonStatus } from "../../shared/daemon-status";
+import { aoBridge } from "./bridge";
+import { startDaemonFailureTelemetry } from "./daemon-telemetry";
+import { captureRendererEvent } from "./telemetry";
+
+vi.mock("./telemetry", () => ({
+ captureRendererEvent: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("./bridge", () => ({
+ aoBridge: {
+ daemon: {
+ getStatus: vi.fn(),
+ onStatus: vi.fn(),
+ },
+ },
+}));
+
+const captureMock = vi.mocked(captureRendererEvent);
+const getStatusMock = vi.mocked(aoBridge.daemon.getStatus);
+const onStatusMock = vi.mocked(aoBridge.daemon.onStatus);
+
+describe("daemon failure telemetry", () => {
+ let pushStatus!: (status: DaemonStatus) => void;
+ let stop: () => void = () => undefined;
+
+ beforeEach(() => {
+ captureMock.mockClear();
+ onStatusMock.mockClear();
+ getStatusMock.mockResolvedValue({ state: "starting" });
+ onStatusMock.mockImplementation((listener) => {
+ pushStatus = listener;
+ return () => undefined;
+ });
+ });
+
+ afterEach(() => {
+ stop();
+ });
+
+ it("reports a failing status with coarse fields only", () => {
+ stop = startDaemonFailureTelemetry();
+
+ pushStatus({ state: "error", message: "spawn /Users/alice/ao failed", code: "spawn_failed" });
+
+ expect(captureMock).toHaveBeenCalledTimes(1);
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
+ daemon_state: "error",
+ code: "spawn_failed",
+ exit_code: undefined,
+ signal: undefined,
+ });
+ });
+
+ it("includes exit code and signal for daemon exits", () => {
+ stop = startDaemonFailureTelemetry();
+
+ pushStatus({ state: "stopped", code: "exited", exitCode: 1, signal: "SIGKILL" });
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
+ daemon_state: "stopped",
+ code: "exited",
+ exit_code: 1,
+ signal: "SIGKILL",
+ });
+ });
+
+ it("ignores statuses without a failure code (healthy or user-initiated stop)", () => {
+ stop = startDaemonFailureTelemetry();
+
+ pushStatus({ state: "ready", port: 3037 });
+ pushStatus({ state: "stopped" });
+
+ expect(captureMock).not.toHaveBeenCalled();
+ });
+
+ it("dedupes identical consecutive failures and resets on recovery", () => {
+ stop = startDaemonFailureTelemetry();
+
+ pushStatus({ state: "error", code: "daemon_unreachable" });
+ pushStatus({ state: "error", code: "daemon_unreachable" });
+ expect(captureMock).toHaveBeenCalledTimes(1);
+
+ // A different failure is a new event.
+ pushStatus({ state: "stopped", code: "exited", exitCode: 137 });
+ expect(captureMock).toHaveBeenCalledTimes(2);
+
+ // Recovery resets dedupe: the same failure recurring is reported again.
+ pushStatus({ state: "ready", port: 3037 });
+ pushStatus({ state: "error", code: "daemon_unreachable" });
+ expect(captureMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("reports the initial status from getStatus on start", async () => {
+ getStatusMock.mockResolvedValue({ state: "error", code: "binary_missing" });
+
+ stop = startDaemonFailureTelemetry();
+ await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1));
+
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
+ daemon_state: "error",
+ code: "binary_missing",
+ exit_code: undefined,
+ signal: undefined,
+ });
+ });
+
+ it("is idempotent while started and restartable after stop", () => {
+ stop = startDaemonFailureTelemetry();
+ const noop = startDaemonFailureTelemetry();
+ expect(onStatusMock).toHaveBeenCalledTimes(1);
+ noop();
+
+ stop();
+ stop = startDaemonFailureTelemetry();
+ expect(onStatusMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/renderer/lib/daemon-telemetry.ts b/frontend/src/renderer/lib/daemon-telemetry.ts
new file mode 100644
index 000000000..a02987227
--- /dev/null
+++ b/frontend/src/renderer/lib/daemon-telemetry.ts
@@ -0,0 +1,53 @@
+// Daemon failures happen in the Electron main process, which has no PostHog
+// client. Main stamps a machine-readable `code` on every failing DaemonStatus
+// (shared/daemon-status.ts); this module rides the existing daemon:status IPC
+// push and reports those failures through the renderer's telemetry client.
+import type { DaemonStatus } from "../../shared/daemon-status";
+import { aoBridge } from "./bridge";
+import { captureRendererEvent } from "./telemetry";
+
+let started = false;
+let lastFailureKey: string | null = null;
+
+function failureKey(status: DaemonStatus): string | null {
+ if (!status.code) return null;
+ return [status.state, status.code, status.exitCode ?? "", status.signal ?? ""].join("|");
+}
+
+function reportStatus(status: DaemonStatus): void {
+ const key = failureKey(status);
+ if (!key) {
+ // Healthy status resets dedupe so a repeat failure after recovery is a new event.
+ lastFailureKey = null;
+ return;
+ }
+ if (key === lastFailureKey) return;
+ lastFailureKey = key;
+ void captureRendererEvent("ao.renderer.daemon_failure", {
+ daemon_state: status.state,
+ code: status.code,
+ exit_code: typeof status.exitCode === "number" ? status.exitCode : undefined,
+ signal: status.signal ?? undefined,
+ });
+}
+
+/** Idempotent; returns a stop function (used by tests). */
+export function startDaemonFailureTelemetry(): () => void {
+ if (started) return () => undefined;
+ started = true;
+ void aoBridge.daemon
+ .getStatus()
+ .then(reportStatus)
+ .catch(() => undefined);
+ let stopListener: () => void = () => undefined;
+ try {
+ stopListener = aoBridge.daemon.onStatus(reportStatus);
+ } catch {
+ // Preload bridge unavailable (browser preview): initial getStatus already handled.
+ }
+ return () => {
+ started = false;
+ lastFailureKey = null;
+ stopListener();
+ };
+}
diff --git a/frontend/src/renderer/lib/mock-data.ts b/frontend/src/renderer/lib/mock-data.ts
index 0c77f116d..f7efc57fd 100644
--- a/frontend/src/renderer/lib/mock-data.ts
+++ b/frontend/src/renderer/lib/mock-data.ts
@@ -1,4 +1,5 @@
import type { PRState, PullRequestFacts, WorkspaceSummary } from "../types/workspace";
+import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
const now = new Date().toISOString();
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000).toISOString();
@@ -202,3 +203,199 @@ export const mockWorkspaces: WorkspaceSummary[] = [
],
},
];
+
+const prSummary = (sessionId: string, number: number, overrides: Partial = {}): SessionPRSummary => {
+ const session = mockWorkspaces.flatMap((workspace) => workspace.sessions).find((item) => item.id === sessionId);
+ const facts = session?.prs.find((item) => item.number === number);
+ const url = facts?.url ?? `https://github.com/me/${session?.workspaceName ?? "preview"}/pull/${number}`;
+ return {
+ url,
+ htmlUrl: url,
+ number,
+ title: session?.title ?? `PR #${number}`,
+ state: facts?.state ?? "open",
+ provider: "github",
+ repo: `me/${session?.workspaceName ?? "preview"}`,
+ author: "preview-agent",
+ sourceBranch: session?.branch ?? "",
+ targetBranch: "main",
+ headSha: `preview-${number}`,
+ additions: 42,
+ deletions: 8,
+ changedFiles: 3,
+ ci: {
+ state: facts?.ci === "failing" ? "failing" : facts?.ci === "pending" ? "pending" : "passing",
+ failingChecks: [],
+ },
+ review: {
+ decision:
+ facts?.review === "changes_requested"
+ ? "changes_requested"
+ : facts?.review === "approved"
+ ? "approved"
+ : "none",
+ hasUnresolvedHumanComments: facts?.reviewComments ?? false,
+ unresolvedBy: [],
+ },
+ mergeability: {
+ state:
+ facts?.mergeability === "conflicting"
+ ? "conflicting"
+ : facts?.mergeability === "blocked"
+ ? "blocked"
+ : facts?.mergeability === "unstable"
+ ? "unstable"
+ : facts?.mergeability === "unknown"
+ ? "unknown"
+ : "mergeable",
+ reasons: [],
+ prUrl: url,
+ conflictFiles: [],
+ },
+ updatedAt: facts?.updatedAt ?? now,
+ observedAt: facts?.updatedAt ?? now,
+ ciObservedAt: facts?.updatedAt ?? now,
+ reviewObservedAt: facts?.updatedAt ?? now,
+ ...overrides,
+ };
+};
+
+export const mockSessionScmSummaries: Record = {
+ "fix-auth-timeouts": [
+ prSummary("fix-auth-timeouts", 184, {
+ changedFiles: 5,
+ additions: 91,
+ deletions: 17,
+ ci: {
+ state: "failing",
+ failingChecks: [
+ {
+ name: "backend / go test ./...",
+ status: "failed",
+ conclusion: "failure",
+ url: "https://github.com/me/api-gateway/actions/runs/184001/job/1",
+ },
+ {
+ name: "lint / golangci",
+ status: "failed",
+ conclusion: "failure",
+ url: "https://github.com/me/api-gateway/actions/runs/184001/job/2",
+ },
+ {
+ name: "api contract drift",
+ status: "failed",
+ conclusion: "failure",
+ url: "https://github.com/me/api-gateway/actions/runs/184001/job/3",
+ },
+ {
+ name: "frontend typecheck",
+ status: "failed",
+ conclusion: "",
+ url: "https://github.com/me/api-gateway/actions/runs/184001/job/4",
+ },
+ ],
+ },
+ }),
+ ],
+ "texture-leak": [
+ prSummary("texture-leak", 51, {
+ changedFiles: 4,
+ additions: 74,
+ deletions: 22,
+ ci: {
+ state: "failing",
+ failingChecks: [
+ {
+ name: "render tests",
+ status: "failed",
+ conclusion: "failure",
+ url: "https://github.com/me/webgl-preview/actions/runs/51001/job/1",
+ },
+ {
+ name: "visual regression",
+ status: "failed",
+ conclusion: "failure",
+ url: "https://github.com/me/webgl-preview/actions/runs/51001/job/2",
+ },
+ ],
+ },
+ mergeability: {
+ state: "conflicting",
+ reasons: ["conflicts"],
+ prUrl: "https://github.com/me/webgl-preview/pull/51",
+ conflictFiles: [
+ {
+ path: "src/render/texture-cache.ts",
+ url: "https://github.com/me/webgl-preview/pull/51/conflicts#src-render-texture-cache-ts",
+ },
+ {
+ path: "src/render/webgl-context.ts",
+ url: "https://github.com/me/webgl-preview/pull/51/conflicts#src-render-webgl-context-ts",
+ },
+ ],
+ },
+ }),
+ ],
+ "review-camera-pan": [
+ prSummary("review-camera-pan", 52, {
+ changedFiles: 6,
+ additions: 128,
+ deletions: 31,
+ review: {
+ decision: "review_required",
+ hasUnresolvedHumanComments: false,
+ unresolvedBy: [],
+ },
+ }),
+ ],
+ "input-pointer-lock": [
+ prSummary("input-pointer-lock", 56, {
+ changedFiles: 3,
+ additions: 48,
+ deletions: 14,
+ review: {
+ decision: "changes_requested",
+ hasUnresolvedHumanComments: true,
+ unresolvedBy: [
+ {
+ reviewerId: "maya",
+ count: 3,
+ reviewUrl: "https://github.com/me/webgl-preview/pull/56#pullrequestreview-1001",
+ links: [
+ {
+ url: "https://github.com/me/webgl-preview/pull/56#discussion_r1001",
+ file: "src/input/pointer-lock.ts",
+ line: 88,
+ },
+ {
+ url: "https://github.com/me/webgl-preview/pull/56#discussion_r1002",
+ file: "src/input/keyboard.ts",
+ line: 41,
+ },
+ ],
+ },
+ {
+ reviewerId: "copilot",
+ count: 1,
+ isBot: true,
+ reviewUrl: "https://github.com/me/webgl-preview/pull/56#pullrequestreview-1002",
+ links: [],
+ },
+ ],
+ },
+ }),
+ ],
+ "invoice-export": [
+ prSummary("invoice-export", 117, {
+ changedFiles: 8,
+ additions: 212,
+ deletions: 36,
+ mergeability: {
+ state: "blocked",
+ reasons: ["behind_base", "review_required", "blocked_by_provider", "ci_failing"],
+ prUrl: "https://github.com/me/billing-portal/pull/117",
+ conflictFiles: [],
+ },
+ }),
+ ],
+};
diff --git a/frontend/src/renderer/lib/pr-display.test.ts b/frontend/src/renderer/lib/pr-display.test.ts
index 3a904f5ec..119b8b051 100644
--- a/frontend/src/renderer/lib/pr-display.test.ts
+++ b/frontend/src/renderer/lib/pr-display.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
-import { prAttentionItems, prBrowserUrl, prDiffSummary, prStatusRows } from "./pr-display";
+import { prBrowserUrl, prDiffSummary, prStatusRows, prSummaryParts } from "./pr-display";
const summary = (overrides: Partial = {}): SessionPRSummary => ({
url: "https://github.com/acme/repo/pull/7",
@@ -37,7 +37,7 @@ describe("prStatusRows", () => {
}),
);
- expect(rows.map((row) => `${row.label}:${row.value}`)).toEqual(["CI:Checking", "Review:None", "Merge:Checking"]);
+ expect(rows.map((row) => `${row.label}:${row.value}`)).toEqual(["CI:Checking", "Merge:Checking", "Review:None"]);
});
it("includes minimal diff detail on the merge row", () => {
@@ -69,13 +69,13 @@ describe("prBrowserUrl", () => {
});
});
-describe("prAttentionItems", () => {
- it("returns no attention for clean open PRs", () => {
- expect(prAttentionItems(summary())).toEqual([]);
+describe("prSummaryParts", () => {
+ it("always returns CI, Merge, and Review parts", () => {
+ expect(prSummaryParts(summary()).map((part) => part.label)).toEqual(["CI", "Merge", "Review"]);
});
- it("details active CI, review, and merge blockers", () => {
- const items = prAttentionItems(
+ it("details active CI, merge, and review blockers under their parts", () => {
+ const parts = prSummaryParts(
summary({
ci: {
state: "failing",
@@ -102,19 +102,34 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.map((item) => item.kind)).toEqual(["merge_blocked", "ci_failing", "review_changes_requested"]);
- expect(items.find((item) => item.kind === "ci_failing")?.links[0]).toMatchObject({
+ expect(parts.map((part) => part.key)).toEqual(["ci", "merge", "review"]);
+ expect(parts.find((part) => part.key === "ci")).toMatchObject({
+ status: "Failing",
+ summary: undefined,
+ tone: "error",
+ });
+ expect(parts.find((part) => part.key === "ci")?.links[0]).toMatchObject({
label: "copy-check",
href: "https://checks.example/copy",
});
- expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "merge")).toMatchObject({
+ status: "Blocked",
+ summary: undefined,
+ tone: "warning",
+ });
+ expect(parts.find((part) => part.key === "review")).toMatchObject({
+ status: "Changes requested",
+ summary: undefined,
+ tone: "warning",
+ });
+ expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
label: "alice +5",
href: "https://github.com/acme/repo/pull/7#discussion_r1",
});
});
it("links failing CI checks to their provider URLs", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
ci: {
state: "failing",
@@ -128,17 +143,17 @@ describe("prAttentionItems", () => {
}),
);
- const ciItem = items.find((item) => item.kind === "ci_failing");
- expect(ciItem?.links).toEqual([
+ const ciPart = parts.find((part) => part.key === "ci");
+ expect(ciPart?.links).toEqual([
{ label: "unit", href: "https://checks.example/unit", title: "failure" },
{ label: "lint", href: "https://checks.example/lint", title: "failure" },
{ label: "build", href: "https://checks.example/build", title: "failure" },
]);
- expect(ciItem?.overflowLabel).toBe("+1 check");
+ expect(ciPart?.overflowLabel).toBe("+1 check");
});
it("prefers the submitted review summary over inline comments", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
review: {
decision: "changes_requested",
@@ -158,7 +173,7 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
label: "alice +1",
href: "https://github.com/acme/repo/pull/7#pullrequestreview-1",
title: "Open requested-changes review from alice",
@@ -166,7 +181,7 @@ describe("prAttentionItems", () => {
});
it("falls back to the first inline comment when no review summary exists", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
review: {
decision: "changes_requested",
@@ -185,7 +200,7 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
label: "alice +1",
href: "https://github.com/acme/repo/pull/7#discussion_r1",
title: "2 unresolved comments from alice",
@@ -193,7 +208,7 @@ describe("prAttentionItems", () => {
});
it("falls back to the PR page when review summary and inline comment URLs are missing", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
url: "https://github.com/acme/repo/issues/7",
htmlUrl: "https://github.com/acme/repo/issues/7",
@@ -205,7 +220,7 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
label: "alice",
href: "https://github.com/acme/repo/pull/7",
title: "Open pull request for alice",
@@ -213,7 +228,7 @@ describe("prAttentionItems", () => {
});
it("shows bot reviewers with a bot label", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
review: {
decision: "changes_requested",
@@ -231,7 +246,7 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "review")?.links[0]).toMatchObject({
label: "copilot bot",
href: "https://github.com/acme/repo/pull/7#pullrequestreview-2",
title: "Open requested-changes review from copilot bot",
@@ -239,7 +254,7 @@ describe("prAttentionItems", () => {
});
it("links merge conflicts to GitHub's conflict resolution page", () => {
- const items = prAttentionItems(
+ const parts = prSummaryParts(
summary({
url: "https://github.com/acme/repo/issues/7",
htmlUrl: "https://github.com/acme/repo/issues/7",
@@ -251,22 +266,39 @@ describe("prAttentionItems", () => {
}),
);
- expect(items.find((item) => item.kind === "merge_conflict")?.links[0]).toMatchObject({
+ expect(parts.find((part) => part.key === "merge")).toMatchObject({
+ status: "Conflict",
+ summary: undefined,
+ });
+ expect(parts.find((part) => part.key === "merge")?.links[0]).toMatchObject({
label: "conflicts",
href: "https://github.com/acme/repo/pull/7/conflicts",
});
});
- it("suppresses attention once the PR is closed or merged", () => {
- expect(
- prAttentionItems(
- summary({
- state: "merged",
- ci: { state: "failing", failingChecks: [{ name: "unit", status: "failed", conclusion: "failure" }] },
- review: { decision: "changes_requested", hasUnresolvedHumanComments: true, unresolvedBy: [] },
- mergeability: { state: "conflicting", reasons: ["conflicts"], prUrl: "https://github.com/acme/repo/pull/7" },
- }),
- ),
- ).toEqual([]);
+ it("keeps closed or merged PR summaries to the three status parts", () => {
+ const parts = prSummaryParts(
+ summary({
+ state: "merged",
+ ci: { state: "failing", failingChecks: [{ name: "unit", status: "failed", conclusion: "failure" }] },
+ review: { decision: "changes_requested", hasUnresolvedHumanComments: true, unresolvedBy: [] },
+ mergeability: { state: "conflicting", reasons: ["conflicts"], prUrl: "https://github.com/acme/repo/pull/7" },
+ }),
+ );
+
+ expect(parts).toHaveLength(3);
+ expect(parts.find((part) => part.key === "merge")?.links).toEqual([]);
+ expect(parts.find((part) => part.key === "review")?.links).toEqual([]);
+ });
+
+ it("puts draft readiness under Review", () => {
+ const parts = prSummaryParts(
+ summary({ state: "draft", review: { decision: "none", hasUnresolvedHumanComments: false, unresolvedBy: [] } }),
+ );
+
+ expect(parts.find((part) => part.key === "review")).toMatchObject({
+ status: "None",
+ summary: "Draft PR · Not ready for review",
+ });
});
});
diff --git a/frontend/src/renderer/lib/pr-display.ts b/frontend/src/renderer/lib/pr-display.ts
index 7ce2c3f45..da1861df0 100644
--- a/frontend/src/renderer/lib/pr-display.ts
+++ b/frontend/src/renderer/lib/pr-display.ts
@@ -27,18 +27,23 @@ export type PRStatusRow = {
tone: PRDisplayTone;
};
-export type PRAttentionLink = {
+export type PRSummaryPartKey = "ci" | "review" | "merge";
+
+export type PRSummaryLink = {
label: string;
href?: string;
title?: string;
};
-export type PRAttentionItem = {
- kind: "draft" | "ci_failing" | "review_changes_requested" | "review_pending" | "merge_conflict" | "merge_blocked";
- title: string;
+export type PRSummaryPart = {
+ key: PRSummaryPartKey;
+ label: string;
+ status: string;
summary?: string;
- links: PRAttentionLink[];
+ links: PRSummaryLink[];
+ linkTotal?: number;
overflowLabel?: string;
+ overflowNoun?: string;
tone: PRDisplayTone;
};
@@ -103,26 +108,53 @@ function sessionPRFactToSummary(session: WorkspaceSession, pr: PullRequestFacts)
}
export function prStatusRows(pr: SessionPRSummary): PRStatusRow[] {
+ return prSummaryParts(pr).map((part) => ({
+ key: part.key,
+ label: part.label,
+ value: part.status,
+ detail: part.key === "merge" ? formatDiffSummary(pr) : undefined,
+ tone: part.tone,
+ }));
+}
+
+export function prSummaryParts(pr: SessionPRSummary): PRSummaryPart[] {
return [
{
key: "ci",
label: "CI",
- value: ciLabel(pr.ci.state),
+ status: ciLabel(pr.ci.state),
+ summary: ciSummary(pr),
+ links: ciLinks(pr),
+ linkTotal: pr.ci.state === "failing" ? pr.ci.failingChecks.length : 0,
+ overflowLabel: pr.ci.state === "failing" ? overflowLabel(pr.ci.failingChecks.length, 3, "check") : undefined,
+ overflowNoun: "check",
tone: ciTone(pr.ci.state),
},
- {
- key: "review",
- label: "Review",
- value: reviewLabel(pr.review.decision),
- tone: reviewTone(pr.review.decision, pr.review.hasUnresolvedHumanComments),
- },
{
key: "merge",
label: "Merge",
- value: mergeabilityLabel(pr.mergeability.state),
- detail: formatDiffSummary(pr),
+ status: mergeabilityLabel(pr.mergeability.state),
+ summary: mergeSummary(pr),
+ links: mergeLinks(pr),
+ linkTotal: mergeLinkTotal(pr),
+ overflowLabel: mergeOverflowLabel(pr),
+ overflowNoun: mergeOverflowNoun(pr),
tone: mergeabilityTone(pr.mergeability.state),
},
+ {
+ key: "review",
+ label: "Review",
+ status: reviewLabel(pr.review.decision),
+ summary: reviewSummary(pr),
+ links: reviewLinks(pr),
+ linkTotal: reviewLinkTotal(pr),
+ overflowLabel:
+ pr.state === "draft" || pr.review.decision === "review_required"
+ ? undefined
+ : overflowLabel(pr.review.unresolvedBy.length, 3, "reviewer"),
+ overflowNoun: "reviewer",
+ tone: reviewTone(pr.review.decision, pr.review.hasUnresolvedHumanComments),
+ },
];
}
@@ -138,69 +170,120 @@ export function prDiffSummary(pr: SessionPRSummary): string | undefined {
return parts.length > 0 ? parts.join(" · ") : undefined;
}
-export function prAttentionItems(pr: SessionPRSummary): PRAttentionItem[] {
+function ciSummary(pr: SessionPRSummary): string | undefined {
+ if (pr.ci.state === "failing") {
+ return pr.ci.failingChecks.length === 0 ? "No failing check link observed" : undefined;
+ }
+ return undefined;
+}
+
+function ciLinks(pr: SessionPRSummary): PRSummaryLink[] {
+ if (pr.ci.state !== "failing") {
+ return [];
+ }
+ return pr.ci.failingChecks.slice(0, 3).map((check) => ({
+ label: check.name,
+ href: check.url || undefined,
+ title: check.conclusion || check.status,
+ }));
+}
+
+function reviewSummary(pr: SessionPRSummary): string | undefined {
+ if (pr.state === "merged" || pr.state === "closed") {
+ return undefined;
+ }
+ if (pr.state === "draft") {
+ return "Draft PR · Not ready for review";
+ }
+ if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) {
+ return reviewLinks(pr).length === 0 ? "Requested changes still active" : undefined;
+ }
+ if (pr.review.decision === "review_required") {
+ return "Required review not submitted";
+ }
+ return undefined;
+}
+
+function reviewLinks(pr: SessionPRSummary): PRSummaryLink[] {
+ if (pr.state === "merged" || pr.state === "closed" || pr.state === "draft") {
+ return [];
+ }
+ if (pr.review.decision !== "changes_requested" && !pr.review.hasUnresolvedHumanComments) {
+ return [];
+ }
+ const links = pr.review.unresolvedBy.slice(0, 3).map((reviewer) => reviewAttentionLink(pr, reviewer));
+ if (links.length === 0 && pr.review.decision === "changes_requested") {
+ links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" });
+ }
+ return links;
+}
+
+function mergeSummary(pr: SessionPRSummary): string | undefined {
+ if (pr.state === "merged" || pr.state === "closed") {
+ return formatDiffSummary(pr);
+ }
+ if (pr.mergeability.state === "conflicting") {
+ return mergeLinks(pr).length === 0 ? "Conflicts with the base branch" : undefined;
+ }
+ if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
+ return mergeLinks(pr).length === 0 ? "Provider reports merge is blocked" : undefined;
+ }
+ return formatDiffSummary(pr);
+}
+
+function mergeLinks(pr: SessionPRSummary): PRSummaryLink[] {
if (pr.state === "merged" || pr.state === "closed") {
return [];
}
- if (pr.state === "draft") {
- return [
- {
- kind: "draft",
- title: "Draft PR",
- summary: "Not ready for review",
- links: [],
- tone: "passive",
- },
- ];
- }
-
- const items: PRAttentionItem[] = [];
if (pr.mergeability.state === "conflicting") {
- items.push(
- mergeAttention(pr, "merge_conflict", "Resolve merge conflict", "Conflicts with the base branch", "error"),
- );
- } else if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
- items.push(mergeAttention(pr, "merge_blocked", "Merge blocked", "Provider reports merge is blocked", "warning"));
+ return mergeAttentionLinks(pr, "merge_conflict");
}
- if (pr.ci.state === "failing") {
- const links = pr.ci.failingChecks.slice(0, 3).map((check) => ({
- label: check.name,
- href: check.url || undefined,
- title: check.conclusion || check.status,
- }));
- items.push({
- kind: "ci_failing",
- title: "Fix failing CI",
- summary: links.length === 0 ? "No failing check link observed" : undefined,
- links,
- overflowLabel: overflowLabel(pr.ci.failingChecks.length, 3, "check"),
- tone: "error",
- });
+ if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
+ return mergeAttentionLinks(pr, "merge_blocked");
}
- if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) {
- const reviewers = pr.review.unresolvedBy.slice(0, 3);
- const links = reviewers.map((reviewer) => reviewAttentionLink(pr, reviewer));
- if (links.length === 0 && pr.review.decision === "changes_requested") {
- links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" });
- }
- items.push({
- kind: "review_changes_requested",
- title: "Address requested changes",
- summary: links.length === 0 ? "Requested changes still active" : undefined,
- links,
- overflowLabel: overflowLabel(pr.review.unresolvedBy.length, 3, "reviewer"),
- tone: "warning",
- });
- } else if (pr.review.decision === "review_required") {
- items.push({
- kind: "review_pending",
- title: "Review pending",
- summary: "Required review not submitted",
- links: [],
- tone: "neutral",
- });
+ return [];
+}
+
+function mergeOverflowLabel(pr: SessionPRSummary): string | undefined {
+ if (pr.state === "merged" || pr.state === "closed") {
+ return undefined;
}
- return items;
+ const hasFileLinks = (pr.mergeability.conflictFiles ?? []).length > 0;
+ if (hasFileLinks) {
+ return overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file");
+ }
+ if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
+ return overflowLabel(pr.mergeability.reasons.length, 3, "reason");
+ }
+ return undefined;
+}
+
+function mergeLinkTotal(pr: SessionPRSummary): number {
+ if (pr.state === "merged" || pr.state === "closed") {
+ return 0;
+ }
+ if (pr.mergeability.state === "conflicting") {
+ const conflictFileCount = pr.mergeability.conflictFiles?.length ?? 0;
+ return conflictFileCount > 0 ? conflictFileCount : mergeLinks(pr).length;
+ }
+ if (pr.mergeability.state === "blocked" || pr.mergeability.state === "unstable") {
+ return pr.mergeability.reasons.length;
+ }
+ return 0;
+}
+
+function mergeOverflowNoun(pr: SessionPRSummary): string {
+ return (pr.mergeability.conflictFiles ?? []).length > 0 ? "file" : "reason";
+}
+
+function reviewLinkTotal(pr: SessionPRSummary): number {
+ if (pr.state === "merged" || pr.state === "closed" || pr.state === "draft") {
+ return 0;
+ }
+ if (pr.review.decision !== "changes_requested" && !pr.review.hasUnresolvedHumanComments) {
+ return 0;
+ }
+ return pr.review.unresolvedBy.length > 0 ? pr.review.unresolvedBy.length : reviewLinks(pr).length;
}
function toCIState(value: string): SessionPRSummary["ci"]["state"] {
@@ -327,13 +410,7 @@ function formatLineDelta(additions: number, deletions: number): string | undefin
return parts.length > 0 ? parts.join(" ") : undefined;
}
-function mergeAttention(
- pr: SessionPRSummary,
- kind: Extract,
- title: string,
- fallback: string,
- tone: PRDisplayTone,
-): PRAttentionItem {
+function mergeAttentionLinks(pr: SessionPRSummary, kind: "merge_conflict" | "merge_blocked"): PRSummaryLink[] {
const href =
kind === "merge_conflict" ? mergeConflictUrl(pr) : pr.mergeability.prUrl || pr.htmlUrl || pr.url || undefined;
const fileLinks = (pr.mergeability.conflictFiles ?? []).slice(0, 3).map((file) => ({
@@ -350,18 +427,7 @@ function mergeAttention(
}));
const fallbackLink =
kind === "merge_conflict" && href ? [{ label: "conflicts", href, title: "Open merge conflicts" }] : [];
- const links = fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink;
- return {
- kind,
- title,
- summary: links.length === 0 ? fallback : undefined,
- links,
- overflowLabel:
- fileLinks.length > 0
- ? overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file")
- : overflowLabel(pr.mergeability.reasons.length, 3, "reason"),
- tone,
- };
+ return fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink;
}
function mergeConflictUrl(pr: SessionPRSummary): string | undefined {
@@ -412,7 +478,7 @@ function reviewerDisplayName(reviewer: SessionPRSummary["review"]["unresolvedBy"
function reviewAttentionLink(
pr: SessionPRSummary,
reviewer: SessionPRSummary["review"]["unresolvedBy"][number],
-): PRAttentionLink {
+): PRSummaryLink {
const inlineURL = reviewer.links.find((link) => link.url)?.url;
if (reviewer.reviewUrl) {
return {
diff --git a/frontend/src/renderer/lib/restart-orchestrator.test.ts b/frontend/src/renderer/lib/restart-orchestrator.test.ts
index 8b85b9bf8..a202fc21b 100644
--- a/frontend/src/renderer/lib/restart-orchestrator.test.ts
+++ b/frontend/src/renderer/lib/restart-orchestrator.test.ts
@@ -36,7 +36,7 @@ describe("restartProjectOrchestrator", () => {
onError,
});
- expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
+ expect(spawnMock).toHaveBeenCalledWith("proj-1", "restart", true);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
diff --git a/frontend/src/renderer/lib/restart-orchestrator.ts b/frontend/src/renderer/lib/restart-orchestrator.ts
index d921a4c80..c1619add1 100644
--- a/frontend/src/renderer/lib/restart-orchestrator.ts
+++ b/frontend/src/renderer/lib/restart-orchestrator.ts
@@ -36,7 +36,7 @@ export async function restartProjectOrchestrator({
setProjectRestarting(projectId, true);
setOrchestratorReplacementError(projectId, null);
try {
- const sessionId = await spawnOrchestrator(projectId, true);
+ const sessionId = await spawnOrchestrator(projectId, "restart", true);
await refreshWorkspaceState(queryClient);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts
index a8748fd20..2d547fadd 100644
--- a/frontend/src/renderer/lib/spawn-orchestrator.test.ts
+++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { spawnOrchestrator } from "./spawn-orchestrator";
import { apiClient } from "./api-client";
+import { captureRendererEvent } from "./telemetry";
vi.mock("./api-client", () => ({
apiClient: { POST: vi.fn() },
@@ -14,6 +15,12 @@ vi.mock("./api-client", () => ({
},
}));
+vi.mock("./telemetry", () => ({
+ captureRendererEvent: vi.fn().mockResolvedValue(undefined),
+}));
+
+const captureMock = vi.mocked(captureRendererEvent);
+
describe("spawnOrchestrator", () => {
beforeEach(() => vi.clearAllMocks());
@@ -23,7 +30,7 @@ describe("spawnOrchestrator", () => {
error: undefined,
response: { status: 201 },
});
- const id = await spawnOrchestrator("proj", true);
+ const id = await spawnOrchestrator("proj", "restore_dialog", true);
expect(id).toBe("proj-9");
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj", clean: true },
@@ -36,12 +43,43 @@ describe("spawnOrchestrator", () => {
error: undefined,
response: { status: 201 },
});
- await spawnOrchestrator("proj");
+ await spawnOrchestrator("proj", "board");
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj", clean: false },
});
});
+ it("emits the requested + succeeded triad keyed by source", async () => {
+ (apiClient.POST as ReturnType).mockResolvedValue({
+ data: { orchestrator: { id: "proj-7" } },
+ error: undefined,
+ response: { status: 201 },
+ });
+ await spawnOrchestrator("proj", "sidebar");
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_requested", {
+ project_id: "proj",
+ source: "sidebar",
+ });
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", {
+ project_id: "proj",
+ source: "sidebar",
+ });
+ });
+
+ it("emits the failed event and rethrows when the daemon rejects the spawn", async () => {
+ (apiClient.POST as ReturnType).mockResolvedValue({
+ data: undefined,
+ error: { message: "boom" },
+ response: { status: 500 },
+ });
+ await expect(spawnOrchestrator("proj", "topbar")).rejects.toThrow("boom");
+ expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_failed", {
+ project_id: "proj",
+ source: "topbar",
+ });
+ expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", expect.anything());
+ });
+
it("surfaces daemon spawn error messages and codes", async () => {
(apiClient.POST as ReturnType).mockResolvedValue({
data: undefined,
@@ -49,6 +87,8 @@ describe("spawnOrchestrator", () => {
response: { status: 400 },
});
- await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)");
+ await expect(spawnOrchestrator("proj", "board")).rejects.toThrow(
+ "agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)",
+ );
});
});
diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts
index 13d2f5533..c1be2ad62 100644
--- a/frontend/src/renderer/lib/spawn-orchestrator.ts
+++ b/frontend/src/renderer/lib/spawn-orchestrator.ts
@@ -1,19 +1,46 @@
import { apiClient, apiErrorMessage } from "./api-client";
+import { captureRendererEvent } from "./telemetry";
+
+// Every UI entry point that spawns an orchestrator: the board CTA, the topbar
+// and sidebar launchers, the restore-unavailable dialog, and the auto-spawn
+// right after a project is added. Emitting the triad from inside
+// spawnOrchestrator (keyed by source) guarantees each path reports, instead of
+// each call site remembering to instrument itself. Keep in sync with the
+// allowed-source list in telemetry.ts.
+export type OrchestratorSpawnSource =
+ | "board"
+ | "restore_dialog"
+ | "topbar"
+ | "sidebar"
+ | "project_add"
+ | "settings"
+ | "restart";
/** Spawn the project's orchestrator session via the daemon API. When clean is
* true the daemon first tears down any active orchestrator for the project, then
* re-spawns one on the canonical branch (reattaching the existing branch). */
-export async function spawnOrchestrator(projectId: string, clean = false): Promise {
- const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
- body: { projectId, clean },
- });
+export async function spawnOrchestrator(
+ projectId: string,
+ source: OrchestratorSpawnSource,
+ clean = false,
+): Promise {
+ void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source });
+ try {
+ const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
+ body: { projectId, clean },
+ });
- if (error || !data?.orchestrator?.id) {
- const message = error
- ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
- : `Failed to spawn orchestrator (${response.status})`;
- throw new Error(message);
+ if (error || !data?.orchestrator?.id) {
+ const message = error
+ ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
+ : `Failed to spawn orchestrator (${response.status})`;
+ throw new Error(message);
+ }
+
+ void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source });
+ return data.orchestrator.id;
+ } catch (err) {
+ void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source });
+ throw err;
}
-
- return data.orchestrator.id;
}
diff --git a/frontend/src/renderer/lib/telemetry.test.ts b/frontend/src/renderer/lib/telemetry.test.ts
index 34e70d9e3..b14cb1cf5 100644
--- a/frontend/src/renderer/lib/telemetry.test.ts
+++ b/frontend/src/renderer/lib/telemetry.test.ts
@@ -111,4 +111,95 @@ describe("telemetry sanitizers", () => {
"https://api.example.com/endpoint",
);
});
+
+ it("hashes project ids and drops everything else on CTA triads", async () => {
+ const triads = [
+ "ao.renderer.task_create_requested",
+ "ao.renderer.task_create_succeeded",
+ "ao.renderer.task_create_failed",
+ "ao.renderer.session_kill_requested",
+ "ao.renderer.session_kill_succeeded",
+ "ao.renderer.session_kill_failed",
+ "ao.renderer.settings_save_requested",
+ "ao.renderer.settings_save_succeeded",
+ "ao.renderer.settings_save_failed",
+ ];
+ for (const event of triads) {
+ const props = await sanitizeRendererProperties(event, {
+ project_id: "demo-project",
+ title: "raw user text",
+ error: "raw error with /Users/alice/path",
+ });
+ expect(Object.keys(props)).toEqual(["project_id_hash"]);
+ }
+ });
+
+ it("keeps only the source enum on orchestrator_spawn events", async () => {
+ const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_requested", {
+ project_id: "demo-project",
+ source: "board",
+ });
+ expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
+ expect(props.source).toBe("board");
+
+ const badSource = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_failed", {
+ project_id: "demo-project",
+ source: "/Users/alice/private",
+ });
+ expect(badSource).not.toHaveProperty("source");
+ });
+
+ it("keeps every whitelisted spawn source, including topbar/sidebar/project_add/settings/restart", async () => {
+ for (const source of ["board", "restore_dialog", "topbar", "sidebar", "project_add", "settings", "restart"]) {
+ const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_succeeded", {
+ project_id: "demo-project",
+ source,
+ });
+ expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
+ expect(props.source).toBe(source);
+ }
+ });
+
+ it("keeps only enum values on notification events", async () => {
+ expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "pr" })).toEqual({
+ target: "pr",
+ });
+ expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "http://x" })).toEqual({});
+ expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "all" })).toEqual({
+ scope: "all",
+ });
+ expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "everything" })).toEqual(
+ {},
+ );
+ });
+
+ it("whitelists coarse daemon failure fields and drops messages", async () => {
+ const props = await sanitizeRendererProperties("ao.renderer.daemon_failure", {
+ daemon_state: "error",
+ code: "spawn_failed",
+ exit_code: 1,
+ signal: "SIGKILL",
+ message: "spawn /Users/alice/ao failed",
+ });
+ expect(props).toEqual({ daemon_state: "error", code: "spawn_failed", exit_code: 1, signal: "SIGKILL" });
+ });
+
+ it("whitelists normalized api_error fields", async () => {
+ const props = await sanitizeRendererProperties("ao.renderer.api_error", {
+ operation: "GET /api/v1/projects/:id",
+ error_category: "http_5xx",
+ status: 500,
+ body: "raw response body",
+ });
+ expect(props).toEqual({ operation: "GET /api/v1/projects/:id", error_category: "http_5xx", status: 500 });
+ });
+
+ it("keeps only the reason enum on terminal_attach_failed", async () => {
+ expect(await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "open_timeout" })).toEqual({
+ reason: "open_timeout",
+ });
+ expect(
+ await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "something else" }),
+ ).toEqual({});
+ });
});
diff --git a/frontend/src/renderer/lib/telemetry.ts b/frontend/src/renderer/lib/telemetry.ts
index 100c290b5..e71f0bdbb 100644
--- a/frontend/src/renderer/lib/telemetry.ts
+++ b/frontend/src/renderer/lib/telemetry.ts
@@ -128,6 +128,19 @@ async function sanitizeRendererContextProperties(properties?: TelemetryPropertie
return safe;
}
+// Allowed `source` enum for the orchestrator-spawn triad. Kept as a literal set
+// here (rather than imported from spawn-orchestrator.ts, which imports this
+// module) to avoid a cycle; keep in sync with OrchestratorSpawnSource.
+const ORCHESTRATOR_SPAWN_SOURCES = new Set([
+ "board",
+ "restore_dialog",
+ "topbar",
+ "sidebar",
+ "project_add",
+ "settings",
+ "restart",
+]);
+
export async function sanitizeRendererProperties(
event: string,
properties?: TelemetryProperties,
@@ -147,11 +160,52 @@ export async function sanitizeRendererProperties(
break;
case "ao.renderer.project_add_succeeded":
case "ao.renderer.project_removed":
- case "ao.renderer.orchestrator_open_requested": {
+ case "ao.renderer.orchestrator_open_requested":
+ case "ao.renderer.task_create_requested":
+ case "ao.renderer.task_create_succeeded":
+ case "ao.renderer.task_create_failed":
+ case "ao.renderer.session_kill_requested":
+ case "ao.renderer.session_kill_succeeded":
+ case "ao.renderer.session_kill_failed":
+ case "ao.renderer.settings_save_requested":
+ case "ao.renderer.settings_save_succeeded":
+ case "ao.renderer.settings_save_failed": {
const projectIDHash = await hashedTelemetryID(properties?.project_id);
if (projectIDHash) safe.project_id_hash = projectIDHash;
break;
}
+ case "ao.renderer.orchestrator_spawn_requested":
+ case "ao.renderer.orchestrator_spawn_succeeded":
+ case "ao.renderer.orchestrator_spawn_failed": {
+ const projectIDHash = await hashedTelemetryID(properties?.project_id);
+ if (projectIDHash) safe.project_id_hash = projectIDHash;
+ if (typeof properties?.source === "string" && ORCHESTRATOR_SPAWN_SOURCES.has(properties.source)) {
+ safe.source = properties.source;
+ }
+ break;
+ }
+ case "ao.renderer.notification_opened":
+ if (properties?.target === "pr" || properties?.target === "session") safe.target = properties.target;
+ break;
+ case "ao.renderer.notification_marked_read":
+ if (properties?.scope === "single" || properties?.scope === "all") safe.scope = properties.scope;
+ break;
+ case "ao.renderer.daemon_failure":
+ if (typeof properties?.daemon_state === "string") safe.daemon_state = properties.daemon_state;
+ if (typeof properties?.code === "string") safe.code = properties.code;
+ if (typeof properties?.exit_code === "number") safe.exit_code = properties.exit_code;
+ if (typeof properties?.signal === "string") safe.signal = properties.signal;
+ break;
+ case "ao.renderer.api_error":
+ if (typeof properties?.operation === "string") safe.operation = properties.operation;
+ if (typeof properties?.error_category === "string") safe.error_category = properties.error_category;
+ if (typeof properties?.status === "number") safe.status = properties.status;
+ break;
+ case "ao.renderer.terminal_attach_failed":
+ if (properties?.reason === "open_timeout" || properties?.reason === "pane_error") {
+ safe.reason = properties.reason;
+ }
+ break;
}
return safe;
}
diff --git a/frontend/src/renderer/main.tsx b/frontend/src/renderer/main.tsx
index 48ce485a3..a575674db 100644
--- a/frontend/src/renderer/main.tsx
+++ b/frontend/src/renderer/main.tsx
@@ -8,9 +8,11 @@ import { queryClient } from "./lib/query-client";
import { createAppRouter } from "./router";
import { TelemetryBoundary } from "./components/TelemetryBoundary";
import { initTelemetry } from "./lib/telemetry";
+import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry";
const router = createAppRouter(queryClient);
void initTelemetry();
+startDaemonFailureTelemetry();
declare module "@tanstack/react-router" {
interface Register {
diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx
index 8891662b2..9e176122d 100644
--- a/frontend/src/renderer/routes/_shell.tsx
+++ b/frontend/src/renderer/routes/_shell.tsx
@@ -112,7 +112,7 @@ function ShellLayout() {
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
try {
- const sessionId = await spawnOrchestrator(workspace.id);
+ const sessionId = await spawnOrchestrator(workspace.id, "project_add");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css
index eb419dde5..6bffd91d2 100644
--- a/frontend/src/renderer/styles.css
+++ b/frontend/src/renderer/styles.css
@@ -773,6 +773,18 @@ body.is-resizing-x [data-slot="sidebar-container"] {
padding: 18px 18px 40px;
}
+/* Browser tab: the panel fills the rail edge-to-edge, so drop the body padding
+ and the panel's own border/radius so it sits flush against the resize handle. */
+.session-inspector__body.session-inspector__body--browser {
+ padding: 0;
+ overflow: hidden;
+}
+
+.session-inspector__body--browser .browser-panel {
+ border: 0;
+ border-radius: 0;
+}
+
/* Inspector resize handle (shadcn resizable separator, AO visual): a 7px
* transparent grab strip whose centered 1px line lights accent on hover, drag
* (rrp sets data-separator="active"), and keyboard focus. */
diff --git a/frontend/src/shared/daemon-attach.test.ts b/frontend/src/shared/daemon-attach.test.ts
index 12dba66fd..66da84ccc 100644
--- a/frontend/src/shared/daemon-attach.test.ts
+++ b/frontend/src/shared/daemon-attach.test.ts
@@ -174,6 +174,7 @@ describe("resolveDaemonFromRunFile", () => {
executablePath: undefined,
workingDirectory: undefined,
message: "An AO daemon is already running, but it is not ready yet.",
+ code: "not_ready",
});
});
@@ -271,6 +272,7 @@ describe("resolveDaemonFromPort", () => {
executablePath: undefined,
workingDirectory: undefined,
message: "An AO daemon is already running, but it is not ready yet.",
+ code: "not_ready",
});
});
diff --git a/frontend/src/shared/daemon-attach.ts b/frontend/src/shared/daemon-attach.ts
index 31bc702fd..8298c7e60 100644
--- a/frontend/src/shared/daemon-attach.ts
+++ b/frontend/src/shared/daemon-attach.ts
@@ -147,6 +147,7 @@ async function readinessStatus(
executablePath: health.executablePath,
workingDirectory: health.workingDirectory,
message: "An AO daemon is already running, but it is not ready yet.",
+ code: "not_ready",
};
}
@@ -159,6 +160,7 @@ async function readinessStatus(
executablePath: ready.executablePath,
workingDirectory: ready.workingDirectory,
message,
+ code: "identity_mismatch",
};
}
diff --git a/frontend/src/shared/daemon-status.ts b/frontend/src/shared/daemon-status.ts
index 4476d6ffd..679ea5ce1 100644
--- a/frontend/src/shared/daemon-status.ts
+++ b/frontend/src/shared/daemon-status.ts
@@ -1,6 +1,19 @@
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
// Electron main process (which derives it) and the preload bridge (which types
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
+// Machine-readable failure classification for telemetry. `message` is
+// human-facing and may contain local paths; `code` is what gets reported.
+// Statuses without a code (normal ready, user-initiated stop) are not failures.
+export type DaemonFailureCode =
+ | "not_configured"
+ | "daemon_unreachable"
+ | "binary_missing"
+ | "spawn_failed"
+ | "exited"
+ | "port_unconfirmed"
+ | "not_ready"
+ | "identity_mismatch";
+
export type DaemonStatus = {
state: "starting" | "ready" | "stopped" | "error";
port?: number;
@@ -8,4 +21,7 @@ export type DaemonStatus = {
executablePath?: string;
workingDirectory?: string;
message?: string;
+ code?: DaemonFailureCode;
+ exitCode?: number | null;
+ signal?: string | null;
};