From 11ade3dedff83c3560aa7239daad55947b828073 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 12 Aug 2026 18:42:33 -0700 Subject: [PATCH 1/2] Fix side chat activation latency --- plugins/side-chat/app.tsx | 44 ++++++++++++++++++++++++-------- plugins/side-chat/server.test.ts | 20 +++++++++++++++ plugins/side-chat/server.ts | 40 +++++++++++++++++++---------- 3 files changed, 80 insertions(+), 24 deletions(-) diff --git a/plugins/side-chat/app.tsx b/plugins/side-chat/app.tsx index 451d1811fa..b4164089f8 100644 --- a/plugins/side-chat/app.tsx +++ b/plugins/side-chat/app.tsx @@ -5,7 +5,7 @@ // with the host-owned ThreadChat: a "Replying to" header above the // conversation, a per-message "Send to main thread" action on assistant // messages. -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { Icon } from "@bb/shared-ui/icon"; import { @@ -120,6 +120,7 @@ interface OpenSideChatArgs { * deliberate re-open still works. */ const inFlightOpens = new Map>(); +const panelHydrationStarts = new Map(); function openKey({ sourceThreadId, @@ -171,15 +172,28 @@ async function createAndOpenSideChat({ ); throw error; } - openPanel({ - title: PANEL_TAB_TITLE, - params: { - threadId, - sourceThreadId, - sourceMessageText: anchorText, - sourceSeqEnd, - }, - }); + const panelHydrationStartedAt = performance.now(); + panelHydrationStarts.set(threadId, panelHydrationStartedAt); + try { + const opened = openPanel({ + title: PANEL_TAB_TITLE, + params: { + threadId, + sourceThreadId, + sourceMessageText: anchorText, + sourceSeqEnd, + }, + }); + if (opened === false) { + panelHydrationStarts.delete(threadId); + console.debug( + `[plugin:side-chat] createSideChat panel hydration sourceThreadId=${sourceThreadId} threadId=${threadId} outcome=rejected durationMs=${(performance.now() - panelHydrationStartedAt).toFixed(1)}`, + ); + } + } catch (error) { + panelHydrationStarts.delete(threadId); + throw error; + } } function ReplyingTo({ anchorText }: { anchorText: string }) { @@ -247,6 +261,16 @@ function SideChatPanel({ params }: PluginThreadPanelProps) { const sideChatThreadId = parsed?.threadId ?? null; const sourceThreadId = parsed?.sourceThreadId ?? null; + useEffect(() => { + if (sideChatThreadId === null) return; + const startedAt = panelHydrationStarts.get(sideChatThreadId); + if (startedAt === undefined) return; + panelHydrationStarts.delete(sideChatThreadId); + console.debug( + `[plugin:side-chat] createSideChat panel hydration sourceThreadId=${sourceThreadId} threadId=${sideChatThreadId} outcome=mounted durationMs=${(performance.now() - startedAt).toFixed(1)}`, + ); + }, [sideChatThreadId]); + const sendToMain = useCallback( async (message: { text: string; threadId: string }) => { if (sourceThreadId === null || sideChatThreadId === null) return; diff --git a/plugins/side-chat/server.test.ts b/plugins/side-chat/server.test.ts index c7be8177fa..4100d64c71 100644 --- a/plugins/side-chat/server.test.ts +++ b/plugins/side-chat/server.test.ts @@ -92,6 +92,26 @@ describe("resolveReplySeedText", () => { }); describe("createSideChat rpc", () => { + it("limits the reply-seed lookup to the latest timeline segment", async () => { + const timeline = vi.fn(async () => + timelineResult([conversationRow("latest answer")]), + ); + const fork = vi.fn(async () => makeThreadResponse({ id: "thr_fork" })); + const { harness } = await loadPlugin({ timeline, fork }); + + await harness.callRpc("createSideChat", { + sourceThreadId: "thr_src", + sourceSeqEnd: 7, + anchorText: "latest answer", + }); + + expect(timeline).toHaveBeenCalledWith({ + threadId: "thr_src", + includeNestedRows: "true", + segmentLimit: "1", + }); + }); + it("forks hidden+isolated with a seed when the anchor is an earlier message", async () => { const fork = vi.fn(async () => makeThreadResponse({ id: "thr_fork" })); const { harness } = await loadPlugin({ diff --git a/plugins/side-chat/server.ts b/plugins/side-chat/server.ts index e56c2169b6..d84b2354b5 100644 --- a/plugins/side-chat/server.ts +++ b/plugins/side-chat/server.ts @@ -176,10 +176,15 @@ export const sideChatRpcContract = defineRpcContract({ export default async function plugin(bb: BbPluginApi) { bb.rpc.register(sideChatRpcContract, { async createSideChat({ sourceThreadId, sourceSeqEnd, anchorText }) { + const timelineStartedAt = performance.now(); const timeline = await bb.sdk.threads.timeline({ threadId: sourceThreadId, includeNestedRows: "true", + segmentLimit: "1", }); + bb.log.debug( + `createSideChat timeline lookup sourceThreadId=${sourceThreadId} durationMs=${(performance.now() - timelineStartedAt).toFixed(1)}`, + ); const seedText = resolveReplySeedText({ anchorText, sourceTimelineRows: timeline.rows, @@ -206,22 +211,29 @@ export default async function plugin(bb: BbPluginApi) { } : {}), }; + const forkStartedAt = performance.now(); try { - const fork = await bb.sdk.threads.fork({ - ...forkArgs, - ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), - }); - return { threadId: fork.id }; - } catch (error) { - // Messages earlier than the source's first provider session (e.g. the - // opening user message) have no point-in-time session to clone. The - // legacy side chat always forked from the tip; fall back to that so - // those anchors keep working — the reply seed still marks the anchor. - if (sourceSeqEnd === undefined || !isSessionUnavailableError(error)) { - throw error; + try { + const fork = await bb.sdk.threads.fork({ + ...forkArgs, + ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), + }); + return { threadId: fork.id }; + } catch (error) { + // Messages earlier than the source's first provider session (e.g. the + // opening user message) have no point-in-time session to clone. The + // legacy side chat always forked from the tip; fall back to that so + // those anchors keep working — the reply seed still marks the anchor. + if (sourceSeqEnd === undefined || !isSessionUnavailableError(error)) { + throw error; + } + const fork = await bb.sdk.threads.fork(forkArgs); + return { threadId: fork.id }; } - const fork = await bb.sdk.threads.fork(forkArgs); - return { threadId: fork.id }; + } finally { + bb.log.debug( + `createSideChat fork persistence sourceThreadId=${sourceThreadId} durationMs=${(performance.now() - forkStartedAt).toFixed(1)}`, + ); } }, async sendToMain({ sourceThreadId, senderThreadId, text }) { From 11ef8cff865e3b021aecf0cb025a71953c30cc74 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 18 Aug 2026 15:13:51 -0700 Subject: [PATCH 2/2] Narrow the side-chat reply-seed timeline lookup to one segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSideChat only reads the source thread's last conversation message (the reply-seed rule), but requested the timeline with no segmentLimit — the default 20 segments of fully nested rows, projected synchronously on the activation path. Ask for segmentLimit=1. Every timeline segment is anchored at a user message, so the newest segment always holds the newest conversation row; the seed answer is unchanged. Measured on a synthetic 40-turn x 30-item thread via buildThreadTimeline: 620 rows / 455 KB / 8.6 ms at the default limit vs 31 rows / 22.8 KB / 1.2 ms at segmentLimit=1, identical last-conversation-message result. Drops the measurement scaffolding the original change carried (panel and fork timing logs, the try/finally restructure, the panelHydrationStarts map — which also leaked an entry whenever a panel never mounted, since the launcher path's openPanel returns void and never hit the cleanup). --- plugins/side-chat/app.tsx | 44 +++++++++---------------------------- plugins/side-chat/server.ts | 43 +++++++++++++++--------------------- 2 files changed, 28 insertions(+), 59 deletions(-) diff --git a/plugins/side-chat/app.tsx b/plugins/side-chat/app.tsx index cdf288d281..fe9c5949b8 100644 --- a/plugins/side-chat/app.tsx +++ b/plugins/side-chat/app.tsx @@ -5,7 +5,7 @@ // with the host-owned ThreadChat: a "Replying to" header above the // conversation, a per-message "Send to main thread" action on assistant // messages. -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Icon } from "@bb/shared-ui/icon"; import { @@ -123,7 +123,6 @@ interface OpenSideChatArgs { * deliberate re-open still works. */ const inFlightOpens = new Map>(); -const panelHydrationStarts = new Map(); function openKey({ sourceThreadId, @@ -175,28 +174,15 @@ async function createAndOpenSideChat({ ); throw error; } - const panelHydrationStartedAt = performance.now(); - panelHydrationStarts.set(threadId, panelHydrationStartedAt); - try { - const opened = openPanel({ - title: PANEL_TAB_TITLE, - params: { - threadId, - sourceThreadId, - sourceMessageText: anchorText, - sourceSeqEnd, - }, - }); - if (opened === false) { - panelHydrationStarts.delete(threadId); - console.debug( - `[plugin:side-chat] createSideChat panel hydration sourceThreadId=${sourceThreadId} threadId=${threadId} outcome=rejected durationMs=${(performance.now() - panelHydrationStartedAt).toFixed(1)}`, - ); - } - } catch (error) { - panelHydrationStarts.delete(threadId); - throw error; - } + openPanel({ + title: PANEL_TAB_TITLE, + params: { + threadId, + sourceThreadId, + sourceMessageText: anchorText, + sourceSeqEnd, + }, + }); } function ReplyingTo({ anchorText }: { anchorText: string }) { @@ -264,16 +250,6 @@ function SideChatPanel({ params }: PluginThreadPanelProps) { const sideChatThreadId = parsed?.threadId ?? null; const sourceThreadId = parsed?.sourceThreadId ?? null; - useEffect(() => { - if (sideChatThreadId === null) return; - const startedAt = panelHydrationStarts.get(sideChatThreadId); - if (startedAt === undefined) return; - panelHydrationStarts.delete(sideChatThreadId); - console.debug( - `[plugin:side-chat] createSideChat panel hydration sourceThreadId=${sourceThreadId} threadId=${sideChatThreadId} outcome=mounted durationMs=${(performance.now() - startedAt).toFixed(1)}`, - ); - }, [sideChatThreadId]); - const sendToMain = useCallback( async (message: { text: string; threadId: string }) => { if (sourceThreadId === null || sideChatThreadId === null) return; diff --git a/plugins/side-chat/server.ts b/plugins/side-chat/server.ts index 706496ca42..486172166c 100644 --- a/plugins/side-chat/server.ts +++ b/plugins/side-chat/server.ts @@ -176,15 +176,15 @@ export const sideChatRpcContract = defineRpcContract({ export default async function plugin(bb: BbPluginApi) { bb.rpc.register(sideChatRpcContract, { async createSideChat({ sourceThreadId, sourceSeqEnd, anchorText }) { - const timelineStartedAt = performance.now(); + // The seed rule reads only the source's *last* conversation message, and + // every timeline segment is anchored at a user message — so the newest + // segment always holds it. Without this the lookup projects the default + // 20 segments of nested rows on the activation path. const timeline = await bb.sdk.threads.timeline({ threadId: sourceThreadId, includeNestedRows: "true", segmentLimit: "1", }); - bb.log.debug( - `createSideChat timeline lookup sourceThreadId=${sourceThreadId} durationMs=${(performance.now() - timelineStartedAt).toFixed(1)}`, - ); const seedText = resolveReplySeedText({ anchorText, sourceTimelineRows: timeline.rows, @@ -211,29 +211,22 @@ export default async function plugin(bb: BbPluginApi) { } : {}), }; - const forkStartedAt = performance.now(); try { - try { - const fork = await bb.sdk.threads.fork({ - ...forkArgs, - ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), - }); - return { threadId: fork.id }; - } catch (error) { - // Messages earlier than the source's first provider session (e.g. the - // opening user message) have no point-in-time session to clone. The - // legacy side chat always forked from the tip; fall back to that so - // those anchors keep working — the reply seed still marks the anchor. - if (sourceSeqEnd === undefined || !isSessionUnavailableError(error)) { - throw error; - } - const fork = await bb.sdk.threads.fork(forkArgs); - return { threadId: fork.id }; + const fork = await bb.sdk.threads.fork({ + ...forkArgs, + ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), + }); + return { threadId: fork.id }; + } catch (error) { + // Messages earlier than the source's first provider session (e.g. the + // opening user message) have no point-in-time session to clone. The + // legacy side chat always forked from the tip; fall back to that so + // those anchors keep working — the reply seed still marks the anchor. + if (sourceSeqEnd === undefined || !isSessionUnavailableError(error)) { + throw error; } - } finally { - bb.log.debug( - `createSideChat fork persistence sourceThreadId=${sourceThreadId} durationMs=${(performance.now() - forkStartedAt).toFixed(1)}`, - ); + const fork = await bb.sdk.threads.fork(forkArgs); + return { threadId: fork.id }; } }, async sendToMain({ sourceThreadId, senderThreadId, text }) {