+
-
+
{(onImageUpload || onAttachImage) ? (
);
-}
+});
export function IssueChatThread({
comments,
@@ -1604,6 +1792,8 @@ export function IssueChatThread({
includeSucceededRunsWithoutOutput = false,
onInterruptQueued,
interruptingQueuedRunId = null,
+ onImageClick,
+ composerRef,
}: IssueChatThreadProps) {
const location = useLocation();
const hasScrolledRef = useRef(false);
@@ -1731,6 +1921,7 @@ export function IssueChatThread({
onVote,
onInterruptQueued,
interruptingQueuedRunId,
+ onImageClick,
}),
[
feedbackVoteByTargetId,
@@ -1741,6 +1932,7 @@ export function IssueChatThread({
onVote,
onInterruptQueued,
interruptingQueuedRunId,
+ onImageClick,
],
);
@@ -1758,6 +1950,10 @@ export function IssueChatThread({
?? (variant === "embedded"
? "No run output yet."
: "This issue conversation is empty. Start with a message below.");
+ const errorBoundaryResetKey = useMemo(
+ () => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
+ [messages],
+ );
return (
@@ -1775,25 +1971,33 @@ export function IssueChatThread({
) : null}
-
-
-
-
- {resolvedEmptyMessage}
-
-
-
-
-
-
+
+
+
+
+
+ {resolvedEmptyMessage}
+
+
+
+
+
+
+
{showComposer ? (
= {
+ status: "Status",
+ id: "ID",
+ assignee: "Assignee",
+ project: "Project",
+ workspace: "Workspace",
+ parent: "Parent issue",
+ labels: "Tags",
+ updated: "Last updated",
+};
+
+const issueColumnDescriptions: Record = {
+ status: "Issue state chip on the left edge.",
+ id: "Ticket identifier like PAP-1009.",
+ assignee: "Assigned agent or board user.",
+ project: "Linked project pill with its color.",
+ workspace: "Execution or project workspace used for the issue.",
+ parent: "Parent issue identifier and title.",
+ labels: "Issue labels and tags.",
+ updated: "Latest visible activity time.",
+};
+
+export function issueActivityText(issue: Issue): string {
+ return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
+}
+
+function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
+ return columns
+ .map((column) => {
+ if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
+ if (column === "project") return "minmax(6.5rem, 8.5rem)";
+ if (column === "workspace") return "minmax(9rem, 12rem)";
+ if (column === "parent") return "minmax(5rem, 7rem)";
+ if (column === "labels") return "minmax(8rem, 10rem)";
+ return "minmax(4rem, 5.5rem)";
+ })
+ .join(" ");
+}
+
+export function IssueColumnPicker({
+ availableColumns,
+ visibleColumnSet,
+ onToggleColumn,
+ onResetColumns,
+ title,
+}: {
+ availableColumns: InboxIssueColumn[];
+ visibleColumnSet: ReadonlySet;
+ onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
+ onResetColumns: () => void;
+ title: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ Desktop issue rows
+
+
+ {title}
+
+
+
+
+ {availableColumns.map((column) => (
+ event.preventDefault()}
+ onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
+ className="items-start rounded-lg px-3 py-2.5 pl-8"
+ >
+
+
+ {issueColumnLabels[column]}
+
+
+ {issueColumnDescriptions[column]}
+
+
+
+ ))}
+
+
+ Reset defaults
+ status, id, updated
+
+
+
+ );
+}
+
+export function InboxIssueMetaLeading({
+ issue,
+ isLive,
+ showStatus = true,
+ showIdentifier = true,
+ statusSlot,
+}: {
+ issue: Issue;
+ isLive: boolean;
+ showStatus?: boolean;
+ showIdentifier?: boolean;
+ statusSlot?: ReactNode;
+}) {
+ return (
+ <>
+ {showStatus ? (
+
+ {statusSlot ?? }
+
+ ) : null}
+ {showIdentifier ? (
+
+ {issue.identifier ?? issue.id.slice(0, 8)}
+
+ ) : null}
+ {isLive && (
+
+
+
+
+
+
+ Live
+
+
+ )}
+ >
+ );
+}
+
+export function InboxIssueTrailingColumns({
+ issue,
+ columns,
+ projectName,
+ projectColor,
+ workspaceName,
+ assigneeName,
+ currentUserId,
+ parentIdentifier,
+ parentTitle,
+ assigneeContent,
+}: {
+ issue: Issue;
+ columns: InboxIssueColumn[];
+ projectName: string | null;
+ projectColor: string | null;
+ workspaceName: string | null;
+ assigneeName: string | null;
+ currentUserId: string | null;
+ parentIdentifier: string | null;
+ parentTitle: string | null;
+ assigneeContent?: ReactNode;
+}) {
+ const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
+ const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
+
+ return (
+
+ {columns.map((column) => {
+ if (column === "assignee") {
+ if (assigneeContent) {
+ return {assigneeContent};
+ }
+
+ if (issue.assigneeAgentId) {
+ return (
+
+
+
+ );
+ }
+
+ if (issue.assigneeUserId) {
+ return (
+
+ {userLabel}
+
+ );
+ }
+
+ return (
+
+ Unassigned
+
+ );
+ }
+
+ if (column === "project") {
+ if (projectName) {
+ const accentColor = projectColor ?? "#64748b";
+ return (
+
+
+ {projectName}
+
+ );
+ }
+
+ return (
+
+ No project
+
+ );
+ }
+
+ if (column === "labels") {
+ if ((issue.labels ?? []).length > 0) {
+ return (
+
+ {(issue.labels ?? []).slice(0, 2).map((label) => (
+
+ {label.name}
+
+ ))}
+ {(issue.labels ?? []).length > 2 ? (
+
+ +{(issue.labels ?? []).length - 2}
+
+ ) : null}
+
+ );
+ }
+
+ return ;
+ }
+
+ if (column === "workspace") {
+ if (!workspaceName) {
+ return ;
+ }
+
+ return (
+
+ {workspaceName}
+
+ );
+ }
+
+ if (column === "parent") {
+ if (!issue.parentId) {
+ return ;
+ }
+
+ return (
+
+ {parentIdentifier ? (
+ {parentIdentifier}
+ ) : (
+ Sub-issue
+ )}
+
+ );
+ }
+
+ if (column === "updated") {
+ return (
+
+ {activityText}
+
+ );
+ }
+
+ return null;
+ })}
+
+ );
+}
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
index 4c3a4f72..a1fe7e1c 100644
--- a/ui/src/components/IssueProperties.test.tsx
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -3,6 +3,7 @@
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
+import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -143,6 +144,30 @@ function createIssue(overrides: Partial = {}): Issue {
};
}
+function createExecutionPolicy(overrides: Partial = {}): IssueExecutionPolicy {
+ return {
+ mode: "normal",
+ commentRequired: true,
+ stages: [],
+ ...overrides,
+ };
+}
+
+function createExecutionState(overrides: Partial = {}): IssueExecutionState {
+ return {
+ status: "changes_requested",
+ currentStageId: "stage-1",
+ currentStageIndex: 0,
+ currentStageType: "review",
+ currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
+ returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
+ completedStageIds: [],
+ lastDecisionId: null,
+ lastDecisionOutcome: "changes_requested",
+ ...overrides,
+ };
+}
+
function renderProperties(container: HTMLDivElement, props: ComponentProps) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -201,4 +226,119 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
+
+ it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
+ const onUpdate = vi.fn();
+ const root = renderProperties(container, {
+ issue: createIssue({
+ executionPolicy: createExecutionPolicy({
+ stages: [
+ {
+ id: "review-stage",
+ type: "review",
+ approvalsNeeded: 1,
+ participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
+ },
+ ],
+ }),
+ }),
+ childIssues: [],
+ onUpdate,
+ });
+ await flush();
+
+ const runReviewButton = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent?.includes("Run review now"));
+ expect(runReviewButton).not.toBeUndefined();
+
+ await act(async () => {
+ runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
+
+ act(() => root.unmount());
+ });
+
+ it("shows a run approval action when approval is the next runnable stage", async () => {
+ const root = renderProperties(container, {
+ issue: createIssue({
+ executionPolicy: createExecutionPolicy({
+ stages: [
+ {
+ id: "approval-stage",
+ type: "approval",
+ approvalsNeeded: 1,
+ participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
+ },
+ ],
+ }),
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ });
+ await flush();
+
+ expect(container.textContent).toContain("Run approval now");
+ expect(container.textContent).not.toContain("Run review now");
+
+ act(() => root.unmount());
+ });
+
+ it("keeps the run review action available after changes are requested", async () => {
+ const root = renderProperties(container, {
+ issue: createIssue({
+ status: "in_progress",
+ executionPolicy: createExecutionPolicy({
+ stages: [
+ {
+ id: "review-stage",
+ type: "review",
+ approvalsNeeded: 1,
+ participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
+ },
+ ],
+ }),
+ executionState: createExecutionState(),
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ });
+ await flush();
+
+ expect(container.textContent).toContain("Run review now");
+
+ act(() => root.unmount());
+ });
+
+ it("hides the run action while an execution stage is already pending", async () => {
+ const root = renderProperties(container, {
+ issue: createIssue({
+ status: "in_review",
+ executionPolicy: createExecutionPolicy({
+ stages: [
+ {
+ id: "review-stage",
+ type: "review",
+ approvalsNeeded: 1,
+ participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
+ },
+ ],
+ }),
+ executionState: createExecutionState({
+ status: "pending",
+ currentStageType: "review",
+ lastDecisionOutcome: null,
+ }),
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ });
+ await flush();
+
+ expect(container.textContent).not.toContain("Run review now");
+ expect(container.textContent).not.toContain("Run approval now");
+
+ act(() => root.unmount());
+ });
});
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 937e4628..c6d33ff2 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -309,6 +309,26 @@ export function IssueProperties({
const approverTrigger = approverValues.length > 0
? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")}
: None;
+ const nextRunnableExecutionStage = (() => {
+ if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
+ return issue.executionState.currentStageType;
+ }
+ if (issue.executionState) return null;
+ if (reviewerValues.length > 0) return "review";
+ if (approverValues.length > 0) return "approval";
+ return null;
+ })();
+ const runExecutionButton = (stageType: "review" | "approval") => (
+
+
+
+ );
const currentExecutionLabel = (() => {
if (!issue.executionState?.currentStageType) return null;
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
@@ -846,15 +866,13 @@ export function IssueProperties({
))}
- ) : (
-
None
- )}
+ ) : null}
- {childIssues.length > 0 ? (
- childIssues.map((child) => (
+ {childIssues.length > 0
+ ? childIssues.map((child) => (
))
- ) : (
-
None
- )}
+ : null}
{onAddSubIssue ? (
-
-
- {
- setIssueSearch(e.target.value);
- onSearchChange?.(e.target.value);
- }}
- placeholder="Search issues..."
- className="pl-7 text-xs sm:text-sm"
- aria-label="Search issues"
- />
-
+
{
+ setIssueSearch(nextSearch);
+ onSearchChange?.(nextSearch);
+ }}
+ />
@@ -376,6 +573,14 @@ export function IssuesList({
+ setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
+ title="Choose which issue columns stay visible"
+ />
+
{/* Filter */}
@@ -605,6 +810,8 @@ export function IssuesList({
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
+ ["workspace", "Workspace"],
+ ["parent", "Parent Issue"],
["none", "None"],
] as const).map(([value, label]) => (
diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
new file mode 100644
index 00000000..937292ad
--- /dev/null
+++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
@@ -0,0 +1,101 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+interface ShortcutEntry {
+ keys: string[];
+ label: string;
+}
+
+interface ShortcutSection {
+ title: string;
+ shortcuts: ShortcutEntry[];
+}
+
+const sections: ShortcutSection[] = [
+ {
+ title: "Inbox",
+ shortcuts: [
+ { keys: ["j"], label: "Move down" },
+ { keys: ["k"], label: "Move up" },
+ { keys: ["Enter"], label: "Open selected item" },
+ { keys: ["a"], label: "Archive item" },
+ { keys: ["y"], label: "Archive item" },
+ { keys: ["r"], label: "Mark as read" },
+ { keys: ["U"], label: "Mark as unread" },
+ ],
+ },
+ {
+ title: "Issue detail",
+ shortcuts: [
+ { keys: ["y"], label: "Quick-archive back to inbox" },
+ { keys: ["g", "i"], label: "Go to inbox" },
+ { keys: ["g", "c"], label: "Focus comment composer" },
+ ],
+ },
+ {
+ title: "Global",
+ shortcuts: [
+ { keys: ["c"], label: "New issue" },
+ { keys: ["["], label: "Toggle sidebar" },
+ { keys: ["]"], label: "Toggle panel" },
+ { keys: ["?"], label: "Show keyboard shortcuts" },
+ ],
+ },
+];
+
+function KeyCap({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function KeyboardShortcutsCheatsheet({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index be39adb7..b5809f46 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -12,6 +12,7 @@ import { NewIssueDialog } from "./NewIssueDialog";
import { NewProjectDialog } from "./NewProjectDialog";
import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
+import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
@@ -32,6 +33,7 @@ import {
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
+import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
@@ -69,6 +71,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState
(() => readRememberedInstanceSettingsPath());
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -151,6 +154,7 @@ export function Layout() {
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
+ onShowShortcuts: () => setShortcutsOpen(true),
});
useEffect(() => {
@@ -265,6 +269,12 @@ export function Layout() {
}
}, [location.hash, location.pathname, location.search]);
+ useEffect(() => {
+ if (typeof document === "undefined") return;
+ const mainContent = document.getElementById("main-content");
+ return scheduleMainContentFocus(mainContent);
+ }, [location.pathname]);
+
return (
@@ -443,6 +453,7 @@ export function Layout() {
+
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index b5cc12d7..a4542607 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -11,6 +11,8 @@ interface MarkdownBodyProps {
style?: React.CSSProperties;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
+ /** Called when a user clicks an inline image */
+ onImageClick?: (src: string) => void;
}
let mermaidLoaderPromise: Promise | null = null;
@@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
-export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
+export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
@@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma
);
},
};
- if (resolveImageSrc) {
+ if (resolveImageSrc || onImageClick) {
components.img = ({ node: _node, src, alt, ...imgProps }) => {
- const resolved = src ? resolveImageSrc(src) : null;
- return
;
+ const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
+ const finalSrc = resolved ?? src;
+ return (
+
{ e.preventDefault(); onImageClick(finalSrc); } : undefined}
+ style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
+ />
+ );
};
}
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index e8c8838b..2ed825b5 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -364,6 +364,19 @@ export const MarkdownEditor = forwardRef
return map;
}, [mentions]);
+ const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
+ ref.current = instance;
+ if (!instance) {
+ return;
+ }
+ if (valueRef.current !== latestValueRef.current) {
+ // Re-apply the latest controlled value once MDXEditor exposes its imperative API.
+ echoIgnoreMarkdownRef.current = valueRef.current;
+ instance.setMarkdown(valueRef.current);
+ latestValueRef.current = valueRef.current;
+ }
+ }, []);
+
const filteredMentions = useMemo(() => {
if (!mentionState) return [];
const q = mentionState.query.trim().toLowerCase();
@@ -379,16 +392,6 @@ export const MarkdownEditor = forwardRef
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState, mentions, slashCommands]);
- const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
- ref.current = instance;
- if (instance) {
- const v = valueRef.current;
- echoIgnoreMarkdownRef.current = v;
- instance.setMarkdown(v);
- latestValueRef.current = v;
- }
- }, []);
-
useImperativeHandle(forwardedRef, () => ({
focus: () => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
diff --git a/ui/src/components/WorktreeBanner.tsx b/ui/src/components/WorktreeBanner.tsx
index 6808b2da..e256c406 100644
--- a/ui/src/components/WorktreeBanner.tsx
+++ b/ui/src/components/WorktreeBanner.tsx
@@ -1,7 +1,18 @@
+import { useCallback, useState } from "react";
import { getWorktreeUiBranding } from "../lib/worktree-branding";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
+ const [copied, setCopied] = useState(false);
+
+ const handleCopyName = useCallback(() => {
+ if (!branding) return;
+ navigator.clipboard.writeText(branding.name).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ }, [branding]);
+
if (!branding) return null;
return (
@@ -18,7 +29,14 @@ export function WorktreeBanner() {
Worktree
- {branding.name}
+
+ {copied ? "Copied!" : branding.name}
+
);
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
new file mode 100644
index 00000000..6a5105f0
--- /dev/null
+++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
+
+const { useQueryMock, logMock } = vi.hoisted(() => ({
+ useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
+ logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: useQueryMock,
+}));
+
+vi.mock("../../api/instanceSettings", () => ({
+ instanceSettingsApi: {
+ getGeneral: vi.fn(),
+ },
+}));
+
+vi.mock("../../api/heartbeats", () => ({
+ heartbeatsApi: {
+ log: logMock,
+ },
+}));
+
+vi.mock("../../adapters", () => ({
+ buildTranscript: (chunks: unknown[]) => chunks,
+ getUIAdapter: () => null,
+ onAdapterChange: () => () => {},
+}));
+
+class FakeWebSocket {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSING = 2;
+ static readonly CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+
+ readonly url: string;
+ readyState = FakeWebSocket.CONNECTING;
+ onopen: ((event: Event) => void) | null = null;
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: ((event: Event) => void) | null = null;
+ onclose: ((event: CloseEvent) => void) | null = null;
+ closeCalls: Array<{ code?: number; reason?: string }> = [];
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ close(code?: number, reason?: string) {
+ this.closeCalls.push({ code, reason });
+ this.readyState = FakeWebSocket.CLOSING;
+ }
+
+ triggerOpen() {
+ this.readyState = FakeWebSocket.OPEN;
+ this.onopen?.(new Event("open"));
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("useLiveRunTranscripts", () => {
+ const OriginalWebSocket = globalThis.WebSocket;
+
+ beforeEach(() => {
+ FakeWebSocket.instances = [];
+ useQueryMock.mockClear();
+ logMock.mockClear();
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
+ });
+
+ afterEach(() => {
+ globalThis.WebSocket = OriginalWebSocket;
+ });
+
+ it("waits for a connecting socket to open before closing it during cleanup", async () => {
+ function Harness() {
+ useLiveRunTranscripts({
+ companyId: "company-1",
+ runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
+ });
+ return null;
+ }
+
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(