diff --git a/.bb/skills/verify-bb/features/plugin-inline-vis.md b/.bb/skills/verify-bb/features/plugin-inline-vis.md index b17b6f0c4a6..fdfd12966df 100644 --- a/.bb/skills/verify-bb/features/plugin-inline-vis.md +++ b/.bb/skills/verify-bb/features/plugin-inline-vis.md @@ -22,9 +22,10 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. | Feature | Drive | Observable success | | --- | --- | --- | -| Render and open | Emit the directive with a relative .html/.htm path, open its header file action, and reload the thread. | The file renders inline and opens from the correct thread workspace. | +| Render and open | Emit the directive with a relative .html/.htm path, open the action that appears over the page on hover, and reload the thread. | The file renders inline with no card or border, follows the theme, and opens from the correct thread workspace. | +| Fenced block | Have the reply contain an `inline-vis` fenced code block whose body is an HTML page; watch it stream, then use the hover action. | A status line shows while the block is written; the page appears when the fence closes and is not reloaded as the reply continues; the action reveals the source; an unclosed block stays a code block. | | Relative assets | Reference a fixture stylesheet, script, and image beside the HTML. | Assets resolve from the authorized workspace location and render without unrelated filesystem access. | -| Height | Try omitted height and values around the declared 120–1200 bounds. | Omitted height is 224px; whole numbers from 120 to 1200 are accepted. Invalid and out-of-range values show an explicit error instead of clamping. | +| Height | Try omitted height and values around the declared 80–2000 bounds. | With no height an HTML frame fits its page and a Markdown card previews 256px, expanding in place with "Show more"; whole numbers from 80 to 2000 are accepted (a cap for HTML, the preview height for Markdown). Invalid and out-of-range values show an explicit error instead of clamping. | | Sandbox | Have the fixture attempt to read parent DOM, cookies, and origin storage; report only success/failure. | The sandbox blocks parent-origin access while allowing the intended visualization interaction. | | Invalid artifacts | Try missing, non-HTML, invalid UTF-8, and greater-than-5MiB fixtures. | Clear artifact errors replace the frame; no silent blank success or unrestricted fallback occurs. | @@ -39,4 +40,5 @@ External account changes use authorized disposable targets. ## Maintenance notes -- Omitted height uses 224px. Whole-number heights from 120 through 1200 are accepted; out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/app.tsx:17`, `plugins/inline-vis/app.tsx:30`. +- An HTML frame fits its page (80–2000px) and `height` caps it; a Markdown card previews 256px and expands in place. Out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/frame.ts` (`FRAME_MIN_HEIGHT`, `FRAME_MAX_HEIGHT`, `frameHeight`), `plugins/inline-vis/app.tsx` (`parseRequestedHeight`). +- The borderless frame, the 80–2000 bounds, the fenced-block recipe, and the expanding Markdown card date from 2026-10-12 and are not covered by the 2026-09-05 validation run. diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 20eba9363d0..d1b150124a3 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -1,3 +1,4 @@ +import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { defineSplit } from "@/lib/define-split"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -188,6 +189,15 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { (invocation) => { const target = invocationTarget(invocation); prepareOpen(target); + recordTelemetryEvent({ + name: "feature_used", + properties: { + feature: "search", + feature_scope: "core", + interaction: "opened", + trigger: "unknown", + }, + }); setSearchingThreads(true); setOpen(true); return true; diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx index 0f7c7b61b86..113f17c62a4 100644 --- a/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx +++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx @@ -35,25 +35,28 @@ function renderDialog({ describe("ThreadArchiveDialog", () => { it("announces the cascade with singular and plural child counts", () => { const { view } = renderDialog({ childThreadCount: 1 }); - expect( - screen.getByText(/1 child thread will be archived with this thread\./), - ).toBeTruthy(); + expect(screen.getByRole("dialog").textContent).toContain( + "Its 1 child thread will be archived too.", + ); + expect(screen.getByText("1 child thread").className).toContain( + "font-semibold", + ); view.unmount(); renderDialog({ childThreadCount: 3 }); - expect( - screen.getByText(/3 child threads will be archived with this thread\./), - ).toBeTruthy(); + expect(screen.getByRole("dialog").textContent).toContain( + "Its 3 child threads will be archived too.", + ); }); - it("names the total number of threads in the title and action", () => { + it("names the single thread in the title and action", () => { renderDialog({ childThreadCount: 3 }); expect( - screen.getByRole("heading", { name: "Archive 4 threads?" }), + screen.getByRole("heading", { name: "Archive thread?" }), ).toBeTruthy(); expect( - screen.getByRole("button", { name: "Archive 4 threads" }), + screen.getByRole("button", { name: "Archive" }), ).toBeTruthy(); }); @@ -73,7 +76,7 @@ describe("ThreadArchiveDialog", () => { it("focuses the archive action when opened for keyboard confirmation", () => { renderDialog(); expect(document.activeElement).toBe( - screen.getByRole("button", { name: "Archive 2 threads" }), + screen.getByRole("button", { name: "Archive" }), ); }); @@ -86,7 +89,7 @@ describe("ThreadArchiveDialog", () => { it("ignores confirmation while archiving is pending", () => { const { onArchive } = renderDialog({ pending: true }); - fireEvent.click(screen.getByRole("button", { name: "Archive 2 threads" })); + fireEvent.click(screen.getByRole("button", { name: "Archive" })); expect(onArchive).not.toHaveBeenCalled(); }); @@ -99,7 +102,7 @@ describe("ThreadArchiveDialog", () => { expect(onArchive).not.toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); - fireEvent.click(screen.getByRole("button", { name: "Archive 3 threads" })); + fireEvent.click(screen.getByRole("button", { name: "Archive" })); expect(onArchive).toHaveBeenCalledWith({ thread, childThreadCount: 2 }); }); }); diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx index 4e4fe745e58..885026ce528 100644 --- a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx @@ -9,6 +9,7 @@ import { DialogHeader, DialogTitle, } from "@bb/shared-ui/dialog"; +import { formatChildThreadCount } from "@/lib/child-thread-copy"; export interface ThreadArchiveDialogTarget { thread: Thread; @@ -68,16 +69,10 @@ export function ThreadArchiveDialogContent({ onArchive, }: ThreadArchiveDialogContentProps) { const { childThreadCount } = target; - const archivedThreadCount = childThreadCount + 1; const active = target.thread.status === "starting" || target.thread.status === "active" || target.thread.status === "stopping"; - const sentences = [ - active ? "This will stop current work." : null, - `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be archived with this thread.`, - "Archived threads stay available and can be unarchived.", - ].filter((sentence): sentence is string => sentence !== null); return (
- Archive {archivedThreadCount} threads? - {sentences.join(" ")} + Archive thread? + + {active ? "This will stop current work. " : null} + Its{" "} + + {formatChildThreadCount(childThreadCount)} + {" "} + will be archived too. Archived threads stay available and can be + unarchived. +
diff --git a/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx b/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx index 02e61c012f1..7d8abb58d31 100644 --- a/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx @@ -3,6 +3,7 @@ import { ConfirmDeleteDialog, ConfirmDeleteDialogContent, } from "@bb/shared-ui/confirm-delete-dialog"; +import { formatChildThreadCount } from "@/lib/child-thread-copy"; export interface ThreadDeleteDialogTarget { thread: Thread; @@ -50,15 +51,24 @@ export function ThreadDeleteDialogContent({ onDelete, }: ThreadDeleteDialogContentProps) { const label = "thread"; - const sentences = [ - target.childThreadCount ? "Child threads will be deleted." : null, - "This action cannot be undone.", - ].filter((part): part is string => part !== null); return ( + {target.childThreadCount ? ( + <> + Its{" "} + + {formatChildThreadCount(target.childThreadCount)} + {" "} + will be deleted too.{" "} + + ) : null} + This action cannot be undone. + + } confirmLabel={`Delete ${label}`} pending={pending} onConfirm={() => onDelete(target)} diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index fc5e07d624b..5bc3e0ab36e 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -116,6 +116,7 @@ import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight"; import { isIOSWebKit } from "@/lib/ios-webkit"; import { wsManager } from "@/lib/ws"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { useSplitViewTelemetry } from "@/lib/split-layout/useSplitViewTelemetry"; import { findPaneByThread } from "@/lib/split-layout"; import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation"; import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory"; @@ -526,6 +527,7 @@ export function AppLayout({ children }: AppLayoutProps) { useServerDaemonLogsCommand(); useDataDirectoryCommand(); useWindowReloadCommand(); + useSplitViewTelemetry(); usePluginSafeModeCommands(); usePluginCachePruneCommand(); const archivedSectionId = isArchivedView diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx index f61a8eed9c6..2038e4ba40a 100644 --- a/apps/app/src/components/onboarding/OnboardingFlow.tsx +++ b/apps/app/src/components/onboarding/OnboardingFlow.tsx @@ -255,11 +255,13 @@ interface SignInSession { function AgentStepContainer({ chrome, + entry, hostId, hostName, onContinue, }: { chrome: StepChrome; + entry: OnboardingEntry; hostId: string | null; hostName: string | null; onContinue: () => void; @@ -285,14 +287,21 @@ function AgentStepContainer({ const { mutate: closeTerminalMutate } = closeTerminal; const { mutateAsync: createTerminalAsync } = createTerminal; - const endSignIn = useCallback(() => { - const current = signInRef.current; - if (current === null) return; - signInRef.current = null; - setSignIn(null); - closeTerminalMutate({ mode: "force", terminalId: current.session.id }); - void refetchStates(); - }, [closeTerminalMutate, refetchStates]); + const endSignIn = useCallback( + (outcome: "ready" | "exited" | "failed" | "cancelled" = "cancelled") => { + const current = signInRef.current; + if (current === null) return; + signInRef.current = null; + recordTelemetryEvent({ + name: "onboarding_sign_in", + properties: { entry, outcome }, + }); + setSignIn(null); + closeTerminalMutate({ mode: "force", terminalId: current.session.id }); + void refetchStates(); + }, + [closeTerminalMutate, entry, refetchStates], + ); const signInRequestRef = useRef(0); useEffect( @@ -309,7 +318,7 @@ function AgentStepContainer({ if (current !== null && exitCode !== 0) { setSignInFailedProviderId(current.providerId); } - endSignIn(); + endSignIn(exitCode === 0 ? "exited" : "failed"); }, [endSignIn], ); @@ -329,7 +338,7 @@ function AgentStepContainer({ states?.find((state) => state.providerId === signingInProviderId) ?.status === "ready"; useEffect(() => { - if (signingInProviderReady) endSignIn(); + if (signingInProviderReady) endSignIn("ready"); }, [endSignIn, signingInProviderReady]); const { runningJobKey } = installRunner; @@ -351,6 +360,10 @@ function AgentStepContainer({ setSignInFailedProviderId(null); signInRequestRef.current += 1; const request = signInRequestRef.current; + recordTelemetryEvent({ + name: "onboarding_sign_in", + properties: { entry, outcome: "started" }, + }); const title = `Sign in to ${state.displayName}`; await closeAbandonedSignInTerminals({ hostId, title }); let session: TerminalSession; @@ -363,9 +376,17 @@ function AgentStepContainer({ target: { kind: "host_path", hostId, cwd: null }, }); } catch { + recordTelemetryEvent({ + name: "onboarding_sign_in", + properties: { entry, outcome: "failed" }, + }); return; } if (signInRequestRef.current !== request) { + recordTelemetryEvent({ + name: "onboarding_sign_in", + properties: { entry, outcome: "cancelled" }, + }); void sdk.terminals .close({ mode: "force", terminalId: session.id }) .catch(() => null); @@ -478,7 +499,7 @@ function AgentStepContainer({ onSignIn={(providerId) => void startSignIn(providerId)} onInstall={startInstall} onViewInstallLog={viewInstallLog} - onCancelSignIn={endSignIn} + onCancelSignIn={() => endSignIn()} onRecheck={() => { void refetchStates(); void cliStatusQuery.refetch(); @@ -912,11 +933,37 @@ export function OnboardingFlow({ const primaryHost = usePrimaryHost(); const hostId = primaryHost?.id ?? null; useHostDiscoveredRepos(hostId); - const agentStates = useSystemProviderStates({ + const agentStatesQuery = useSystemProviderStates({ enabled: hostId !== null, poll: false, ...(hostId === null ? {} : { hostId }), - }).data?.providers; + }); + const agentStates = agentStatesQuery.data?.providers; + const agentStateRef = useRef(null); + useEffect(() => { + if (agentStates === undefined && !agentStatesQuery.isError) return; + const knownStates = agentStatesQuery.isError ? undefined : agentStates; + const hasUnknown = + knownStates?.some((state) => state.status === "unknown") ?? true; + const installed = + knownStates?.some( + (state) => + state.status !== "not_installed" && state.status !== "unknown", + ) ?? false; + const properties = { + entry, + agent_installed: installed ? true : hasUnknown ? null : false, + agent_ready: hasReadyAgent(knownStates) + ? true + : hasUnknown + ? null + : false, + }; + const key = JSON.stringify(properties); + if (agentStateRef.current === key) return; + agentStateRef.current = key; + recordTelemetryEvent({ name: "onboarding_agent_state", properties }); + }, [agentStates, agentStatesQuery.isError, entry]); const startReportedRef = useRef(entry !== "first_run"); useEffect(() => { if (startReportedRef.current || agentStates === undefined) return; @@ -994,6 +1041,7 @@ export function OnboardingFlow({ return ( undefined); + try { + void sdk.system + .experimental_recordTelemetryEvent(event) + .catch(() => undefined); + } catch {} } diff --git a/apps/app/src/components/pickers/ParentThreadPicker.tsx b/apps/app/src/components/pickers/ParentThreadPicker.tsx index e408ea61343..49c9760a314 100644 --- a/apps/app/src/components/pickers/ParentThreadPicker.tsx +++ b/apps/app/src/components/pickers/ParentThreadPicker.tsx @@ -141,7 +141,7 @@ export function ParentThreadPicker({ No matching threads. ) : ( - + {filteredOptions.map((option) => ( ({ })); vi.mock("@/lib/sdk", () => ({ - sdk: { projects: { attachments: { copy: mocks.copyAttachments } } }, + sdk: { + projects: { attachments: { copy: mocks.copyAttachments } }, + system: { + experimental_recordTelemetryEvent: vi + .fn() + .mockRejectedValue(new Error("Telemetry unavailable")), + }, + }, })); const PROJECT = makeProjectWithThreadsResponse({ diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx index 2c49682d295..acd4305d2c0 100644 --- a/apps/app/src/components/plugin/PluginSlotMount.tsx +++ b/apps/app/src/components/plugin/PluginSlotMount.tsx @@ -8,7 +8,17 @@ import { type PluginSlotOwnershipRegistry, } from "./plugin-context"; -const crashedSlotInstances = new Set(); +const crashedSlotInstances = new Map(); + +function isSlotInstanceCrashed( + instanceKey: string, + resetKey: string | undefined, +): boolean { + return ( + crashedSlotInstances.has(instanceKey) && + crashedSlotInstances.get(instanceKey) === resetKey + ); +} const ownedStateReleasesBySlotInstance = new Map< string, Map void> @@ -55,7 +65,7 @@ function pluginSlotInstanceKey( export function resetCrashedPluginSlots(pluginId: string): void { const prefix = `${pluginId}/`; - for (const key of [...crashedSlotInstances]) { + for (const key of [...crashedSlotInstances.keys()]) { if (key.startsWith(prefix)) crashedSlotInstances.delete(key); } } @@ -77,11 +87,13 @@ interface PluginSlotBoundaryProps { instanceKey: string; children: ReactNode; fallback?: ReactNode; + resetKey?: string; onCrash?: (pluginId: string) => void; } interface PluginSlotBoundaryState { crashed: boolean; + resetKey: string | undefined; } class PluginSlotBoundary extends Component< @@ -103,19 +115,37 @@ class PluginSlotBoundary extends Component< constructor(props: PluginSlotBoundaryProps) { super(props); - this.state = { crashed: crashedSlotInstances.has(props.instanceKey) }; + this.state = { + crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey), + resetKey: props.resetKey, + }; } - static getDerivedStateFromError(): PluginSlotBoundaryState { + static getDerivedStateFromError(): Pick { return { crashed: true }; } + static getDerivedStateFromProps( + props: PluginSlotBoundaryProps, + state: PluginSlotBoundaryState, + ): PluginSlotBoundaryState | null { + if (props.resetKey === state.resetKey) { + return null; + } + return { + crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey), + resetKey: props.resetKey, + }; + } + override componentDidCatch(error: Error, info: ErrorInfo): void { releaseSlotInstanceOwnedState(this.props.instanceKey); this.ownedStateReleases.clear(); - crashedSlotInstances.add(this.props.instanceKey); + crashedSlotInstances.set(this.props.instanceKey, this.props.resetKey); console.warn( - `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`, + this.props.resetKey === undefined + ? `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}` + : `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled until its input changes: ${error.message}`, info.componentStack, ); try { @@ -144,7 +174,7 @@ class PluginSlotBoundary extends Component< override render(): ReactNode { if ( this.state.crashed || - crashedSlotInstances.has(this.props.instanceKey) + isSlotInstanceCrashed(this.props.instanceKey, this.props.resetKey) ) { return this.props.fallback === undefined ? ( @@ -166,6 +196,7 @@ interface PluginSlotMountProps { slotId: string; children: ReactNode; crashFallback?: ReactNode; + crashResetKey?: string; instanceId?: string; onCrash?: (pluginId: string) => void; } @@ -176,6 +207,7 @@ export function PluginSlotMount({ slotId, children, crashFallback, + crashResetKey, instanceId, onCrash, }: PluginSlotMountProps) { @@ -192,6 +224,7 @@ export function PluginSlotMount({ instanceId, )} fallback={crashFallback} + {...(crashResetKey === undefined ? {} : { resetKey: crashResetKey })} {...(onCrash ? { onCrash } : {})} >
{ title: "Shared context", relation: null, }, + { + kind: "thread", + path: "thread:thr_child", + replacement: "thread:thr_child", + projectId: "proj_app", + threadId: "thr_child", + title: "Shared context", + relation: "child", + }, ]; const { promptBoxRef } = renderPromptBox("@shared", { mentionSuggestions: suggestions, @@ -4847,7 +4856,10 @@ describe("PromptBoxInternal mention triggers", () => { "Shared context · parent", "Shared context · same environment", "Shared context", + "Shared context · child thread", ]); + expect(within(menu).getByText("parent")).toBeTruthy(); + expect(within(menu).getByText("child thread")).toBeTruthy(); expect(within(menu).getByText("same environment")).toBeTruthy(); expect(within(menu).getByText("same env").className).toContain("hidden"); }); diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index 0874a81262f..c06ce6efa43 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -32,6 +32,7 @@ import { type ThreadMentionRelation, type TypeaheadMenuState, } from "@bb/client-core"; +import { CHILD_THREAD_NOUN } from "@/lib/child-thread-copy"; export type TypeaheadSuggestion = | PromptMentionSuggestion @@ -108,7 +109,7 @@ function getPathSectionLabel(item: PathMentionSuggestion): string { const THREAD_MENTION_RELATION_LABEL: Record = { parent: "parent", - child: "child", + child: CHILD_THREAD_NOUN, "same-parent": "same parent", "same-environment": "same environment", }; @@ -118,7 +119,7 @@ const THREAD_MENTION_RELATION_COMPACT_LABEL: Record< string > = { parent: "parent", - child: "child", + child: CHILD_THREAD_NOUN, "same-parent": "same parent", "same-environment": "same env", }; diff --git a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx index 3efb9bc518a..e621f1545a4 100644 --- a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx +++ b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx @@ -400,7 +400,7 @@ export function Overview() {
+ {isHostActive ? ( <> diff --git a/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx b/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx index 19da5aec7cb..bc26037c716 100644 --- a/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx +++ b/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx @@ -3,9 +3,10 @@ import { resolveThreadListIndicator, threadListIndicatorStateForThread, } from "@bb/client-core"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; import { ThreadStatusGlyph } from "@/components/thread/ThreadStatusGlyph"; import { ThreadTitle } from "@/components/thread/ThreadTitleMentions"; -import { useThreads } from "@/hooks/queries/thread-queries"; +import { useChildThreads, useThreads } from "@/hooks/queries/thread-queries"; import { getThreadRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { InfoList, InfoListRow, InfoRowTime, InfoSection } from "./info-list"; @@ -15,12 +16,14 @@ interface RelatedThreadsSectionProps { sectionId: string; label: string; threads: readonly ThreadListEntry[]; + idleIcon?: IconName; } export function RelatedThreadsSection({ sectionId, label, threads, + idleIcon, }: RelatedThreadsSectionProps) { const collapse = useInfoSectionCollapse(sectionId); if (threads.length === 0) return null; @@ -31,16 +34,18 @@ export function RelatedThreadsSection({ getKey={(relatedThread) => relatedThread.id} renderItem={(relatedThread) => { const title = getThreadDisplayTitle(relatedThread); + const indicator = resolveThreadListIndicator( + threadListIndicatorStateForThread(relatedThread, false), + ); return ( - + {idleIcon !== undefined && indicator === "none" ? ( +