` the sent-message inline editor portals into. Separate component
+ * so the ref-callback read stays out of {@link ConversationRowContent}: React
+ * Compiler treats a value passed to `ref` as a ref object and refuses to
+ * memoize any component that reads other fields of it during render.
+ */
+function InlineMessageEditorHost({
+ editor,
+}: {
+ editor: ThreadTimelineInlineMessageEditor;
+}) {
+ return (
+
+ );
+}
+
+const ConversationRowContent = memo(function ConversationRowContent({
+ row,
+ showAssistantMessageActions,
+ mobileActionDisplay,
+}: ConversationRowContentProps) {
const {
canSpawnChild,
inlineMessageEditor,
@@ -954,14 +1008,7 @@ function ConversationRow({
inlineMessageEditor !== undefined &&
inlineMessageEditor.messageId === row.id
) {
- return (
-
- );
+ return
;
}
// The narrow, stable message reference plugin actions receive — sourced
// from row fields, never the row object itself.
@@ -1034,9 +1081,7 @@ function ConversationRow({
originKind={originKind}
initiator={row.initiator}
mentions={row.mentions}
- mobileActionDisplay={
- row.id === latestActionableUserMessageId ? "inline" : "overflow"
- }
+ mobileActionDisplay={mobileActionDisplay}
onAddToChat={onSelectionAddToChat}
onEdit={onEdit}
onOpenLink={onOpenLink}
@@ -1102,9 +1147,7 @@ function ConversationRow({
resolveUserAttachmentImageSrc={resolveUserAttachmentImageSrc}
role="assistant"
showActions={showAssistantMessageActions}
- mobileActionDisplay={
- row.id === latestActionableAssistantMessageId ? "inline" : "overflow"
- }
+ mobileActionDisplay={mobileActionDisplay}
sourceSeqEnd={row.sourceSeqEnd}
sourceSeqStart={row.sourceSeqStart}
text={row.text}
@@ -1114,7 +1157,7 @@ function ConversationRow({
workspaceRootPath={workspaceRootPath}
/>
);
-}
+});
function TimelineUnreadDivider({ autoScroll }: TimelineUnreadDividerProps) {
const bottomAnchor = useBottomAnchoredScroll();
diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx
index 91db9feb3f..505c090f12 100644
--- a/apps/app/src/components/ui/app-route-anchor.tsx
+++ b/apps/app/src/components/ui/app-route-anchor.tsx
@@ -3,12 +3,14 @@ import {
useCallback,
useContext,
useEffect,
+ useLayoutEffect,
useMemo,
+ useRef,
type ComponentPropsWithoutRef,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, type NavigateOptions } from "react-router-dom";
import { isRoutePath, resolveRouteHref } from "@/lib/route-paths";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
@@ -16,8 +18,10 @@ export interface RouteNavigationProviderProps {
children: ReactNode;
}
-export interface RouteAnchorProps
- extends Omit
, "href"> {
+export interface RouteAnchorProps extends Omit<
+ ComponentPropsWithoutRef<"a">,
+ "href"
+> {
href: string | undefined;
}
@@ -25,10 +29,43 @@ interface ShouldHandleRouteAnchorClickArgs {
event: ReactMouseEvent;
}
-type RouteNavigate = (path: string) => void;
+export interface RouteNavigateOptions {
+ replace?: boolean;
+ state?: NavigateOptions["state"];
+}
+
+/** Navigate to an absolute app route (`/projects/...`); see {@link useRouteNavigate}. */
+export type RouteNavigate = (
+ path: string,
+ options?: RouteNavigateOptions,
+) => void;
const RouteNavigationContext = createContext(null);
+/**
+ * A `navigate` whose identity never changes and whose caller does not
+ * subscribe to the router's location.
+ *
+ * Under `` react-router's `useNavigate()` reads `useLocation()`
+ * and rebuilds its function per pathname, so every component that calls it
+ * re-renders on every navigation and every callback listing it as a
+ * dependency is rebuilt. Sidebar rows, the thread-actions context and the fork
+ * handler only navigate to absolute app routes, so they read this one stable
+ * function from {@link RouteNavigationProvider} (mounted once at the app root,
+ * which holds the live `useNavigate()` in a ref) instead. Without a provider
+ * the returned function throws when called, so a misplaced consumer fails at
+ * the click, not silently.
+ */
+export function useRouteNavigate(): RouteNavigate {
+ return useContext(RouteNavigationContext) ?? navigateWithoutProvider;
+}
+
+function navigateWithoutProvider(path: string): void {
+ throw new Error(
+ `useRouteNavigate: no above the caller (navigating to "${path}")`,
+ );
+}
+
function currentOrigin(): string | null {
return typeof window === "undefined" ? null : window.location.origin;
}
@@ -55,12 +92,21 @@ export function RouteNavigationProvider({
children,
}: RouteNavigationProviderProps) {
const navigate = useNavigate();
- const navigateRoute = useCallback(
- (path) => {
- navigate(path);
- },
- [navigate],
- );
+ // The live `navigate` changes per pathname; the context value must not, or
+ // every consumer would re-render per navigation (the thing this exists to
+ // avoid). Layout effect: the ref is current before any child effect or
+ // event handler can navigate after a commit.
+ const navigateRef = useRef(navigate);
+ useLayoutEffect(() => {
+ navigateRef.current = navigate;
+ }, [navigate]);
+ const navigateRoute = useCallback((path, options) => {
+ if (options === undefined) {
+ navigateRef.current(path);
+ return;
+ }
+ navigateRef.current(path, options);
+ }, []);
useEffect(() => {
const browserApi = getDesktopBrowserApi();
if (browserApi === null) {
diff --git a/apps/app/src/components/ui/sidebar.test.tsx b/apps/app/src/components/ui/sidebar.test.tsx
index ca48987e11..8d0a4be9ad 100644
--- a/apps/app/src/components/ui/sidebar.test.tsx
+++ b/apps/app/src/components/ui/sidebar.test.tsx
@@ -7,6 +7,7 @@ import {
render,
screen,
} from "@testing-library/react";
+import { memo } from "react";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport";
@@ -15,7 +16,9 @@ import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
+ useIsSidebarShowing,
useOptionalIsSidebarShowing,
+ useSidebar,
} from "./sidebar";
afterEach(() => {
@@ -135,6 +138,70 @@ describe("useOptionalIsSidebarShowing", () => {
});
});
+describe("useIsSidebarShowing", () => {
+ it("re-renders its reader only when the visible bit flips, not on every provider commit", () => {
+ vi.useFakeTimers();
+ const showingRenders: boolean[] = [];
+ const ShowingReader = memo(function ShowingReader() {
+ const isShowing = useIsSidebarShowing();
+ showingRenders.push(isShowing);
+ return {String(isShowing)} ;
+ });
+ function Controls() {
+ const {
+ openMobileSidebar,
+ closeMobileSidebar,
+ setSuppressMobileOpenAnimation,
+ } = useSidebar();
+ return (
+ <>
+
+ open
+
+
+ close
+
+ setSuppressMobileOpenAnimation(true)}
+ >
+ suppress
+
+ >
+ );
+ }
+ render(
+
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId("showing").textContent).toBe("false");
+ const settled = showingRenders.length;
+
+ // A provider commit that changes the full context object but not the
+ // visible bit (page header and retained secondary panel read only the bit).
+ fireEvent.click(screen.getByRole("button", { name: "suppress" }));
+ expect(showingRenders).toHaveLength(settled);
+
+ fireEvent.click(screen.getByRole("button", { name: "open" }));
+ settleMobileToggle();
+ expect(screen.getByTestId("showing").textContent).toBe("true");
+ const afterOpen = showingRenders.length;
+ expect(afterOpen).toBe(settled + 1);
+
+ // Close: the closing-flag commit must not reach the reader; only the
+ // deferred openMobile flip does.
+ fireEvent.click(screen.getByRole("button", { name: "close" }));
+ expect(showingRenders).toHaveLength(afterOpen);
+ settleMobileToggle();
+ expect(screen.getByTestId("showing").textContent).toBe("false");
+ expect(showingRenders).toHaveLength(afterOpen + 1);
+ });
+});
+
describe("SidebarTrigger", () => {
it("uses the shared sidebar icon on every viewport", () => {
const markup = renderToString(
diff --git a/apps/app/src/components/ui/sidebar.tsx b/apps/app/src/components/ui/sidebar.tsx
index 62de2d826b..bab801da53 100644
--- a/apps/app/src/components/ui/sidebar.tsx
+++ b/apps/app/src/components/ui/sidebar.tsx
@@ -442,6 +442,16 @@ type SidebarContext = {
const SidebarContext = React.createContext(null);
+/**
+ * "Is the sidebar visible" as its own boolean context. The full
+ * {@link SidebarContext} value changes on every provider commit (the mobile
+ * close flips the closing flag, then four states), and its readers include
+ * the page header and the retained secondary panel, whose ~1000-line bodies
+ * only need this one bit. A boolean context re-renders them only when the
+ * bit flips. `null` outside a provider.
+ */
+const SidebarShowingContext = React.createContext(null);
+
const SidebarContentElementContext =
React.createContext | null>(null);
@@ -465,19 +475,18 @@ function useSidebar() {
return context;
}
-function useIsSidebarShowing() {
- const { state, isCompactViewport, openMobile } = useSidebar();
- return isCompactViewport ? openMobile : state === "expanded";
+function useIsSidebarShowing(): boolean {
+ const isShowing = React.useContext(SidebarShowingContext);
+ if (isShowing === null) {
+ throw new Error(
+ "useIsSidebarShowing must be used within a SidebarProvider.",
+ );
+ }
+ return isShowing;
}
-function useOptionalIsSidebarShowing() {
- const context = React.useContext(SidebarContext);
- if (context === null) {
- return null;
- }
- return context.isCompactViewport
- ? context.openMobile
- : context.state === "expanded";
+function useOptionalIsSidebarShowing(): boolean | null {
+ return React.useContext(SidebarShowingContext);
}
/**
@@ -702,35 +711,39 @@ const SidebarProvider = React.forwardRef<
],
);
+ const isSidebarShowing = isCompactViewport ? openMobile : open;
+
return (
- {/* Match the agent message action bar's tooltip timing (300ms open
+
+ {/* Match the agent message action bar's tooltip timing (300ms open
delay + Radix's default skip window) so sidebar icon tooltips feel
the same instead of flashing instantly on hover. disableHoverableContent
dismisses the tooltip the moment the pointer leaves the trigger, so it
never lingers/floats while the mouse moves on. */}
-
-
- {children}
-
-
+
+
+ {children}
+
+
+
);
},
@@ -1079,9 +1092,8 @@ const SidebarMobilePanel = React.forwardRef<
// assistive-technology activation leave the trigger focus-visible, so
// keep the modal focus move for those paths.
const shouldMoveFocus =
- previouslyFocused?.matches(
- '[data-sidebar="trigger"]:focus-visible',
- ) ?? false;
+ previouslyFocused?.matches('[data-sidebar="trigger"]:focus-visible') ??
+ false;
if (shouldMoveFocus) {
panelRef.current?.focus({ preventScroll: true });
}
@@ -1129,7 +1141,9 @@ const SidebarMobilePanel = React.forwardRef<
for (let step = 1; step <= stops.length; step += 1) {
const nextIndex =
activeIndex === -1
- ? (event.shiftKey ? stops.length - step : step - 1)
+ ? event.shiftKey
+ ? stops.length - step
+ : step - 1
: (((activeIndex + direction * step) % stops.length) +
stops.length) %
stops.length;
@@ -1145,7 +1159,10 @@ const SidebarMobilePanel = React.forwardRef<
return () => {
window.removeEventListener("keydown", handleKeyDown);
const active = document.activeElement;
- if (active instanceof HTMLElement && panelRef.current?.contains(active)) {
+ if (
+ active instanceof HTMLElement &&
+ panelRef.current?.contains(active)
+ ) {
active.blur();
if (shouldMoveFocus) {
previouslyFocused?.focus({ preventScroll: true });
@@ -1228,7 +1245,8 @@ const SidebarMobilePanel = React.forwardRef<
const nowMs = Date.now();
const elapsedMs = nowMs - session.lastTimeMs;
if (elapsedMs > 0) {
- session.velocityX = ((clientX - session.lastClientX) / elapsedMs) * 1000;
+ session.velocityX =
+ ((clientX - session.lastClientX) / elapsedMs) * 1000;
session.lastClientX = clientX;
session.lastTimeMs = nowMs;
}
@@ -1340,10 +1358,7 @@ const SidebarMobilePanel = React.forwardRef<
const handleMove = (moveEvent: PointerEvent) => {
const session = dragSessionRef.current;
- if (
- session?.kind !== "pointer" ||
- moveEvent.pointerId !== session.id
- ) {
+ if (session?.kind !== "pointer" || moveEvent.pointerId !== session.id) {
return;
}
continuePanelDrag(moveEvent.clientX, moveEvent.clientY, moveEvent);
@@ -1377,9 +1392,7 @@ const SidebarMobilePanel = React.forwardRef<
removeDragListenersRef.current = removeListeners;
};
- const handlePanelTouchStart = (
- event: React.TouchEvent,
- ) => {
+ const handlePanelTouchStart = (event: React.TouchEvent) => {
onTouchStart?.(event);
if (
!open ||
diff --git a/apps/app/src/hooks/queries/child-thread-pending-interactions.hook.test.tsx b/apps/app/src/hooks/queries/child-thread-pending-interactions.hook.test.tsx
new file mode 100644
index 0000000000..73cfb21ee7
--- /dev/null
+++ b/apps/app/src/hooks/queries/child-thread-pending-interactions.hook.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+
+import type { ReactNode } from "react";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { PendingInteraction } from "@bb/domain";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ EMPTY_CHILD_THREAD_PENDING_ATTENTION,
+ useChildThreadPendingAttention,
+ type ChildThreadPendingAttentionSource,
+} from "./child-thread-pending-interactions";
+
+const mocks = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
+
+vi.mock("@/lib/sdk", () => ({
+ sdk: { threads: { interactions: { list: mocks.list } } },
+}));
+
+function makeApproval(id: string, createdAt: number): PendingInteraction {
+ return {
+ id,
+ threadId: "thr_child",
+ turnId: "turn_1",
+ providerId: "codex",
+ providerThreadId: "provider-thread",
+ providerRequestId: `request-${id}`,
+ origin: {
+ kind: "provider",
+ providerId: "codex",
+ providerThreadId: "provider-thread",
+ providerRequestId: `request-${id}`,
+ },
+ status: "pending",
+ resolution: null,
+ statusReason: null,
+ createdAt,
+ resolvedAt: null,
+ payload: {
+ kind: "approval",
+ subject: {
+ kind: "command",
+ itemId: "item_cmd",
+ command: "ls",
+ cwd: "/tmp",
+ actions: [],
+ sessionGrant: null,
+ },
+ reason: "Run a command",
+ availableDecisions: ["allow_once", "deny"],
+ },
+ };
+}
+
+function renderAttention(
+ children: readonly ChildThreadPendingAttentionSource[],
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const wrapper = ({ children: node }: { children: ReactNode }) => (
+ {node}
+ );
+ return renderHook(
+ ({ items }: { items: readonly ChildThreadPendingAttentionSource[] }) =>
+ useChildThreadPendingAttention(items),
+ { initialProps: { items: children }, wrapper },
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("useChildThreadPendingAttention", () => {
+ it("returns the shared empty array when no child needs attention", () => {
+ const children = [
+ {
+ id: "thr_working",
+ title: "Run tests",
+ href: "/threads/thr_working",
+ hasPendingInteraction: false,
+ },
+ ];
+ const { result, rerender } = renderAttention(children);
+ expect(result.current).toBe(EMPTY_CHILD_THREAD_PENDING_ATTENTION);
+ // ThreadDetailView re-renders constantly; the value it memoizes the prompt
+ // stack on must not change identity when nothing changed.
+ rerender({ items: children });
+ expect(result.current).toBe(EMPTY_CHILD_THREAD_PENDING_ATTENTION);
+ expect(mocks.list).not.toHaveBeenCalled();
+ });
+
+ it("keeps the same array across re-renders while the child interactions are unchanged", async () => {
+ mocks.list.mockResolvedValue([makeApproval("pi_1", 10)]);
+ const children = [
+ {
+ id: "thr_blocked",
+ title: "Install tools",
+ href: "/threads/thr_blocked",
+ hasPendingInteraction: true,
+ },
+ ];
+ const { result, rerender } = renderAttention(children);
+ await waitFor(() => {
+ expect(result.current).toHaveLength(1);
+ });
+ const settled = result.current;
+ expect(settled[0]?.interaction.id).toBe("pi_1");
+
+ rerender({ items: children });
+ rerender({ items: children });
+ expect(result.current).toBe(settled);
+ });
+});
diff --git a/apps/app/src/hooks/queries/child-thread-pending-interactions.ts b/apps/app/src/hooks/queries/child-thread-pending-interactions.ts
index 743bd440cf..e99ff4fa3b 100644
--- a/apps/app/src/hooks/queries/child-thread-pending-interactions.ts
+++ b/apps/app/src/hooks/queries/child-thread-pending-interactions.ts
@@ -1,4 +1,4 @@
-import { useQueries } from "@tanstack/react-query";
+import { useQueries, type UseQueryResult } from "@tanstack/react-query";
import { useMemo } from "react";
import type { PendingInteraction } from "@bb/domain";
import { sdk } from "@/lib/sdk";
@@ -20,13 +20,21 @@ export interface ChildThreadPendingAttention {
interaction: PendingInteraction;
}
+/**
+ * Shared "nothing pending" result. `ThreadDetailView` feeds the hook's return
+ * value into the prompt-area props (`promptStack`), which are memoized on it;
+ * a fresh empty array per render would invalidate them on every view render.
+ */
+export const EMPTY_CHILD_THREAD_PENDING_ATTENTION: readonly ChildThreadPendingAttention[] =
+ Object.freeze([]);
+
export function collectChildThreadPendingAttention(
children: readonly ChildThreadPendingAttentionSource[],
interactionsByThreadId: ReadonlyMap<
string,
readonly PendingInteraction[] | undefined
>,
-): ChildThreadPendingAttention[] {
+): readonly ChildThreadPendingAttention[] {
const items: ChildThreadPendingAttention[] = [];
for (const child of children) {
if (!child.hasPendingInteraction) {
@@ -45,12 +53,31 @@ export function collectChildThreadPendingAttention(
interaction,
});
}
- return items;
+ return items.length === 0 ? EMPTY_CHILD_THREAD_PENDING_ATTENTION : items;
+}
+
+const EMPTY_INTERACTION_LISTS: readonly (
+ | readonly PendingInteraction[]
+ | undefined
+)[] = Object.freeze([]);
+
+/**
+ * Module-level so its identity is stable: TanStack only structurally shares
+ * a `useQueries` result across renders when `combine` keeps its reference,
+ * and it re-runs an inline one every render. Without it the hook returned a
+ * new array per render.
+ */
+function combinePendingInteractionLists(
+ results: UseQueryResult[],
+): readonly (readonly PendingInteraction[] | undefined)[] {
+ return results.length === 0
+ ? EMPTY_INTERACTION_LISTS
+ : results.map((result) => result.data);
}
export function useChildThreadPendingAttention(
children: readonly ChildThreadPendingAttentionSource[],
-): ChildThreadPendingAttention[] {
+): readonly ChildThreadPendingAttention[] {
// Thread-list realtime already flips `hasPendingInteraction`. Resolving
// from the parent invalidates the interaction query. Do not subscribe to
// each child detail stream.
@@ -62,7 +89,7 @@ export function useChildThreadPendingAttention(
[children],
);
- const queries = useQueries({
+ const interactionLists = useQueries({
queries: pendingChildIds.map((threadId) => ({
queryKey: threadPendingInteractionsQueryKey(threadId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
@@ -74,15 +101,16 @@ export function useChildThreadPendingAttention(
refetchOnMount: true,
...REALTIME_OWNED_NO_FOCUS_QUERY_POLICY,
})),
+ combine: combinePendingInteractionLists,
});
const interactionsByThreadId = useMemo(() => {
const next = new Map();
pendingChildIds.forEach((threadId, index) => {
- next.set(threadId, queries[index]?.data);
+ next.set(threadId, interactionLists[index]);
});
return next;
- }, [pendingChildIds, queries]);
+ }, [pendingChildIds, interactionLists]);
return useMemo(
() => collectChildThreadPendingAttention(children, interactionsByThreadId),
diff --git a/apps/app/src/hooks/useCreateThreadInWorktree.ts b/apps/app/src/hooks/useCreateThreadInWorktree.ts
index 02967e5ee1..c5b3bd3607 100644
--- a/apps/app/src/hooks/useCreateThreadInWorktree.ts
+++ b/apps/app/src/hooks/useCreateThreadInWorktree.ts
@@ -1,5 +1,5 @@
import { useCallback } from "react";
-import { useNavigate } from "react-router-dom";
+import { useRouteNavigate } from "@/components/ui/app-route-anchor";
import { getRootComposeRoutePath } from "@/lib/route-paths";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
@@ -17,7 +17,7 @@ export function useCreateThreadInWorktree({
projectId,
environmentId,
}: UseCreateThreadInWorktreeArgs): () => void {
- const navigate = useNavigate();
+ const navigate = useRouteNavigate();
const setRootComposeProjectId = useSetRootComposeProjectId();
return useCallback(() => {
setRootComposeProjectId(projectId);
diff --git a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
index c7ad96c86a..8310585c33 100644
--- a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
+++ b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
import type { Thread } from "@bb/domain";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -8,12 +9,31 @@ import {
type ForkThreadCreateSeed,
} from "@/lib/fork-thread-request";
import { getRootComposeRoutePath } from "@/lib/route-paths";
+import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
import { useForkThreadFromMessage } from "./useForkThreadFromMessage";
const mocks = vi.hoisted(() => ({
fetchQuery: vi.fn(),
navigate: vi.fn(),
setRootComposeProjectId: vi.fn(),
+ queryClient: {
+ fetchQuery: (...args: unknown[]) => mocks.fetchQuery(...args),
+ // findCachedProviderInfo scans cached execution-options responses for
+ // the source provider's fork capability.
+ getQueriesData: () => [
+ [
+ ["systemExecutionOptions"],
+ {
+ providers: [
+ {
+ id: "codex",
+ capabilities: { supportsFork: true },
+ },
+ ],
+ },
+ ],
+ ],
+ },
}));
vi.mock("react-router-dom", async (importOriginal) => {
@@ -28,24 +48,8 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- useQueryClient: () => ({
- fetchQuery: mocks.fetchQuery,
- // findCachedProviderInfo scans cached execution-options responses for
- // the source provider's fork capability.
- getQueriesData: () => [
- [
- ["systemExecutionOptions"],
- {
- providers: [
- {
- id: "codex",
- capabilities: { supportsFork: true },
- },
- ],
- },
- ],
- ],
- }),
+ // One stable client per test run, like the real provider hands out.
+ useQueryClient: () => mocks.queryClient,
};
});
@@ -84,6 +88,10 @@ afterEach(() => {
vi.clearAllMocks();
});
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
describe("useForkThreadFromMessage", () => {
it("opens the root composer with the source thread display title in the fork seed", async () => {
mocks.fetchQuery.mockResolvedValue({
@@ -93,10 +101,12 @@ describe("useForkThreadFromMessage", () => {
serviceTier: "fast",
});
- const { result } = renderHook(() =>
- useForkThreadFromMessage({
- sourceThread: makeThread(),
- }),
+ const { result } = renderHook(
+ () =>
+ useForkThreadFromMessage({
+ sourceThread: makeThread(),
+ }),
+ { wrapper: Wrapper },
);
await act(async () => {
@@ -130,4 +140,34 @@ describe("useForkThreadFromMessage", () => {
sourceThreadTitle: "Fallback fork title",
});
});
+ it("keeps one handler identity across thread refetches and reads the latest thread", async () => {
+ mocks.fetchQuery.mockResolvedValue({
+ model: "gpt-5",
+ permissionMode: "accept-edits",
+ reasoningLevel: "high",
+ serviceTier: "fast",
+ });
+ const { result, rerender } = renderHook(
+ ({ sourceThread }: { sourceThread: Thread | null }) =>
+ useForkThreadFromMessage({ sourceThread }),
+ { initialProps: { sourceThread: makeThread() }, wrapper: Wrapper },
+ );
+ const first = result.current;
+
+ // A refetch hands the hook a new thread object (same id, new title): the
+ // handler feeds the timeline static context, so its identity must hold.
+ rerender({ sourceThread: makeThread({ title: "Renamed source" }) });
+ expect(result.current).toBe(first);
+
+ await act(async () => {
+ await first({ sourceSeqEnd: 3 });
+ });
+ const navigateState = mocks.navigate.mock.calls[0]?.[1]?.state as
+ | Record
+ | undefined;
+ const seed = navigateState?.[FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY] as
+ | ForkThreadCreateSeed
+ | undefined;
+ expect(seed?.sourceThreadTitle).toBe("Renamed source");
+ });
});
diff --git a/apps/app/src/hooks/useForkThreadFromMessage.ts b/apps/app/src/hooks/useForkThreadFromMessage.ts
index 85f65a979e..30c84c1ce2 100644
--- a/apps/app/src/hooks/useForkThreadFromMessage.ts
+++ b/apps/app/src/hooks/useForkThreadFromMessage.ts
@@ -1,5 +1,4 @@
-import { useCallback, useRef } from "react";
-import { useNavigate } from "react-router-dom";
+import { useCallback, useLayoutEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Thread } from "@bb/domain";
import { sdk } from "@/lib/sdk";
@@ -13,6 +12,7 @@ import { getThreadDisplayTitle } from "@/lib/thread-title";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { threadDefaultExecutionOptionsQueryKey } from "@/hooks/queries/query-keys";
import { findCachedProviderInfo } from "@/hooks/queries/system-queries";
+import { useRouteNavigate } from "@/components/ui/app-route-anchor";
export interface UseForkThreadFromMessageArgs {
/** Source thread the fork branches from. Null until the thread loads. */
@@ -23,65 +23,79 @@ export interface ForkThreadFromMessageTarget {
sourceSeqEnd: number;
}
+/**
+ * Returns a handler whose identity is stable for the lifetime of the caller.
+ * It reads the source thread from a ref at click time: the handler feeds the
+ * timeline's static context, so a new identity per thread-detail refetch
+ * would re-render every mounted message row.
+ */
export function useForkThreadFromMessage({
sourceThread,
}: UseForkThreadFromMessageArgs): (
target: ForkThreadFromMessageTarget,
) => Promise {
- const navigate = useNavigate();
+ const navigate = useRouteNavigate();
const queryClient = useQueryClient();
const setRootComposeProjectId = useSetRootComposeProjectId();
const forkInFlightRef = useRef(false);
+ const sourceThreadRef = useRef(sourceThread);
+ useLayoutEffect(() => {
+ sourceThreadRef.current = sourceThread;
+ }, [sourceThread]);
- return useCallback(async (target: ForkThreadFromMessageTarget) => {
- if (
- sourceThread === null ||
- !isThreadForkable(
- sourceThread,
- findCachedProviderInfo(queryClient, sourceThread.providerId)
- ?.capabilities.supportsFork ?? false,
- ) ||
- forkInFlightRef.current
- ) {
- return;
- }
-
- forkInFlightRef.current = true;
- try {
- const executionOptions = await queryClient.fetchQuery({
- queryKey: threadDefaultExecutionOptionsQueryKey(sourceThread.id),
- queryFn: ({ signal }) =>
- sdk.threads.defaultExecutionOptions({
- signal,
- threadId: sourceThread.id,
- }),
- });
- if (executionOptions === null || sourceThread.environmentId === null) {
+ return useCallback(
+ async (target: ForkThreadFromMessageTarget) => {
+ const source = sourceThreadRef.current;
+ if (
+ source === null ||
+ !isThreadForkable(
+ source,
+ findCachedProviderInfo(queryClient, source.providerId)?.capabilities
+ .supportsFork ?? false,
+ ) ||
+ forkInFlightRef.current
+ ) {
return;
}
- const seed: ForkThreadCreateSeed = {
- environmentId: sourceThread.environmentId,
- model: executionOptions.model,
- permissionMode: executionOptions.permissionMode,
- projectId: sourceThread.projectId,
- providerId: sourceThread.providerId,
- reasoningLevel: executionOptions.reasoningLevel,
- serviceTier: executionOptions.serviceTier,
- sourceSeqEnd: target.sourceSeqEnd,
- sourceThreadId: sourceThread.id,
- sourceThreadTitle: getThreadDisplayTitle(sourceThread),
- };
- setRootComposeProjectId(sourceThread.projectId);
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- reuseEnvironmentId: sourceThread.environmentId,
- [FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY]: seed,
- },
- });
- } finally {
- forkInFlightRef.current = false;
- }
- }, [navigate, queryClient, setRootComposeProjectId, sourceThread]);
+ forkInFlightRef.current = true;
+ try {
+ const executionOptions = await queryClient.fetchQuery({
+ queryKey: threadDefaultExecutionOptionsQueryKey(source.id),
+ queryFn: ({ signal }) =>
+ sdk.threads.defaultExecutionOptions({
+ signal,
+ threadId: source.id,
+ }),
+ });
+ if (executionOptions === null || source.environmentId === null) {
+ return;
+ }
+
+ const seed: ForkThreadCreateSeed = {
+ environmentId: source.environmentId,
+ model: executionOptions.model,
+ permissionMode: executionOptions.permissionMode,
+ projectId: source.projectId,
+ providerId: source.providerId,
+ reasoningLevel: executionOptions.reasoningLevel,
+ serviceTier: executionOptions.serviceTier,
+ sourceSeqEnd: target.sourceSeqEnd,
+ sourceThreadId: source.id,
+ sourceThreadTitle: getThreadDisplayTitle(source),
+ };
+ setRootComposeProjectId(source.projectId);
+ navigate(getRootComposeRoutePath(), {
+ state: {
+ focusPrompt: true,
+ reuseEnvironmentId: source.environmentId,
+ [FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY]: seed,
+ },
+ });
+ } finally {
+ forkInFlightRef.current = false;
+ }
+ },
+ [navigate, queryClient, setRootComposeProjectId],
+ );
}
diff --git a/apps/app/src/hooks/useLatestRef.ts b/apps/app/src/hooks/useLatestRef.ts
new file mode 100644
index 0000000000..3196208063
--- /dev/null
+++ b/apps/app/src/hooks/useLatestRef.ts
@@ -0,0 +1,22 @@
+import { useRef, type RefObject } from "react";
+
+/**
+ * A ref that always holds the latest `value`, updated during render so event
+ * handlers, async continuations and child callbacks in the same commit read
+ * the current value without being re-created per render.
+ *
+ * Kept in its own hook on purpose: React Compiler refuses to memoize any
+ * function that writes a ref during render, and it also types the assigned
+ * value as a ref, so an inlined `ref.current = value` costs the whole calling
+ * component its memoization (this was one of the reasons
+ * `ThreadDetailPromptArea` compiled to nothing). Only the reads matter to the
+ * caller; do them outside render.
+ */
+export function useLatestRef(value: T): RefObject {
+ const ref = useRef(value);
+ // Deliberate render-time write: this hook exists to keep that write (and the
+ // compiler bailout it costs) out of the calling component.
+ // eslint-disable-next-line react-hooks/refs
+ ref.current = value;
+ return ref;
+}
diff --git a/apps/app/src/hooks/usePromptDraftStorage.test.tsx b/apps/app/src/hooks/usePromptDraftStorage.test.tsx
index 8322aa909d..d120debed0 100644
--- a/apps/app/src/hooks/usePromptDraftStorage.test.tsx
+++ b/apps/app/src/hooks/usePromptDraftStorage.test.tsx
@@ -166,6 +166,59 @@ describe("usePromptDraftStorage", () => {
expect([...result.current.inputThreadIds]).toEqual([]);
});
+ it("does not re-read storage or re-render the batch when a draft edit keeps presence", () => {
+ const projectId = "proj-batch-keystrokes";
+ const threadRefs = Array.from({ length: 30 }, (_, index) => ({
+ id: `thr-batch-${index}`,
+ projectId,
+ }));
+ const composer = getPromptDraftAccessor({
+ kind: "thread",
+ projectId,
+ threadId: "thr-batch-3",
+ });
+ let batchRenders = 0;
+ const { result, rerender } = renderHook(() => {
+ batchRenders += 1;
+ return usePromptDraftInputThreadIds(threadRefs);
+ });
+ act(() => {
+ composer.setDraft({ text: "first", mentions: [], attachments: [] });
+ });
+ expect([...result.current]).toEqual(["thr-batch-3"]);
+
+ const getItem = vi.spyOn(Storage.prototype, "getItem");
+ const presenceReads = () =>
+ getItem.mock.calls.filter(([key]) => String(key).includes(projectId))
+ .length;
+ // The sidebar re-renders for unrelated reasons constantly; the presence
+ // snapshot must come from the cache, not 30 localStorage reads.
+ rerender();
+ expect(presenceReads()).toBe(0);
+
+ // A keystroke in an already-present draft: presence did not flip, so the
+ // batch subscriber is not notified (no sidebar render) and only the edited
+ // key is consulted.
+ const rendersBefore = batchRenders;
+ act(() => {
+ composer.setDraft({
+ text: "first keystroke",
+ mentions: [],
+ attachments: [],
+ });
+ });
+ expect(presenceReads()).toBeLessThanOrEqual(1);
+ expect(batchRenders).toBe(rendersBefore);
+
+ // Clearing flips presence: one notification, one render, one read.
+ act(() => {
+ composer.setDraft({ text: "", mentions: [], attachments: [] });
+ });
+ expect([...result.current]).toEqual([]);
+ expect(batchRenders).toBe(rendersBefore + 1);
+ getItem.mockRestore();
+ });
+
it("uses project-agnostic storage for new-thread prompt contents", () => {
window.localStorage.setItem(
LEGACY_PROJECT_DRAFT_KEY,
diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts
index 09a7f0cf00..01dc987869 100644
--- a/apps/app/src/hooks/usePromptDraftStorage.ts
+++ b/apps/app/src/hooks/usePromptDraftStorage.ts
@@ -483,6 +483,67 @@ interface PromptDraftThreadSubscription {
threadId: string;
}
+function getEmptyPresenceSnapshot(): string {
+ return "";
+}
+
+function readPromptDraftPresenceBit(storageKey: string): "0" | "1" {
+ return isPromptDraftEmpty(readPromptDraft(storageKey)) ? "0" : "1";
+}
+
+/**
+ * Presence bit-string store for one subscription set. `getSnapshot` runs on
+ * every render of the subscribing component (the sidebar), and a change to
+ * one draft notifies once per keystroke; reading localStorage for every
+ * sidebar thread on each of those was N `getItem` calls per keystroke and per
+ * sidebar render. The store caches the joined bits, re-reads only the key
+ * that changed, and stays silent when that key's presence did not flip.
+ */
+function createPromptDraftPresenceStore(
+ subscriptions: readonly PromptDraftThreadSubscription[],
+): {
+ subscribe: (listener: () => void) => () => void;
+ getSnapshot: () => string;
+} {
+ let bits: ("0" | "1")[] | null = null;
+ let snapshot: string | null = null;
+ const refresh = (): string => {
+ bits = subscriptions.map(({ storageKey }) =>
+ readPromptDraftPresenceBit(storageKey),
+ );
+ snapshot = bits.join("");
+ return snapshot;
+ };
+ return {
+ getSnapshot: () => snapshot ?? refresh(),
+ subscribe: (listener) => {
+ // A draft may have flipped between the render that computed `snapshot`
+ // and this subscription; drop the cache so the post-subscribe
+ // `getSnapshot` re-reads instead of returning the stale string.
+ snapshot = null;
+ bits = null;
+ const unsubscribe = subscriptions.map(({ storageKey }, index) =>
+ subscribePromptDraft(storageKey, () => {
+ const bit = readPromptDraftPresenceBit(storageKey);
+ if (bits !== null && bits[index] === bit) return;
+ if (bits === null) {
+ refresh();
+ } else {
+ bits[index] = bit;
+ snapshot = bits.join("");
+ }
+ listener();
+ }),
+ );
+ return () => {
+ for (const stopListening of unsubscribe) {
+ stopListening();
+ }
+ };
+ },
+ };
+}
+
/**
* Subscribes to draft presence for a collection of threads without mounting a
* hook per row. The primitive bit-string snapshot stays referentially stable
@@ -509,30 +570,14 @@ export function usePromptDraftInputThreadIds(
return next;
}, [threads]);
+ const presenceStore = useMemo(
+ () => createPromptDraftPresenceStore(subscriptions),
+ [subscriptions],
+ );
const presenceSnapshot = useSyncExternalStore(
- useCallback(
- (listener) => {
- const unsubscribe = subscriptions.map(({ storageKey }) =>
- subscribePromptDraft(storageKey, listener),
- );
- return () => {
- for (const stopListening of unsubscribe) {
- stopListening();
- }
- };
- },
- [subscriptions],
- ),
- useCallback(
- () =>
- subscriptions
- .map(({ storageKey }) =>
- isPromptDraftEmpty(readPromptDraft(storageKey)) ? "0" : "1",
- )
- .join(""),
- [subscriptions],
- ),
- () => "",
+ presenceStore.subscribe,
+ presenceStore.getSnapshot,
+ getEmptyPresenceSnapshot,
);
return useMemo(() => {
diff --git a/apps/app/src/hooks/useSecondTick.ts b/apps/app/src/hooks/useSecondTick.ts
new file mode 100644
index 0000000000..e96c0b3fe0
--- /dev/null
+++ b/apps/app/src/hooks/useSecondTick.ts
@@ -0,0 +1,44 @@
+import { useSyncExternalStore } from "react";
+
+/**
+ * One 1 Hz ticker shared by every live-duration label. Each label used to own
+ * a `setInterval` plus a state update; with several workflow/background rows
+ * mounted that is many timers firing at slightly different phases, each a
+ * separate render. One interval, one notification per second, and it stops
+ * when the last subscriber leaves.
+ */
+const listeners = new Set<() => void>();
+let lastTickMs = 0;
+let intervalId: ReturnType | null = null;
+
+function tick(): void {
+ lastTickMs = Date.now();
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0) {
+ lastTickMs = Date.now();
+ intervalId = setInterval(tick, 1_000);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && intervalId !== null) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+ };
+}
+
+function getSnapshot(): number {
+ // Before the first subscription there is no ticker; read the clock once so
+ // the initial render is not a stale zero.
+ if (lastTickMs === 0) lastTickMs = Date.now();
+ return lastTickMs;
+}
+
+/** Current time in ms, refreshed once per second while any subscriber is mounted. */
+export function useSecondTick(): number {
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}
diff --git a/apps/app/src/lib/plugin-frontend-load-order.test.ts b/apps/app/src/lib/plugin-frontend-load-order.test.ts
index e6bf7daeff..f12f1c79f5 100644
--- a/apps/app/src/lib/plugin-frontend-load-order.test.ts
+++ b/apps/app/src/lib/plugin-frontend-load-order.test.ts
@@ -68,6 +68,7 @@ function makeDeps(
removeRegistrations: vi.fn(),
warn: vi.fn(),
routePluginId: () => null,
+ beginSlotBatch: () => () => {},
...overrides,
};
}
diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts
index e0823f5fce..bbdaefb61e 100644
--- a/apps/app/src/lib/plugin-frontend-reload.test.ts
+++ b/apps/app/src/lib/plugin-frontend-reload.test.ts
@@ -86,6 +86,7 @@ function makeDeps(initial: PluginFrontendCandidate[] = []) {
resetCrashedSlots: vi.fn(),
setRegistrations: vi.fn(),
removeRegistrations: vi.fn(),
+ beginSlotBatch: () => () => {},
warn: vi.fn(),
routePluginId: () => null,
mountTimeoutMs: undefined as number | undefined,
@@ -154,6 +155,7 @@ describe("reconcilePluginFrontends", () => {
resetCrashedSlots: vi.fn(),
setRegistrations: setPluginSlotRegistrations,
removeRegistrations: removePluginSlotRegistrations,
+ beginSlotBatch: () => () => {},
warn: vi.fn(),
routePluginId: () => null,
};
diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts
index c99be26254..0b1e2a2653 100644
--- a/apps/app/src/lib/plugin-frontend.ts
+++ b/apps/app/src/lib/plugin-frontend.ts
@@ -51,6 +51,7 @@ import { createGatedPierreDiffsReact } from "./plugin-pierre-diffs-react";
import { getPluginPanelRoutePluginId } from "./route-paths";
import { pluginSdkAppImplementation } from "./plugin-sdk-app-impl";
import {
+ beginPluginSlotBatch,
removePluginSlotRegistrations,
setPluginSlotRegistrations,
type PluginRegistrationSet,
@@ -492,6 +493,11 @@ export interface PluginFrontendReconcileDeps {
registrations: PluginRegistrationSet,
) => void;
removeRegistrations: (pluginId: string) => void;
+ /**
+ * Hold slot-store notifications while a reconcile run activates several
+ * plugins; returns the closer. Production binds `beginPluginSlotBatch`.
+ */
+ beginSlotBatch: () => () => void;
warn: (message: string) => void;
/**
* The plugin whose panel is the current route (`/plugins/:pluginId/...`),
@@ -778,6 +784,23 @@ export async function reconcilePluginFrontends(
state.diagnostics.delete(pluginId);
deps.diagnosticsChanged?.();
}
+ // One notification burst per run instead of one per plugin: every
+ // `usePluginSlots` reader (timeline static context, markdown directive
+ // registry, composer customizations, ...) would otherwise re-render once
+ // per bundle as they resolve.
+ const closeSlotBatch = deps.beginSlotBatch();
+ try {
+ await reconcileCandidates(candidates, state, deps);
+ } finally {
+ closeSlotBatch();
+ }
+}
+
+async function reconcileCandidates(
+ candidates: readonly PluginFrontendCandidate[],
+ state: PluginFrontendReconcileState,
+ deps: PluginFrontendReconcileDeps,
+): Promise {
// Bounded, ordered loading: every bundle is a separate parse/eval on the
// main thread and, on a phone, they used to all land during the window in
// which the route chunk itself was still arriving. Three at a time keeps
@@ -1027,6 +1050,13 @@ function publishBrowserDiagnostics(): void {
for (const listener of browserDiagnosticsListeners) listener();
}
+/**
+ * Longest a reconcile run holds slot notifications: bundles that resolve
+ * within this window flush together; a slow bundle cannot keep the others'
+ * UI off screen past it.
+ */
+const PLUGIN_SLOT_BATCH_MAX_HOLD_MS = 150;
+
const browserReconcileDeps: PluginFrontendReconcileDeps = {
fetchCandidates: fetchFrontendCandidates,
importModule: (url) => import(/* @vite-ignore */ url),
@@ -1040,6 +1070,8 @@ const browserReconcileDeps: PluginFrontendReconcileDeps = {
resetCrashedSlots: resetCrashedPluginSlots,
setRegistrations: setPluginSlotRegistrations,
removeRegistrations: removePluginSlotRegistrations,
+ beginSlotBatch: () =>
+ beginPluginSlotBatch({ maxHoldMs: PLUGIN_SLOT_BATCH_MAX_HOLD_MS }),
warn: (message) => console.warn(message),
diagnosticsChanged: publishBrowserDiagnostics,
};
diff --git a/apps/app/src/lib/plugin-sidebar-hooks.test.tsx b/apps/app/src/lib/plugin-sidebar-hooks.test.tsx
new file mode 100644
index 0000000000..78356c8b43
--- /dev/null
+++ b/apps/app/src/lib/plugin-sidebar-hooks.test.tsx
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+
+import { cleanup, renderHook } from "@testing-library/react";
+import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { makeThreadListEntry } from "@/test/fixtures/thread-list-entries";
+import { useSidebarThreads } from "./plugin-sidebar-hooks";
+
+const state = vi.hoisted(() => ({
+ data: undefined as
+ | {
+ sections: never[];
+ projects: { id: string; name: string; threads: ThreadListEntry[] }[];
+ personalProject: {
+ id: string;
+ name: string;
+ threads: ThreadListEntry[];
+ };
+ }
+ | undefined,
+}));
+
+vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
+ useSidebarNavigation: () => ({ data: state.data, isError: false }),
+}));
+
+vi.mock("@/hooks/queries/host-queries", () => {
+ // Stable like the real query result; a fresh array per render would rebuild
+ // the host-name map and (correctly) invalidate every cached DTO.
+ const hosts: never[] = [];
+ return { useHosts: () => ({ data: hosts }) };
+});
+
+function payload(threads: ThreadListEntry[]) {
+ return {
+ sections: [],
+ projects: [{ id: "proj_app", name: "App", threads }],
+ personalProject: { id: PERSONAL_PROJECT_ID, name: "Personal", threads: [] },
+ };
+}
+
+afterEach(() => {
+ cleanup();
+ state.data = undefined;
+});
+
+describe("useSidebarThreads", () => {
+ it("keeps DTO identity for entries that did not change across a sidebar update", () => {
+ const stable = makeThreadListEntry({ id: "thr_stable", title: "Stable" });
+ const changing = makeThreadListEntry({ id: "thr_changing", title: "One" });
+ state.data = payload([stable, changing]);
+ const { result, rerender } = renderHook(() => useSidebarThreads());
+ const before = result.current.threads;
+ expect(before.map((thread) => thread.id)).toEqual([
+ "thr_stable",
+ "thr_changing",
+ ]);
+
+ // React Query structurally shares the payload: a refetch that touched
+ // one thread keeps the other entry objects. Plugin rows memoized on their
+ // thread DTO must get the same object back for the untouched thread.
+ state.data = payload([
+ stable,
+ makeThreadListEntry({ id: "thr_changing", title: "Two" }),
+ ]);
+ rerender();
+ const after = result.current.threads;
+ expect(after).not.toBe(before);
+ expect(after[0]).toBe(before[0]);
+ expect(after[1]).not.toBe(before[1]);
+ expect(after[1]?.title).toBe("Two");
+ });
+
+ it("shares DTO identity between two consumers of the same payload", () => {
+ const stable = makeThreadListEntry({ id: "thr_stable", title: "Stable" });
+ state.data = payload([stable]);
+ const first = renderHook(() => useSidebarThreads());
+ const second = renderHook(() => useSidebarThreads());
+ // Two plugin lists mounted at once (or one list plus the built-in
+ // sidebar's plugin surfaces): each derives the host-name map from the
+ // same hosts payload, so they must not evict each other's cached DTOs.
+ expect(second.result.current.threads[0]).toBe(
+ first.result.current.threads[0],
+ );
+ const before = first.result.current.threads[0];
+ first.rerender();
+ second.rerender();
+ expect(first.result.current.threads[0]).toBe(before);
+ expect(second.result.current.threads[0]).toBe(before);
+ });
+});
diff --git a/apps/app/src/lib/plugin-sidebar-hooks.ts b/apps/app/src/lib/plugin-sidebar-hooks.ts
index e9ed333427..07d2f714ed 100644
--- a/apps/app/src/lib/plugin-sidebar-hooks.ts
+++ b/apps/app/src/lib/plugin-sidebar-hooks.ts
@@ -1,7 +1,10 @@
import { useCallback, useMemo } from "react";
import { useStore } from "jotai";
-import { useNavigate } from "react-router-dom";
-import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain";
+import {
+ PERSONAL_PROJECT_ID,
+ type Host,
+ type ThreadListEntry,
+} from "@bb/domain";
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import type {
PluginSidebarProject,
@@ -19,6 +22,7 @@ import { useHosts } from "@/hooks/queries/host-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
import { useUpdateThread } from "@/hooks/mutations/thread-state-mutations";
import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled";
+import { useRouteNavigate } from "@/components/ui/app-route-anchor";
import { toPluginSidebarThread } from "./plugin-sidebar-threads";
import { useSetRootComposeProjectId } from "./root-compose-selection";
import { openThreadInSplit } from "./split-layout/openThreadInSplit";
@@ -31,6 +35,54 @@ import {
const EMPTY_THREADS: readonly PluginSidebarThread[] = [];
const EMPTY_PROJECTS: readonly PluginSidebarProject[] = [];
const EMPTY_ENTRIES: ReadonlyMap = new Map();
+const EMPTY_HOST_NAMES: ReadonlyMap = new Map();
+
+/**
+ * Host-name map per hosts payload. Module-level (not `useMemo`) so every
+ * `useSidebarThreads` caller derives the same map object from the same React
+ * Query result; a per-hook map would give two plugin lists two keys and make
+ * them evict each other's entries from {@link pluginSidebarThreadByEntry}.
+ */
+const hostNamesByHosts = new WeakMap<
+ readonly Host[],
+ ReadonlyMap
+>();
+
+function hostNamesFor(
+ hosts: readonly Host[] | undefined,
+): ReadonlyMap {
+ if (hosts === undefined) return EMPTY_HOST_NAMES;
+ const cached = hostNamesByHosts.get(hosts);
+ if (cached !== undefined) return cached;
+ const names = new Map(hosts.map((host) => [host.id, host.name] as const));
+ hostNamesByHosts.set(hosts, names);
+ return names;
+}
+
+/**
+ * Per-entry DTO memo. React Query structurally shares the sidebar payload, so
+ * an unchanged `ThreadListEntry` keeps its identity across refetches; mapping
+ * it again produced a fresh DTO per thread per sidebar update, which defeats
+ * `memo`/compiler bailouts in every plugin row. The DTO also depends on the
+ * host-name map, so a cached DTO is reused only for the same map instance.
+ */
+const pluginSidebarThreadByEntry = new WeakMap<
+ ThreadListEntry,
+ { hostNamesById: ReadonlyMap; thread: PluginSidebarThread }
+>();
+
+function toPluginSidebarThreadCached(
+ entry: ThreadListEntry,
+ hostNamesById: ReadonlyMap,
+): PluginSidebarThread {
+ const cached = pluginSidebarThreadByEntry.get(entry);
+ if (cached !== undefined && cached.hostNamesById === hostNamesById) {
+ return cached.thread;
+ }
+ const thread = toPluginSidebarThread(entry, hostNamesById);
+ pluginSidebarThreadByEntry.set(entry, { hostNamesById, thread });
+ return thread;
+}
/**
* The sidebar's live thread view for plugin surfaces.
@@ -49,10 +101,7 @@ export function useSidebarThreads(): PluginSidebarThreadsState {
// The sidebar already subscribes to host updates; this reads the same
// cached list so a row can print a machine name instead of a host id.
const { data: hosts } = useHosts();
- const hostNamesById = useMemo(
- () => new Map((hosts ?? []).map((host) => [host.id, host.name] as const)),
- [hosts],
- );
+ const hostNamesById = hostNamesFor(hosts);
return useMemo(() => {
if (data === undefined) {
@@ -69,7 +118,7 @@ export function useSidebarThreads(): PluginSidebarThreadsState {
status: "ready",
threads: allProjects.flatMap((project) =>
project.threads.map((thread) =>
- toPluginSidebarThread(thread, hostNamesById),
+ toPluginSidebarThreadCached(thread, hostNamesById),
),
),
projects: allProjects.map((project) => ({
@@ -111,7 +160,7 @@ export function useSidebarThreadEntry(
* dead threads.
*/
export function useSidebarThreadActions(): PluginSidebarThreadActions {
- const navigate = useNavigate();
+ const navigate = useRouteNavigate();
const store = useStore();
const isCompact = useIsCompactViewport();
const threadSplitsEnabled = useThreadSplitsEnabled();
diff --git a/apps/app/src/lib/plugin-slots.test.ts b/apps/app/src/lib/plugin-slots.test.ts
index eb8c6c4594..a2a7c33fb9 100644
--- a/apps/app/src/lib/plugin-slots.test.ts
+++ b/apps/app/src/lib/plugin-slots.test.ts
@@ -5,6 +5,7 @@ import type {
PluginNavPanelProps,
} from "@get-bb/plugin-sdk";
import {
+ beginPluginSlotBatch,
getPluginSlotSnapshot,
removePluginSlotRegistrations,
resetPluginSlotStoreForTest,
@@ -242,3 +243,134 @@ describe("plugin slot store", () => {
expect(snapshot.messageDirectives).toHaveLength(0);
});
});
+
+describe("plugin slot store structural sharing", () => {
+ it("keeps the messageActions array identity when a navPanels-only plugin registers", () => {
+ setPluginSlotRegistrations(
+ "actions",
+ registrationSet({
+ messageActions: [{ id: "copy", title: "copy", run: () => {} }],
+ }),
+ );
+ const before = getPluginSlotSnapshot();
+
+ setPluginSlotRegistrations(
+ "board",
+ registrationSet({
+ navPanels: [
+ {
+ id: "board",
+ title: "Board",
+ icon: "columns",
+ path: "board",
+ component: PanelComponent,
+ },
+ ],
+ }),
+ );
+ const after = getPluginSlotSnapshot();
+
+ expect(after).not.toBe(before);
+ expect(after.navPanels).toHaveLength(1);
+ // Kinds the new plugin did not touch keep their arrays, so consumers keyed
+ // on `messageActions`/`messageDirectives` (timeline static context,
+ // markdown directive registry) do not re-render or re-parse.
+ expect(after.messageActions).toBe(before.messageActions);
+ expect(after.messageDirectives).toBe(before.messageDirectives);
+ expect(after.composerCustomizations).toBe(before.composerCustomizations);
+ // Slot objects of untouched plugins keep identity too.
+ expect(after.messageActions[0]).toBe(before.messageActions[0]);
+ });
+
+ it("does not notify when a registration changes nothing visible", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribePluginSlots(listener);
+ const before = getPluginSlotSnapshot();
+ setPluginSlotRegistrations("empty", registrationSet());
+ expect(getPluginSlotSnapshot()).toBe(before);
+ expect(listener).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it("re-registering a plugin replaces only its own kinds' arrays", () => {
+ setPluginSlotRegistrations(
+ "a",
+ registrationSet({
+ messageActions: [{ id: "a-copy", title: "a-copy", run: () => {} }],
+ }),
+ );
+ setPluginSlotRegistrations(
+ "b",
+ registrationSet({
+ messageDirectives: [{ id: "b-vis", component: DirectiveComponent }],
+ }),
+ );
+ const before = getPluginSlotSnapshot();
+ setPluginSlotRegistrations(
+ "b",
+ registrationSet({
+ messageDirectives: [{ id: "b-chart", component: DirectiveComponent }],
+ }),
+ );
+ const after = getPluginSlotSnapshot();
+ expect(after.messageDirectives.map((d) => d.id)).toEqual(["b-chart"]);
+ expect(after.messageDirectives[0]?.generation).toBe(2);
+ expect(after.messageActions).toBe(before.messageActions);
+ });
+});
+
+describe("plugin slot batches", () => {
+ it("holds notifications until the batch closes and then notifies once", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribePluginSlots(listener);
+ const close = beginPluginSlotBatch({ maxHoldMs: 10_000 });
+ setPluginSlotRegistrations(
+ "a",
+ registrationSet({
+ messageActions: [{ id: "a", title: "a", run: () => {} }],
+ }),
+ );
+ setPluginSlotRegistrations(
+ "b",
+ registrationSet({
+ messageActions: [{ id: "b", title: "b", run: () => {} }],
+ }),
+ );
+ expect(listener).not.toHaveBeenCalled();
+ // Reads inside the batch see the current registrations.
+ expect(getPluginSlotSnapshot().messageActions.map((a) => a.id)).toEqual([
+ "a",
+ "b",
+ ]);
+ close();
+ expect(listener).toHaveBeenCalledTimes(1);
+ close();
+ expect(listener).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ });
+
+ it("flushes on the hold timer so a slow plugin cannot starve the others", () => {
+ vi.useFakeTimers();
+ try {
+ const listener = vi.fn();
+ const unsubscribe = subscribePluginSlots(listener);
+ const close = beginPluginSlotBatch({ maxHoldMs: 100 });
+ setPluginSlotRegistrations(
+ "fast",
+ registrationSet({
+ messageActions: [{ id: "fast", title: "fast", run: () => {} }],
+ }),
+ );
+ vi.advanceTimersByTime(99);
+ expect(listener).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(listener).toHaveBeenCalledTimes(1);
+ // Nothing new since the flush: closing does not notify again.
+ close();
+ expect(listener).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts
index 3cc062be24..897d11e76d 100644
--- a/apps/app/src/lib/plugin-slots.ts
+++ b/apps/app/src/lib/plugin-slots.ts
@@ -128,97 +128,105 @@ const generationByPluginId = new Map();
const listeners = new Set<() => void>();
let snapshot: PluginSlotSnapshot = EMPTY_PLUGIN_SLOT_SNAPSHOT;
-function buildSnapshot(): PluginSlotSnapshot {
- const pluginIds = [...registrationsByPluginId.keys()].sort();
- const next: {
- homepageSections: PluginHomepageSectionSlot[];
- settingsSections: PluginSettingsSectionSlot[];
- navPanels: PluginNavPanelSlot[];
- threadPanelActions: PluginThreadPanelActionSlot[];
- newThreadPanelActions: PluginNewThreadPanelActionSlot[];
- composerCustomizations: PluginComposerCustomizationSlot[];
- pendingInteractions: PluginPendingInteractionSlot[];
- sidebarFooterActions: PluginSidebarFooterActionSlot[];
- threadLists: PluginThreadListSlot[];
- threadHeaderActions: PluginThreadHeaderActionSlot[];
- fileOpeners: PluginFileOpenerSlot[];
- messageDirectives: PluginMessageDirectiveSlot[];
- messageActions: PluginMessageActionSlot[];
- providerIcons: PluginProviderIconSlot[];
- } = {
- homepageSections: [],
- settingsSections: [],
- navPanels: [],
- threadPanelActions: [],
- newThreadPanelActions: [],
- composerCustomizations: [],
- pendingInteractions: [],
- sidebarFooterActions: [],
- threadLists: [],
- threadHeaderActions: [],
- fileOpeners: [],
- messageDirectives: [],
- messageActions: [],
- providerIcons: [],
+type SlotKind = keyof PluginSlotSnapshot;
+
+const SLOT_KINDS: readonly SlotKind[] = [
+ "homepageSections",
+ "settingsSections",
+ "navPanels",
+ "threadPanelActions",
+ "newThreadPanelActions",
+ "composerCustomizations",
+ "pendingInteractions",
+ "sidebarFooterActions",
+ "threadLists",
+ "threadHeaderActions",
+ "fileOpeners",
+ "messageDirectives",
+ "messageActions",
+ "providerIcons",
+];
+
+/**
+ * One plugin's registrations flattened into slot objects (`pluginId` +
+ * `generation` baked in). Built once per `setPluginSlotRegistrations` call, so
+ * slot objects keep their identity across later snapshot rebuilds. That is
+ * what lets {@link buildSnapshot} hand back the previous per-kind array when
+ * a different plugin's registrations change: consumers keyed on one kind
+ * (`messageDirectives` -> markdown directive registry, `messageActions` ->
+ * timeline static context) only re-render when their own kind changed.
+ */
+type FlattenedPluginSlots = {
+ readonly [K in SlotKind]: PluginSlotSnapshot[K];
+};
+
+const flattenedByPluginId = new Map();
+
+function flattenRegistrations(
+ pluginId: string,
+ generation: number,
+ set: PluginRegistrationSet,
+): FlattenedPluginSlots {
+ const stamp = (
+ registrations: readonly T[] | undefined,
+ ): readonly (T & PluginSlotBase)[] =>
+ (registrations ?? []).map((registration) => ({
+ ...registration,
+ pluginId,
+ generation,
+ }));
+ return {
+ homepageSections: stamp(set.homepageSections),
+ settingsSections: stamp(set.settingsSections),
+ navPanels: stamp(set.navPanels),
+ threadPanelActions: stamp(set.threadPanelActions),
+ newThreadPanelActions: stamp(set.newThreadPanelActions),
+ composerCustomizations: stamp(set.composerCustomizations),
+ pendingInteractions: stamp(set.pendingInteractions),
+ sidebarFooterActions: stamp(set.sidebarFooterActions),
+ threadLists: stamp(set.threadLists),
+ threadHeaderActions: stamp(set.threadHeaderActions),
+ fileOpeners: stamp(set.fileOpeners),
+ messageDirectives: stamp(set.messageDirectives),
+ messageActions: stamp(set.messageActions),
+ providerIcons: stamp(set.providerIcons),
};
+}
+
+function sameSlotSequence(
+ previous: readonly unknown[],
+ next: readonly unknown[],
+): boolean {
+ if (previous.length !== next.length) return false;
+ for (let index = 0; index < previous.length; index += 1) {
+ if (previous[index] !== next[index]) return false;
+ }
+ return true;
+}
+
+function collectKind(
+ kind: K,
+ pluginIds: readonly string[],
+): PluginSlotSnapshot[K][number][] {
+ const collected: PluginSlotSnapshot[K][number][] = [];
for (const pluginId of pluginIds) {
- const set = registrationsByPluginId.get(pluginId);
- if (set === undefined) continue;
- const generation = generationByPluginId.get(pluginId) ?? 0;
- for (const registration of set.homepageSections) {
- next.homepageSections.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.settingsSections) {
- next.settingsSections.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.navPanels) {
- next.navPanels.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.threadPanelActions) {
- next.threadPanelActions.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.newThreadPanelActions ?? []) {
- next.newThreadPanelActions.push({
- ...registration,
- pluginId,
- generation,
- });
- }
- for (const registration of set.composerCustomizations ?? []) {
- next.composerCustomizations.push({
- ...registration,
- pluginId,
- generation,
- });
- }
- for (const registration of set.pendingInteractions ?? []) {
- next.pendingInteractions.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.sidebarFooterActions) {
- next.sidebarFooterActions.push({
- ...registration,
- pluginId,
- generation,
- });
- }
- for (const registration of set.threadLists ?? []) {
- next.threadLists.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.threadHeaderActions ?? []) {
- next.threadHeaderActions.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.fileOpeners) {
- next.fileOpeners.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.messageDirectives) {
- next.messageDirectives.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.messageActions ?? []) {
- next.messageActions.push({ ...registration, pluginId, generation });
- }
- for (const registration of set.providerIcons ?? []) {
- const claimed = next.providerIcons.find(
- (slot) => slot.providerId === registration.providerId,
+ const flattened = flattenedByPluginId.get(pluginId);
+ if (flattened === undefined) continue;
+ for (const slot of flattened[kind]) collected.push(slot);
+ }
+ return collected;
+}
+
+function collectProviderIcons(
+ pluginIds: readonly string[],
+): PluginProviderIconSlot[] {
+ const collected: PluginProviderIconSlot[] = [];
+ for (const pluginId of pluginIds) {
+ const flattened = flattenedByPluginId.get(pluginId);
+ if (flattened === undefined) continue;
+ for (const slot of flattened.providerIcons) {
+ const claimed = collected.find(
+ (existing) => existing.providerId === slot.providerId,
);
if (claimed !== undefined) {
// Provider ids are a shared namespace: nothing stops a second plugin
@@ -226,30 +234,125 @@ function buildSnapshot(): PluginSlotSnapshot {
// sorted order, so keeping the first claim makes the winner stable
// across reloads instead of depending on load timing.
console.warn(
- `plugin ${pluginId}: provider icon for "${registration.providerId}" ignored — already registered by plugin ${claimed.pluginId}`,
+ `plugin ${pluginId}: provider icon for "${slot.providerId}" ignored — already registered by plugin ${claimed.pluginId}`,
);
continue;
}
- next.providerIcons.push({ ...registration, pluginId, generation });
+ collected.push(slot);
}
}
- return next;
+ return collected;
}
-function emitChange(): void {
- snapshot = buildSnapshot();
+/**
+ * Rebuild the flattened snapshot with per-kind structural sharing: a kind
+ * whose slot sequence equals the previous snapshot's keeps the previous
+ * array, and a rebuild that changes no kind returns the previous snapshot
+ * object so `useSyncExternalStore` readers bail out entirely.
+ */
+function buildSnapshot(previous: PluginSlotSnapshot): PluginSlotSnapshot {
+ const pluginIds = [...registrationsByPluginId.keys()].sort();
+ const next: { -readonly [K in SlotKind]: PluginSlotSnapshot[K] } = {
+ ...previous,
+ };
+ let changed = false;
+ for (const kind of SLOT_KINDS) {
+ const collected =
+ kind === "providerIcons"
+ ? collectProviderIcons(pluginIds)
+ : collectKind(kind, pluginIds);
+ if (sameSlotSequence(previous[kind], collected)) continue;
+ changed = true;
+ // `collected` came from `collectKind(kind)`, so it has the element type
+ // of `next[kind]`; the loop variable erases that correlation for TS.
+ Object.assign(next, { [kind]: collected });
+ }
+ return changed ? next : previous;
+}
+
+/**
+ * Open batches hold listener notifications so a burst of registrations (every
+ * plugin bundle resolving during boot, a multi-plugin reload) turns into a
+ * few flushes instead of one app-wide re-render per plugin. Reads stay
+ * consistent while a batch is open: the snapshot is rebuilt lazily on the
+ * next `getPluginSlotSnapshot`. Batches nest (depth count).
+ */
+let openBatchDepth = 0;
+let batchMaxHoldMs = 0;
+/** Registrations changed since `snapshot` was last built. */
+let snapshotStale = false;
+/** `snapshot` changed since listeners were last notified. */
+let notifyPending = false;
+let batchFlushTimer: ReturnType | null = null;
+
+function rebuildIfStale(): void {
+ if (!snapshotStale) return;
+ snapshotStale = false;
+ const previous = snapshot;
+ snapshot = buildSnapshot(previous);
+ if (snapshot !== previous) notifyPending = true;
+}
+
+function flushChange(): void {
+ if (batchFlushTimer !== null) {
+ clearTimeout(batchFlushTimer);
+ batchFlushTimer = null;
+ }
+ rebuildIfStale();
+ if (!notifyPending) return;
+ notifyPending = false;
for (const listener of listeners) listener();
}
+function emitChange(): void {
+ snapshotStale = true;
+ if (openBatchDepth === 0) {
+ flushChange();
+ return;
+ }
+ if (batchFlushTimer !== null) return;
+ // Bound the hold: a slow plugin bundle must not keep every other plugin's
+ // UI off screen for the whole batch.
+ batchFlushTimer = setTimeout(() => {
+ batchFlushTimer = null;
+ flushChange();
+ }, batchMaxHoldMs);
+}
+
+/**
+ * Hold notifications until the returned closer runs, or until `maxHoldMs`
+ * passes since the first held change (whichever comes first, repeatedly).
+ * The closer is idempotent.
+ */
+export function beginPluginSlotBatch(options: {
+ maxHoldMs: number;
+}): () => void {
+ openBatchDepth += 1;
+ batchMaxHoldMs =
+ openBatchDepth === 1
+ ? options.maxHoldMs
+ : Math.min(batchMaxHoldMs, options.maxHoldMs);
+ let closed = false;
+ return () => {
+ if (closed) return;
+ closed = true;
+ // Never below zero: a test reset can zero the depth under an open batch.
+ openBatchDepth = Math.max(0, openBatchDepth - 1);
+ if (openBatchDepth === 0) flushChange();
+ };
+}
+
/** Replace one plugin's registrations wholesale (P3.4 reload reuses this). */
export function setPluginSlotRegistrations(
pluginId: string,
registrations: PluginRegistrationSet,
): void {
registrationsByPluginId.set(pluginId, registrations);
- generationByPluginId.set(
+ const generation = (generationByPluginId.get(pluginId) ?? 0) + 1;
+ generationByPluginId.set(pluginId, generation);
+ flattenedByPluginId.set(
pluginId,
- (generationByPluginId.get(pluginId) ?? 0) + 1,
+ flattenRegistrations(pluginId, generation, registrations),
);
emitChange();
}
@@ -257,6 +360,7 @@ export function setPluginSlotRegistrations(
/** Drop one plugin's registrations (uninstall/disable/failed re-interpret). */
export function removePluginSlotRegistrations(pluginId: string): void {
if (!registrationsByPluginId.delete(pluginId)) return;
+ flattenedByPluginId.delete(pluginId);
emitChange();
}
@@ -268,6 +372,9 @@ export function subscribePluginSlots(listener: () => void): () => void {
}
export function getPluginSlotSnapshot(): PluginSlotSnapshot {
+ // A read inside an open batch must not observe stale registrations: the
+ // batch defers the notification, not the data.
+ rebuildIfStale();
return snapshot;
}
@@ -280,5 +387,7 @@ export function usePluginSlots(): PluginSlotSnapshot {
export function resetPluginSlotStoreForTest(): void {
registrationsByPluginId.clear();
generationByPluginId.clear();
+ flattenedByPluginId.clear();
+ openBatchDepth = 0;
emitChange();
}
diff --git a/apps/app/src/lib/split-layout/openThreadInSplit.ts b/apps/app/src/lib/split-layout/openThreadInSplit.ts
index 01ff2cfa4d..d2d894e0f6 100644
--- a/apps/app/src/lib/split-layout/openThreadInSplit.ts
+++ b/apps/app/src/lib/split-layout/openThreadInSplit.ts
@@ -1,4 +1,3 @@
-import type { useNavigate } from "react-router-dom";
import { getThreadRoutePath } from "@/lib/route-paths";
import { decideThreadDrop } from "@/lib/split-drag";
import { splitLayoutAtom } from "./atoms";
@@ -20,7 +19,7 @@ interface SplitLayoutStore {
export interface OpenThreadInSplitArgs {
store: SplitLayoutStore;
- navigate: ReturnType;
+ navigate: (route: string, options?: { replace?: boolean }) => void;
projectId: string;
threadId: string;
/** Splits are off on compact viewports. */
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index f2a9d82d15..df67f90eeb 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -1,4 +1,11 @@
-import { useCallback, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type RefObject,
+} from "react";
import { createPortal } from "react-dom";
import { NavLink, useNavigate } from "react-router-dom";
import type { IconName } from "@bb/shared-ui/icon";
@@ -60,6 +67,7 @@ import {
import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary";
import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
import { useComposerTextEffects } from "@/lib/composer-text-effects";
+import { useLatestRef } from "@/hooks/useLatestRef";
import { useEscapeToHide } from "@/hooks/useEscapeToHide";
import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions";
import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query";
@@ -308,6 +316,89 @@ function buildInlineDraftComposer(options: InlineDraftComposerOptions) {
);
}
+type InlineQueuedMessageEditSession = Pick<
+ InlineQueuedMessageEditState,
+ "editSessionId" | "queuedMessageId"
+>;
+
+function isInlineQueuedMessageEditSession(
+ current: InlineQueuedMessageEditState | null,
+ session: InlineQueuedMessageEditSession,
+): current is InlineQueuedMessageEditState {
+ return (
+ current?.editSessionId === session.editSessionId &&
+ current.queuedMessageId === session.queuedMessageId
+ );
+}
+
+/** Plugin composer-host accessors for the queued-message inline editor (see below). */
+function readInlineQueuedMessageDraft(
+ editStateRef: RefObject,
+ session: InlineQueuedMessageEditSession,
+ fallback: PromptDraftState,
+): PromptDraftState {
+ const current = editStateRef.current;
+ return isInlineQueuedMessageEditSession(current, session)
+ ? current.draft
+ : fallback;
+}
+
+function writeInlineQueuedMessageDraft(
+ editStateRef: RefObject,
+ session: InlineQueuedMessageEditSession,
+ draft: PromptDraftState,
+ commit: (next: InlineQueuedMessageEditState) => void,
+): void {
+ const current = editStateRef.current;
+ if (isInlineQueuedMessageEditSession(current, session)) {
+ commit({ ...current, draft });
+ }
+}
+
+/**
+ * Plugin composer-host accessors for the sent-message inline editor. Module
+ * level on purpose: inlined closures that return `ref.current.draft` in one
+ * branch and the render-time `draft` in another make React Compiler type the
+ * whole edit object as a ref value and bail out of the component.
+ */
+function readSentMessageEditDraft(
+ sentMessageEditRef: RefObject,
+ operationId: string,
+ fallback: PromptDraftState,
+): PromptDraftState {
+ const current = sentMessageEditRef.current;
+ return current?.operationId === operationId ? current.draft : fallback;
+}
+
+function writeSentMessageEditDraft(
+ sentMessageEditRef: RefObject,
+ operationId: string,
+ nextDraft: PromptDraftState,
+): void {
+ const current = sentMessageEditRef.current;
+ if (current?.operationId === operationId) {
+ current.updateDraft(() => nextDraft);
+ }
+}
+
+/**
+ * Flip the "sending" flag around a task. Kept outside the component: React
+ * Compiler bails out of any function containing `try`/`finally`, and one such
+ * block inside `ThreadDetailPromptArea` left the whole ~1600-line body
+ * unmemoized.
+ */
+async function runWhileFollowUpShortcutSending(
+ setSending: (sending: boolean) => void,
+ task: () => Promise,
+): Promise {
+ setSending(true);
+ try {
+ await task();
+ } finally {
+ setSending(false);
+ }
+}
+
export function ThreadDetailPromptArea({
activeBackgroundAgentCount,
canUseGitUi,
@@ -372,8 +463,8 @@ export function ThreadDetailPromptArea({
const { data: queuedMessages = [] } = useThreadQueuedMessages(thread.id, {
enabled: true,
});
- const queuedMessagesRef = useRef([]);
- queuedMessagesRef.current = queuedMessages;
+ const queuedMessagesRef =
+ useLatestRef(queuedMessages);
const [bottomPluginFocusNonce, setBottomPluginFocusNonce] = useState(0);
const [editFocusNonce, setEditFocusNonce] = useState(0);
const focusBottomPluginComposer = useCallback(() => {
@@ -382,8 +473,7 @@ export function ThreadDetailPromptArea({
const focusInlinePluginComposer = useCallback(() => {
setEditFocusNonce((nonce) => nonce + 1);
}, []);
- const sentMessageEditRef = useRef(sentMessageEdit);
- sentMessageEditRef.current = sentMessageEdit;
+ const sentMessageEditRef = useLatestRef(sentMessageEdit);
const clearInlineAttachmentErrorRef = useRef<() => void>(() => {});
const {
inlineEditingQueuedMessage,
@@ -481,7 +571,12 @@ export function ThreadDetailPromptArea({
}
: null,
});
- clearInlineAttachmentErrorRef.current = () => setInlineAttachmentError(null);
+ // Read only from the queued-message edit handler (never during render), so
+ // a layout-effect write is current by the time it can run.
+ useLayoutEffect(() => {
+ clearInlineAttachmentErrorRef.current = () =>
+ setInlineAttachmentError(null);
+ }, [setInlineAttachmentError]);
const promptTextEffects = useComposerTextEffects(promptDraft.storageKey);
const queuedComposerTextEffects = useComposerTextEffects(
inlineEditingQueuedMessage
@@ -840,24 +935,25 @@ export function ThreadDetailPromptArea({
}
if (shortcutRequest.kind === "draft") {
- setIsFollowUpShortcutSending(true);
promptDraft.clearIfCurrentMatches(submittedDraft);
setBottomAttachmentError(null);
-
- try {
- await sendMessage.mutateAsync(shortcutRequest.request);
- } catch (nextError) {
- promptDraft.restoreIfEmpty(submittedDraft);
- appToast.error(
- getMutationErrorMessage({
- error: nextError,
- fallbackMessage: "Failed to send message",
- lifecycleOperation: "send_message",
- }),
- );
- } finally {
- setIsFollowUpShortcutSending(false);
- }
+ await runWhileFollowUpShortcutSending(
+ setIsFollowUpShortcutSending,
+ async () => {
+ try {
+ await sendMessage.mutateAsync(shortcutRequest.request);
+ } catch (nextError) {
+ promptDraft.restoreIfEmpty(submittedDraft);
+ appToast.error(
+ getMutationErrorMessage({
+ error: nextError,
+ fallbackMessage: "Failed to send message",
+ lifecycleOperation: "send_message",
+ }),
+ );
+ }
+ },
+ );
return;
}
@@ -866,20 +962,21 @@ export function ThreadDetailPromptArea({
return;
}
- setIsFollowUpShortcutSending(true);
- try {
- await sendQueuedMessageById({
- guard: "current-head",
- messageId: queuedMessageId,
- });
- } finally {
- setIsFollowUpShortcutSending(false);
- }
+ await runWhileFollowUpShortcutSending(
+ setIsFollowUpShortcutSending,
+ async () => {
+ await sendQueuedMessageById({
+ guard: "current-head",
+ messageId: queuedMessageId,
+ });
+ },
+ );
}, [
canSubmitModifierShortcut,
currentPromptDraft,
currentPromptDraftInput,
promptDraft,
+ queuedMessagesRef,
sendMessage,
sendQueuedMessageById,
setBottomAttachmentError,
@@ -1225,11 +1322,7 @@ export function ThreadDetailPromptArea({
editSessionId,
queuedMessageId,
} = inlineEditingQueuedMessage;
- const isCurrentSession = (
- current: InlineQueuedMessageEditState | null,
- ): current is InlineQueuedMessageEditState =>
- current?.editSessionId === editSessionId &&
- current.queuedMessageId === queuedMessageId;
+ const session = { editSessionId, queuedMessageId };
const pluginComposerHost: PluginComposerHost = {
scope: {
kind: "queued-message",
@@ -1238,16 +1331,19 @@ export function ThreadDetailPromptArea({
},
textEffectKey: `queued-message:${thread.id}:${queuedMessageId}:${editSessionId}`,
draft: activeComposerDraft,
- getCurrent: () => {
- const current = inlineEditingQueuedMessageRef.current;
- return isCurrentSession(current) ? current.draft : initialDraft;
- },
- setDraft: (draft) => {
- const current = inlineEditingQueuedMessageRef.current;
- if (isCurrentSession(current)) {
- commitInlineQueuedMessage({ ...current, draft });
- }
- },
+ getCurrent: () =>
+ readInlineQueuedMessageDraft(
+ inlineEditingQueuedMessageRef,
+ session,
+ initialDraft,
+ ),
+ setDraft: (draft) =>
+ writeInlineQueuedMessageDraft(
+ inlineEditingQueuedMessageRef,
+ session,
+ draft,
+ commitInlineQueuedMessage,
+ ),
focus: focusInlinePluginComposer,
};
const inlineEditor: QueuedMessageInlineEditor = {
@@ -1369,18 +1465,14 @@ export function ThreadDetailPromptArea({
scope: { kind: "thread", threadId: thread.id },
textEffectKey: `sent-message:${thread.id}:${operationId}`,
draft,
- getCurrent: () => {
- const current = sentMessageEditRef.current;
- return current?.operationId === operationId
- ? current.draft
- : draft;
- },
- setDraft: (nextDraft) => {
- const current = sentMessageEditRef.current;
- if (current?.operationId === operationId) {
- current.updateDraft(() => nextDraft);
- }
- },
+ getCurrent: () =>
+ readSentMessageEditDraft(sentMessageEditRef, operationId, draft),
+ setDraft: (nextDraft) =>
+ writeSentMessageEditDraft(
+ sentMessageEditRef,
+ operationId,
+ nextDraft,
+ ),
focus: focusInlinePluginComposer,
},
promptActions,
@@ -1412,6 +1504,7 @@ export function ThreadDetailPromptArea({
sentMessageAttachmentError,
sentMessageComposerTextEffects,
sentMessageEdit,
+ sentMessageEditRef,
sentMessageEditSubmitMode,
thread.id,
typeaheadConfig,
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 7f66a36f65..694fc734aa 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -578,7 +578,15 @@ reimplementing it, and `indicatorLabel` carries the matching accessible string.
child threads and running threads that `isUnreadDoneThread` excludes by
design. Confirm that is the more useful primitive for a replaced list.
4. **Scale.** Confirm one array of every thread is right at ten thousand
- threads, versus a paged or windowed read.
+ threads, versus a paged or windowed read. Today the host memoizes each
+ thread DTO per unchanged `ThreadListEntry` (React Query structurally shares
+ the payload), so a refetch that changes one thread hands plugins the same
+ objects for every other thread and a `memo`/compiler-memoized row bails
+ out; the array itself is new whenever the payload changes. Plugin lists
+ are still expected to window their rows (the built-in sidebar does): the
+ host does not cap the array, and mounting one row per thread on a phone is
+ the plugin's cost. Decide whether that expectation should be enforced by
+ the contract (paged/windowed read) before stabilizing.
5. **Draft indicators.** `indicator` never reports "draft" or "working-draft",
because an unsubmitted draft is per-composer client state the host reads per
row. An idle unread thread holding a draft therefore reads as
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
index 3e95d3078e..c15e87483f 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
@@ -1553,6 +1553,13 @@ interface PluginSdkApp {
* The sidebar's live thread view (see {@link PluginSidebarThreadsState}).
* Reads the host's own cache and realtime subscriptions, so it costs no
* extra request and updates exactly when the built-in sidebar does.
+ *
+ * `threads` is one array of every visible thread and is not capped. Thread
+ * objects keep their identity across updates while the underlying entry is
+ * unchanged, so a memoized row re-renders only when its own thread changed;
+ * the array itself is new on every update. Window your rows (render only
+ * what is on screen) as the built-in sidebar does — a list that mounts one
+ * row per thread is slow on phones with many threads.
* Experimental: see docs/api_to_audit.md.
*/
experimental_useSidebarThreads(): PluginSidebarThreadsState;
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
index 6560a28b03..e9ad727718 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
@@ -12272,6 +12272,13 @@ interface PluginSdkApp {
* The sidebar's live thread view (see {@link PluginSidebarThreadsState}).
* Reads the host's own cache and realtime subscriptions, so it costs no
* extra request and updates exactly when the built-in sidebar does.
+ *
+ * `threads` is one array of every visible thread and is not capped. Thread
+ * objects keep their identity across updates while the underlying entry is
+ * unchanged, so a memoized row re-renders only when its own thread changed;
+ * the array itself is new on every update. Window your rows (render only
+ * what is on screen) as the built-in sidebar does — a list that mounts one
+ * row per thread is slow on phones with many threads.
* Experimental: see docs/api_to_audit.md.
*/
experimental_useSidebarThreads(): PluginSidebarThreadsState;
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index fa7c9e6a92..9b82efe875 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -763,8 +763,7 @@ export interface ThreadChatMessageReference {
* action opening its own tab is already the target, so it passes the bare
* {@link PluginPanelActionOpenOptions} instead.
*/
-export interface PluginTargetedPanelActionOpenOptions
- extends PluginPanelActionOpenOptions {
+export interface PluginTargetedPanelActionOpenOptions extends PluginPanelActionOpenOptions {
/** A `threadPanelAction` id registered by this same plugin. */
actionId: string;
}
@@ -1472,6 +1471,13 @@ export interface PluginSdkApp {
* The sidebar's live thread view (see {@link PluginSidebarThreadsState}).
* Reads the host's own cache and realtime subscriptions, so it costs no
* extra request and updates exactly when the built-in sidebar does.
+ *
+ * `threads` is one array of every visible thread and is not capped. Thread
+ * objects keep their identity across updates while the underlying entry is
+ * unchanged, so a memoized row re-renders only when its own thread changed;
+ * the array itself is new on every update. Window your rows (render only
+ * what is on screen) as the built-in sidebar does — a list that mounts one
+ * row per thread is slow on phones with many threads.
* Experimental: see docs/api_to_audit.md.
*/
experimental_useSidebarThreads(): PluginSidebarThreadsState;
diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
index 3d85774139..3bf6c203f4 100644
--- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts
+++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
@@ -2,6 +2,6 @@
// Generated by packages/templates/scripts/generate-templates.mjs from
// @get-bb/plugin-sdk/bundled-types. Do not edit directly.
-export const PLUGIN_SDK_DTS = "// Portable type declarations for `@get-bb/plugin-sdk`. Unpublished BB\n// workspace contracts are flattened; public subpaths may reuse the\n// package root without requiring any other @bb/* package.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/get-bb/bb\n\nimport * as react from 'react';\nimport { ComponentType, ReactNode } from 'react';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\n\n/**\n * App-wide server-backed preferences.\n * Client-local settings stay in the frontend localStorage helpers instead.\n */\ndeclare const appSettingsSchema: z$1.ZodObject<{\n claudeCodeMemoryEnabled: z$1.ZodBoolean;\n claudeCodeSubagentsDisabled: z$1.ZodBoolean;\n claudeCodeWorkflowsDisabled: z$1.ZodBoolean;\n codexMemoryEnabled: z$1.ZodBoolean;\n codexSubagentsDisabled: z$1.ZodBoolean;\n onboardingCompletedAt: z$1.ZodNullable;\n showKeyboardHints: z$1.ZodBoolean;\n showUnhandledProviderEvents: z$1.ZodBoolean;\n steerActiveThreadOnEnter: z$1.ZodBoolean;\n}, z$1.core.$strict>;\ntype AppSettings = z$1.infer;\n\ndeclare const appKeybindingOverridesSchema: z$1.ZodArray;\n shortcut: z$1.ZodNullable>;\n}, z$1.core.$strict>>;\ntype AppKeybindingOverrides = z$1.infer;\n\ninterface JsonObject {\n [key: string]: JsonValue$1;\n}\ntype JsonValue$1 = string | number | boolean | null | JsonValue$1[] | JsonObject;\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n blue: \"blue\";\n default: \"default\";\n green: \"green\";\n orange: \"orange\";\n pink: \"pink\";\n purple: \"purple\";\n red: \"red\";\n teal: \"teal\";\n yellow: \"yellow\";\n }>;\n resolvedCodeTheme: z$1.ZodDefault>>;\n light: z$1.ZodString;\n }, z$1.core.$strict>>;\n themeId: z$1.ZodString;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n/**\n * The complete appearance selection a client sends when changing the palette\n * and/or favicon tint. The server validates `themeId` (built-in id or an\n * existing custom theme) and resolves the CSS from disk for custom themes.\n * Callers changing only one facet must carry the other facet forward explicitly.\n */\ndeclare const appThemeSelectionSchema: z$1.ZodObject<{\n faviconColor: z$1.ZodEnum<{\n blue: \"blue\";\n default: \"default\";\n green: \"green\";\n orange: \"orange\";\n pink: \"pink\";\n purple: \"purple\";\n red: \"red\";\n teal: \"teal\";\n yellow: \"yellow\";\n }>;\n themeId: z$1.ZodString;\n}, z$1.core.$strip>;\ntype AppThemeSelection = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n changes: z$1.ZodReadonly>>;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n type: z$1.ZodLiteral<\"changed\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n changes: z$1.ZodReadonly>>;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"changed\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n changes: z$1.ZodReadonly>>;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"changed\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n changes: z$1.ZodReadonly>>;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"changed\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n changes: z$1.ZodReadonly>>;\n entity: z$1.ZodLiteral<\"system\">;\n type: z$1.ZodLiteral<\"changed\">;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const environmentSchema: z$1.ZodObject<{\n baseBranch: z$1.ZodNullable;\n branchName: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n defaultBranch: z$1.ZodNullable;\n hostId: z$1.ZodString;\n id: z$1.ZodString;\n isGitRepo: z$1.ZodBoolean;\n isWorktree: z$1.ZodBoolean;\n managed: z$1.ZodBoolean;\n mergeBaseBranch: z$1.ZodNullable;\n name: z$1.ZodNullable;\n path: z$1.ZodNullable;\n projectId: z$1.ZodString;\n status: z$1.ZodEnum<{\n destroyed: \"destroyed\";\n destroying: \"destroying\";\n error: \"error\";\n provisioning: \"provisioning\";\n ready: \"ready\";\n retiring: \"retiring\";\n }>;\n updatedAt: z$1.ZodNumber;\n workspaceProvisionType: z$1.ZodEnum<{\n \"managed-worktree\": \"managed-worktree\";\n personal: \"personal\";\n unmanaged: \"unmanaged\";\n }>;\n}, z$1.core.$strip>;\ntype Environment = z$1.infer;\n\ndeclare const experimentsSchema: z$1.ZodRecord, z$1.ZodBoolean>;\ntype Experiments = z$1.infer;\n\ndeclare const hostSchema: z$1.ZodObject<{\n createdAt: z$1.ZodNumber;\n id: z$1.ZodString;\n lastRejectedProtocolVersion: z$1.ZodNullable;\n lastSeenAt: z$1.ZodNullable;\n maxPermissionMode: z$1.ZodEnum<{\n \"accept-edits\": \"accept-edits\";\n auto: \"auto\";\n full: \"full\";\n }>;\n name: z$1.ZodString;\n status: z$1.ZodEnum<{\n connected: \"connected\";\n disconnected: \"disconnected\";\n }>;\n type: z$1.ZodEnum<{\n persistent: \"persistent\";\n }>;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype Host = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n answers: z$1.ZodRecord;\n selected: z$1.ZodArray;\n }, z$1.core.$strip>>;\n kind: z$1.ZodLiteral<\"user_answer\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin_submitted\">;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\ndeclare const providerPendingInteractionSchema: z$1.ZodObject<{\n createdAt: z$1.ZodNumber;\n expiresAt: z$1.ZodOptional>;\n id: z$1.ZodString;\n origin: z$1.ZodOptional;\n providerId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n }, z$1.core.$strip>>;\n payload: z$1.ZodUnion>;\n kind: z$1.ZodLiteral<\"approval\">;\n reason: z$1.ZodNullable;\n subject: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n actions: z$1.ZodArray;\n }, z$1.core.$strip>, z$1.ZodObject<{\n command: z$1.ZodString;\n path: z$1.ZodNullable;\n type: z$1.ZodLiteral<\"listFiles\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n command: z$1.ZodString;\n path: z$1.ZodNullable;\n query: z$1.ZodNullable;\n type: z$1.ZodLiteral<\"search\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n command: z$1.ZodString;\n type: z$1.ZodLiteral<\"unknown\">;\n }, z$1.core.$strip>], \"type\">>;\n command: z$1.ZodString;\n cwd: z$1.ZodNullable;\n itemId: z$1.ZodString;\n kind: z$1.ZodLiteral<\"command\">;\n sessionGrant: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n itemId: z$1.ZodString;\n kind: z$1.ZodLiteral<\"file_change\">;\n sessionGrant: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n writeScope: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n itemId: z$1.ZodString;\n kind: z$1.ZodLiteral<\"permission_grant\">;\n permissions: z$1.ZodObject<{\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>;\n toolName: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n itemId: z$1.ZodString;\n kind: z$1.ZodLiteral<\"plan\">;\n plan: z$1.ZodString;\n planFilePath: z$1.ZodNullable;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_question\">;\n questions: z$1.ZodArray;\n label: z$1.ZodString;\n value: z$1.ZodString;\n }, z$1.core.$strip>>>;\n prompt: z$1.ZodString;\n shortLabel: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>]>;\n providerId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n resolution: z$1.ZodNullable;\n grantedPermissions: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n }, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n answers: z$1.ZodRecord;\n selected: z$1.ZodArray;\n }, z$1.core.$strip>>;\n kind: z$1.ZodLiteral<\"user_answer\">;\n }, z$1.core.$strip>]>>;\n resolvedAt: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n interrupted: \"interrupted\";\n pending: \"pending\";\n resolved: \"resolved\";\n resolving: \"resolving\";\n }>;\n statusReason: z$1.ZodNullable;\n threadId: z$1.ZodString;\n turnId: z$1.ZodString;\n}, z$1.core.$strip>;\ntype ProviderPendingInteraction = z$1.infer;\ndeclare const pluginPendingInteractionSchema: z$1.ZodObject<{\n createdAt: z$1.ZodNumber;\n expiresAt: z$1.ZodOptional>;\n id: z$1.ZodString;\n origin: z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n rendererId: z$1.ZodString;\n }, z$1.core.$strip>;\n payload: z$1.ZodObject<{\n data: z$1.ZodType>;\n kind: z$1.ZodLiteral<\"plugin\">;\n title: z$1.ZodString;\n }, z$1.core.$strip>;\n resolution: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n resolvedAt: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n interrupted: \"interrupted\";\n pending: \"pending\";\n resolved: \"resolved\";\n resolving: \"resolving\";\n }>;\n statusReason: z$1.ZodNullable;\n threadId: z$1.ZodString;\n turnId: z$1.ZodNullable;\n}, z$1.core.$strip>;\ntype PluginPendingInteraction = z$1.infer;\ntype PendingInteraction = ProviderPendingInteraction | PluginPendingInteraction;\n\ndeclare const projectSourceSchema: z$1.ZodObject<{\n createdAt: z$1.ZodNumber;\n hostId: z$1.ZodString;\n id: z$1.ZodString;\n isDefault: z$1.ZodBoolean;\n path: z$1.ZodString;\n projectId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype ProjectSource = z$1.infer;\n\ndeclare const reasoningLevelSchema: z$1.ZodEnum<{\n high: \"high\";\n low: \"low\";\n max: \"max\";\n medium: \"medium\";\n none: \"none\";\n ultra: \"ultra\";\n ultracode: \"ultracode\";\n xhigh: \"xhigh\";\n}>;\ntype ReasoningLevel = z$1.infer;\ndeclare const serviceTierSchema: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n}>;\ntype ServiceTier = z$1.infer;\ndeclare const permissionModeSchema: z$1.ZodEnum<{\n \"accept-edits\": \"accept-edits\";\n auto: \"auto\";\n full: \"full\";\n}>;\ntype PermissionMode = z$1.infer;\ndeclare const promptInputSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mentions: z$1.ZodDefault, z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n label: z$1.ZodString;\n projectId: z$1.ZodOptional;\n threadId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n label: z$1.ZodString;\n projectId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"section\">;\n label: z$1.ZodString;\n sectionId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n entryKind: z$1.ZodEnum<{\n directory: \"directory\";\n file: \"file\";\n }>;\n kind: z$1.ZodLiteral<\"path\">;\n label: z$1.ZodString;\n path: z$1.ZodString;\n source: z$1.ZodEnum<{\n \"thread-storage\": \"thread-storage\";\n workspace: \"workspace\";\n }>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n argumentHint: z$1.ZodNullable;\n kind: z$1.ZodLiteral<\"command\">;\n label: z$1.ZodString;\n name: z$1.ZodString;\n origin: z$1.ZodEnum<{\n builtin: \"builtin\";\n project: \"project\";\n user: \"user\";\n }>;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n icon: z$1.ZodOptional>;\n itemId: z$1.ZodString;\n kind: z$1.ZodLiteral<\"plugin\">;\n label: z$1.ZodString;\n pluginId: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">>;\n start: z$1.ZodNumber;\n }, z$1.core.$strip>>>;\n text: z$1.ZodString;\n type: z$1.ZodLiteral<\"text\">;\n visibility: z$1.ZodOptional>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n visibility: z$1.ZodOptional>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n path: z$1.ZodString;\n type: z$1.ZodLiteral<\"localImage\">;\n visibility: z$1.ZodOptional>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n mimeType: z$1.ZodOptional;\n name: z$1.ZodOptional;\n path: z$1.ZodString;\n sizeBytes: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"localFile\">;\n visibility: z$1.ZodOptional>;\n}, z$1.core.$strip>], \"type\">;\ntype PromptInput = z$1.infer;\ndeclare const resolvedThreadExecutionOptionsSchema: z$1.ZodObject<{\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n \"accept-edits\": \"accept-edits\";\n auto: \"auto\";\n full: \"full\";\n }>;\n reasoningLevel: z$1.ZodEnum<{\n high: \"high\";\n low: \"low\";\n max: \"max\";\n medium: \"medium\";\n none: \"none\";\n ultra: \"ultra\";\n ultracode: \"ultracode\";\n xhigh: \"xhigh\";\n }>;\n seq: z$1.ZodOptional;\n serviceTier: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n }>;\n source: z$1.ZodEnum<{\n \"client/thread/start\": \"client/thread/start\";\n \"client/turn/requested\": \"client/turn/requested\";\n \"client/turn/start\": \"client/turn/start\";\n }>;\n}, z$1.core.$strip>;\ntype ResolvedThreadExecutionOptions = z$1.infer;\ndeclare const projectExecutionDefaultsSchema: z$1.ZodObject<{\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n \"accept-edits\": \"accept-edits\";\n auto: \"auto\";\n full: \"full\";\n }>;\n providerId: z$1.ZodString;\n reasoningLevel: z$1.ZodEnum<{\n high: \"high\";\n low: \"low\";\n max: \"max\";\n medium: \"medium\";\n none: \"none\";\n ultra: \"ultra\";\n ultracode: \"ultracode\";\n xhigh: \"xhigh\";\n }>;\n serviceTier: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n }>;\n}, z$1.core.$strip>;\ntype ProjectExecutionDefaults = z$1.infer;\n\n/** All thread events — provider-originated or system-originated. */\ndeclare const threadEventSchema: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"thread/identity\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n parentToolCallId: z$1.ZodOptional;\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"turn/started\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n error: z$1.ZodOptional>;\n providerCheckpointId: z$1.ZodOptional;\n providerThreadId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"turn/completed\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n clientRequestId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n scope: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"turn\">;\n turnId: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"turn/input/accepted\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n threadName: z$1.ZodString;\n type: z$1.ZodLiteral<\"thread/name/updated\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"thread/compacted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"thread/context/cleared\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n objective: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n status: z$1.ZodEnum<{\n active: \"active\";\n budgetLimited: \"budgetLimited\";\n complete: \"complete\";\n paused: \"paused\";\n }>;\n threadId: z$1.ZodString;\n timeUsedSeconds: z$1.ZodNumber;\n tokenBudget: z$1.ZodNullable;\n tokensUsed: z$1.ZodNumber;\n type: z$1.ZodLiteral<\"thread/goal/updated\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n providerThreadId: z$1.ZodString;\n threadId: z$1.ZodString;\n type: z$1.ZodLiteral<\"thread/goal/cleared\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n item: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n clientRequestId: z$1.ZodOptional;\n content: z$1.ZodArray;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n path: z$1.ZodString;\n type: z$1.ZodLiteral<\"localImage\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n path: z$1.ZodString;\n type: z$1.ZodLiteral<\"localFile\">;\n }, z$1.core.$strip>], \"type\">>;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"userMessage\">;\n }, z$1.core.$strict>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n text: z$1.ZodString;\n type: z$1.ZodLiteral<\"agentMessage\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n aggregatedOutput: z$1.ZodOptional;\n approvalStatus: z$1.ZodNullable>;\n command: z$1.ZodString;\n cwd: z$1.ZodString;\n durationMs: z$1.ZodOptional;\n exitCode: z$1.ZodOptional;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n pending: \"pending\";\n }>;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n type: z$1.ZodLiteral<\"commandExecution\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n approvalStatus: z$1.ZodNullable>;\n changes: z$1.ZodArray;\n kind: z$1.ZodEnum<{\n add: \"add\";\n delete: \"delete\";\n update: \"update\";\n }>;\n movePath: z$1.ZodOptional;\n path: z$1.ZodString;\n }, z$1.core.$strip>>;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n pending: \"pending\";\n }>;\n type: z$1.ZodLiteral<\"fileChange\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n queries: z$1.ZodArray;\n resultText: z$1.ZodNullable;\n type: z$1.ZodLiteral<\"webSearch\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n pattern: z$1.ZodNullable;\n prompt: z$1.ZodNullable;\n resultText: z$1.ZodNullable;\n type: z$1.ZodLiteral<\"webFetch\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n path: z$1.ZodString;\n type: z$1.ZodLiteral<\"imageView\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n arguments: z$1.ZodOptional>;\n durationMs: z$1.ZodOptional;\n error: z$1.ZodOptional;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n result: z$1.ZodOptional;\n server: z$1.ZodOptional;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n pending: \"pending\";\n }>;\n statusLabels: z$1.ZodOptional>;\n tool: z$1.ZodString;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n type: z$1.ZodLiteral<\"toolCall\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n content: z$1.ZodArray;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n summary: z$1.ZodArray;\n type: z$1.ZodLiteral<\"reasoning\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n text: z$1.ZodString;\n type: z$1.ZodLiteral<\"plan\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n type: z$1.ZodLiteral<\"contextCompaction\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n description: z$1.ZodString;\n error: z$1.ZodOptional;\n id: z$1.ZodString;\n outputFile: z$1.ZodOptional;\n parentToolCallId: z$1.ZodOptional;\n skipTranscript: z$1.ZodBoolean;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n pending: \"pending\";\n }>;\n summary: z$1.ZodOptional;\n taskStatus: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n killed: \"killed\";\n paused: \"paused\";\n pending: \"pending\";\n running: \"running\";\n stopped: \"stopped\";\n }>;\n taskType: z$1.ZodString;\n type: z$1.ZodLiteral<\"backgroundTask\">;\n usage: z$1.ZodOptional>;\n workflow: z$1.ZodOptional;\n attempt: z$1.ZodNumber;\n cached: z$1.ZodBoolean;\n durationMs: z$1.ZodOptional;\n error: z$1.ZodOptional;\n index: z$1.ZodNumber;\n isolation: z$1.ZodOptional;\n label: z$1.ZodString;\n lastProgressAt: z$1.ZodNumber;\n lastToolName: z$1.ZodOptional;\n lastToolSummary: z$1.ZodOptional;\n model: z$1.ZodString;\n phaseIndex: z$1.ZodOptional;\n phaseTitle: z$1.ZodOptional;\n promptPreview: z$1.ZodOptional