From 02bba578d9dc5391ff3ad208199668dd29f296de Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:02:25 +0000 Subject: [PATCH] Resolve archived thread names in sidebar mentions --- .../src/components/sidebar/ThreadRow.test.tsx | 40 ++++++ .../components/thread/ThreadTitleMentions.tsx | 131 ++++++++++++++---- 2 files changed, 142 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 396f56ed4e..29d5c4636c 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, render, screen, + waitFor, } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; @@ -45,6 +46,7 @@ import { import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence"; import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; +import { sdk } from "@/lib/sdk"; vi.mock("@/components/thread/ThreadActionsMenu", () => ({ ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => ( @@ -653,6 +655,44 @@ describe("ThreadRow", () => { expect(screen.getByTitle(resolvedTitle)).not.toBeNull(); }); + it("resolves a serialized thread title mention outside the sidebar cache", async () => { + const resolveMentions = vi + .spyOn(sdk.threads, "resolveMentions") + .mockResolvedValue([ + { + threadId: "thr_dcwivn5n8w", + projectId: "proj_mentioned", + label: "Mention target", + }, + ]); + + render( + + + , + ); + + expect(screen.queryByText("thr_dcwivn5n8w")).toBeNull(); + expect( + screen.getByRole("link", { name: "Open Continue from Thread" }), + ).not.toBeNull(); + await waitFor(() => expect(resolveMentions).toHaveBeenCalledTimes(1)); + expect(screen.getByText("Mention target")).not.toBeNull(); + expect(screen.queryByText("thr_dcwivn5n8w")).toBeNull(); + expect( + screen.getByRole("link", { name: "Open Continue from Mention target" }), + ).not.toBeNull(); + }); + it("marks a child from another project with the project name", () => { const { container } = render( cursor) { segments.push({ - rawThreadId: null, + unresolvedThreadId: null, resource: null, serializedText: null, text: title.slice(cursor, match.index), }); } + const serializedThreadId = + rawThreadId === null ? serializedThreadMentionId(token) : null; const resource = rawThreadId === null ? resolveTitleMentionResource(token, resources) : threadMentionResource(rawThreadId, resources); + const unresolvedThreadId = + resource === null ? (rawThreadId ?? serializedThreadId) : null; segments.push({ - rawThreadId: resource === null ? rawThreadId : null, + unresolvedThreadId, resource, - serializedText: resource === null ? null : token, - text: resource?.label ?? token, + serializedText: + resource === null && unresolvedThreadId === null ? null : token, + text: + resource?.label ?? + (serializedThreadId === null ? token : UNRESOLVED_THREAD_MENTION_LABEL), }); cursor = matchEnd; } @@ -589,7 +611,7 @@ function threadTitleTextSegments( if (segments.length === 0) { return [ { - rawThreadId: null, + unresolvedThreadId: null, resource: null, serializedText: null, text: title, @@ -598,7 +620,7 @@ function threadTitleTextSegments( } if (cursor < title.length) { segments.push({ - rawThreadId: null, + unresolvedThreadId: null, resource: null, serializedText: null, text: title.slice(cursor), @@ -620,10 +642,32 @@ export function resolveThreadTitleDisplayText( /** Resolves serialized mentions in a thread title to one plain display label. */ export function useThreadTitleDisplayText(title: string): string { const resources = useContext(ThreadTitleMentionResourcesContext); - return useMemo( - () => resolveThreadTitleDisplayText(title, resources), + const segments = useMemo( + () => threadTitleTextSegments(title, resources), [resources, title], ); + const unresolvedThreadIds = useMemo(() => { + const threadIds = new Set(); + for (const segment of segments) { + if (segment.unresolvedThreadId !== null) { + threadIds.add(segment.unresolvedThreadId); + } + } + return [...threadIds]; + }, [segments]); + const resolvedThreadsById = useRawThreadMentionResources(unresolvedThreadIds); + return useMemo( + () => + segments + .map((segment) => + segment.unresolvedThreadId === null + ? segment.text + : (resolvedThreadsById.get(segment.unresolvedThreadId)?.label ?? + segment.text), + ) + .join(""), + [resolvedThreadsById, segments], + ); } /** @@ -711,17 +755,26 @@ export function useRawThreadMentionResources( const resources = useContext(ThreadTitleMentionResourcesContext); const queryClient = useContext(QueryClientContext); const batch = useContext(RawThreadMentionBatchContext); + const resolver = useContext(RawThreadMentionResolverContext); + const resolutionContext = + batch === EMPTY_RAW_THREAD_MENTION_BATCH ? resolver : batch; useEffect(() => { + let registeredCount = 0; for (const threadId of threadIds) { const sidebarResource = threadMentionResource(threadId, resources); const cachedThread = queryClient?.getQueryData( threadQueryKey(threadId), ); - if (sidebarResource === null && cachedThread === undefined) { - batch.register(threadId); + if ( + sidebarResource === null && + cachedThread === undefined && + registeredCount < THREAD_MENTION_RESOLVE_MAX_IDS + ) { + resolutionContext.register(threadId); + registeredCount += 1; } } - }, [batch, queryClient, resources, threadIds]); + }, [queryClient, resolutionContext, resources, threadIds]); return useMemo(() => { const resourceById = new Map(); @@ -743,25 +796,43 @@ export function useRawThreadMentionResources( }); continue; } - const batchResource = batch.resourceById.get(threadId); + const batchResource = resolutionContext.resourceById.get(threadId); if (batchResource !== undefined) { resourceById.set(threadId, batchResource); } } return resourceById; - }, [batch.resourceById, queryClient, resources, threadIds]); + }, [queryClient, resolutionContext.resourceById, resources, threadIds]); +} + +interface ResolvingThreadTitleMentionProps { + renderFallbackPill: boolean; + serializedText: string; + threadId: string; } -function RawThreadTitleMention({ threadId }: { threadId: string }) { +function ResolvingThreadTitleMention({ + renderFallbackPill, + serializedText, + threadId, +}: ResolvingThreadTitleMentionProps) { const resource = useRawThreadMentionResource(threadId); if (resource === null) { - return threadId; + return renderFallbackPill ? ( + + ) : ( + threadId + ); } return ( ); } @@ -769,10 +840,12 @@ function RawThreadTitleMention({ threadId }: { threadId: string }) { function ThreadTitleMentionsContent({ title }: { title: string }) { const resources = useContext(ThreadTitleMentionResourcesContext); return threadTitleTextSegments(title, resources).map((segment, index) => - segment.rawThreadId !== null ? ( - ) : segment.resource === null || segment.serializedText === null ? ( segment.text