diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
index 514924688..8f5dfb840 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import { atom, useAtom } from "jotai";
import { RESET, atomWithStorage } from "jotai/utils";
import type {
@@ -1132,6 +1133,7 @@ export function PromptBoxInternal({
promptBoxRef,
focusEndKey,
}: PromptBoxInternalProps) {
+ renderProbe("PromptBoxInternal");
const focusComposerShortcut = useAppCommandShortcut("composer.focus");
const {
isSubmitting = false,
diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx
index e1b991ad2..288019ca6 100644
--- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx
+++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import {
useCallback,
useEffect,
@@ -270,6 +271,7 @@ function EmbeddedThreadChatHostedFooter({
scrollOverlay,
surface,
}: EmbeddedThreadChatHostedFooterProps) {
+ renderProbe("EmbeddedThreadChatHostedFooter");
return (
getViewRows(props.timelineRows),
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
index 978011b31..6b41497a6 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type {
ActiveThinking,
@@ -178,6 +179,7 @@ export function ThreadTimelineSurface({
unreadDividerPlacement,
workspaceRootPath,
}: ThreadTimelineSurfaceProps) {
+ renderProbe("ThreadTimelineSurface");
const preferredTheme = usePreferredTheme();
const showActiveThinking =
activeThinking !== null && ongoingIndicatorLabel === undefined;
diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx
index b25ea5392..0f47a5da6 100644
--- a/apps/app/src/components/ui/markdown-preview.tsx
+++ b/apps/app/src/components/ui/markdown-preview.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import {
Children,
cloneElement,
@@ -618,6 +619,7 @@ function MarkdownAnchor({
rewriteLocalhostLinks,
...anchorProps
}: MarkdownAnchorProps) {
+ renderProbe("MarkdownAnchor");
const localFileRouting = linkRouting?.localFile;
const onOpenLocalFileLink = localFileRouting?.onOpenLink;
const rewrittenHref = rewriteLocalhostLinkHref({
diff --git a/apps/app/src/hooks/useLocalOpenTargets.ts b/apps/app/src/hooks/useLocalOpenTargets.ts
index ba573bd1c..1481e97c0 100644
--- a/apps/app/src/hooks/useLocalOpenTargets.ts
+++ b/apps/app/src/hooks/useLocalOpenTargets.ts
@@ -211,9 +211,29 @@ function useOpenTargetResolution(
export function useLocalOpenTargets(
args: UseLocalOpenTargetsArgs,
): UseLocalOpenTargetsResult {
+ // FIX (#1304 part B): memoize structurally. Callers (ThreadDetailView)
+ // rebuild an equal context object on every render; keying on identity made
+ // every callback below — and the timeline-wide context value built from
+ // them — change identity per render.
+ const openContextKind = args.openContext?.kind ?? "local";
+ const openContextHostId =
+ args.openContext?.kind === "remote-ssh" ? args.openContext.hostId : null;
+ const openContextServerOrigin =
+ args.openContext?.kind === "remote-ssh"
+ ? args.openContext.serverOrigin
+ : null;
const openContext = useMemo(
- () => args.openContext ?? { kind: "local" },
- [args.openContext],
+ () =>
+ openContextKind === "remote-ssh" &&
+ openContextHostId !== null &&
+ openContextServerOrigin !== null
+ ? {
+ kind: "remote-ssh",
+ hostId: openContextHostId,
+ serverOrigin: openContextServerOrigin,
+ }
+ : { kind: "local" },
+ [openContextHostId, openContextKind, openContextServerOrigin],
);
const contextKind = openContext.kind;
const { hasDaemon } = useHostDaemon();
diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts
index a040f7da2..33073d107 100644
--- a/apps/app/src/hooks/usePromptDraftStorage.ts
+++ b/apps/app/src/hooks/usePromptDraftStorage.ts
@@ -251,13 +251,30 @@ function getPromptDraftStorageKey(scope: PromptDraftScope): string {
* renders the draft.
*/
export function getPromptDraftAccessor(scope: PromptDraftScope): {
+ storageKey: string;
getCurrent: () => PromptDraftState;
setDraft: (draft: PromptDraftState) => void;
+ addQuote: (
+ text: string,
+ attachments?: readonly PromptDraftAttachment[],
+ ) => void;
} {
const storageKey = getPromptDraftStorageKey(scope);
return {
+ storageKey,
getCurrent: () => readPromptDraft(storageKey),
setDraft: (draft) => writePromptDraft(storageKey, draft),
+ addQuote: (text, attachments = []) => {
+ const currentDraft = readPromptDraft(storageKey);
+ const nextDraft = appendQuoteAndAttachmentsToDraft(
+ currentDraft,
+ text,
+ attachments,
+ );
+ // Whitespace-only text with no new attachments is a no-op.
+ if (nextDraft === currentDraft) return;
+ writePromptDraft(storageKey, nextDraft);
+ },
};
}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index bc2815413..0832ff124 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import { useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink, useNavigate } from "react-router-dom";
@@ -346,6 +347,7 @@ export function ThreadDetailPromptArea({
composerFocusRequestNonce,
thread,
}: ThreadDetailPromptAreaProps) {
+ renderProbe("ThreadDetailPromptArea");
const navigate = useNavigate();
const defaultExecutionOptionsQuery = useThreadDefaultExecutionOptions(
thread.id,
diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
index fe23cf36a..6fa495375 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import { useMemo, type ComponentProps, type ReactNode } from "react";
import { Skeleton } from "@bb/shared-ui/skeleton";
import { cn } from "@bb/shared-ui/lib/utils";
@@ -78,6 +79,7 @@ function ThreadDetailSecondaryContentBody({
secondaryPanel,
timeline,
}: ThreadDetailSecondaryContentProps) {
+ renderProbe("ThreadDetailSecondaryContentBody");
const composerHost = usePluginComposerHost();
const { renderBrowserDeck, ...threadSecondaryPanelProps } = secondaryPanel;
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index a2a4724bd..6ba829694 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import {
useCallback,
useEffect,
@@ -74,7 +75,7 @@ import {
type ProjectThreadSubsetFilters,
} from "../../hooks/queries/thread-queries";
import { isTransientReadError } from "@/hooks/queries/query-helpers";
-import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
+import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage";
import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests";
import { ThreadGitActionDialog } from "@/components/dialogs/ThreadGitActionDialog";
import { PageShell } from "@/components/ui/page-shell.js";
@@ -503,6 +504,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
}
function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
+ renderProbe("ThreadDetailViewInternal");
const { projectId, threadId } = props;
const { isFocused, navigateInPane, onRequestClose, isBoundedPane } =
usePaneContext();
@@ -962,11 +964,20 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
// localStorage-backed draft — the quoted text is appended to the draft as a
// `> ` blockquote block and renders inline in the composer immediately, with
// no duplicated draft state.
- const selectionPromptDraft = usePromptDraftStorage({
- kind: "thread",
- projectId: thread?.projectId ?? projectId ?? "",
- threadId: thread?.id ?? "",
- });
+ // FIX (#1304 part A): this view never renders the draft; it only needs
+ // event-time access (addQuote) and the storage key. Subscribing via
+ // usePromptDraftStorage re-rendered the whole thread view on every keystroke.
+ const selectionPromptDraftProjectId = thread?.projectId ?? projectId ?? "";
+ const selectionPromptDraftThreadId = thread?.id ?? "";
+ const selectionPromptDraft = useMemo(
+ () =>
+ getPromptDraftAccessor({
+ kind: "thread",
+ projectId: selectionPromptDraftProjectId,
+ threadId: selectionPromptDraftThreadId,
+ }),
+ [selectionPromptDraftProjectId, selectionPromptDraftThreadId],
+ );
const addQuoteToComposer = selectionPromptDraft.addQuote;
// Desktop quote actions keep their existing focus handoff. Mobile web does
// not focus inputs programmatically; see PromptBoxInternal.
@@ -2368,6 +2379,14 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
pluginFileOpeners,
],
);
+ // #1304 investigation probe: which deps of getLocalFileContextMenuItems change identity per render
+ {
+ const w = window as unknown as { __bbDepPrev?: Record; __bbDepChanges?: Record };
+ const cur: Record = { threadOpenContext, fileOpenTargets, openPathInFileTarget, handleOpenTimelineLocalFileLink, pluginFileOpeners, getLocalFileContextMenuItems, workspaceOpenTargetsLen: fileOpenTargets.length };
+ const prev = w.__bbDepPrev;
+ if (prev) { w.__bbDepChanges ??= {}; for (const k of Object.keys(cur)) if (!Object.is(prev[k], cur[k])) w.__bbDepChanges[k] = (w.__bbDepChanges[k] ?? 0) + 1; }
+ w.__bbDepPrev = cur;
+ }
const workspaceMarkdownLinkRouting = useMemo(
() =>
buildMarkdownPreviewLinkRouting({
diff --git a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx
index 7d323f95c..7c112546e 100644
--- a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx
+++ b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx
@@ -1,3 +1,4 @@
+import { renderProbe } from "@/lib/render-probe";
import type { ReactNode } from "react";
import type { ThreadTimelineUnreadDividerPlacement } from "@/components/thread/timeline";
import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link";
@@ -27,6 +28,7 @@ export function ThreadTimelinePane({
footer,
...surface
}: ThreadTimelinePaneProps) {
+ renderProbe("ThreadTimelinePane");
return (