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