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 (