Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/app/src/components/sidebar/ThreadRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactNode } from "react";
Expand Down Expand Up @@ -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 }) => (
Expand Down Expand Up @@ -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(
<ThreadTitleMentionResourcesProvider
sectionNamesById={new Map()}
projectNamesById={new Map()}
threadById={new Map()}
>
<ThreadRowTestHarness
thread={createThread({
title: "Continue from @thread:thr_dcwivn5n8w",
titleFallback: "Continue from @thread:thr_dcwivn5n8w",
})}
/>
</ThreadTitleMentionResourcesProvider>,
);

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(
<ThreadTitleMentionResourcesProvider
Expand Down
131 changes: 102 additions & 29 deletions apps/app/src/components/thread/ThreadTitleMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,20 +486,30 @@ function threadMentionResource(
};
}

const UNRESOLVED_THREAD_MENTION_LABEL = "Thread";

function unresolvedThreadMentionResource(
threadId: string,
): PromptMentionResource {
return {
kind: "thread",
threadId,
label: UNRESOLVED_THREAD_MENTION_LABEL,
};
}

function resolveTitleMentionResource(
token: string,
resources: ThreadTitleMentionResources,
): PromptMentionResource {
): PromptMentionResource | null {
const serializedValue = token.slice(1);
if (serializedValue.startsWith("thread:")) {
const threadId = serializedValue.slice("thread:".length);
return (
threadMentionResource(threadId, resources) ?? {
kind: "thread",
threadId,
label: threadId,
}
);
const resource = threadMentionResource(threadId, resources);
if (resource !== null || isRawThreadId(threadId)) {
return resource;
}
return { kind: "thread", threadId, label: threadId };
}

if (serializedValue.startsWith("project:")) {
Expand Down Expand Up @@ -533,12 +543,17 @@ function resolveTitleMentionResource(
}

interface ThreadTitleTextSegment {
rawThreadId: string | null;
unresolvedThreadId: string | null;
resource: PromptMentionResource | null;
serializedText: string | null;
text: string;
}

function serializedThreadMentionId(token: string): string | null {
const prefix = "@thread:";
return token.startsWith(prefix) ? token.slice(prefix.length) : null;
}

function threadTitleTextSegments(
title: string,
resources: ThreadTitleMentionResources,
Expand Down Expand Up @@ -567,29 +582,36 @@ function threadTitleTextSegments(
}
if (match.index > 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;
}

if (segments.length === 0) {
return [
{
rawThreadId: null,
unresolvedThreadId: null,
resource: null,
serializedText: null,
text: title,
Expand All @@ -598,7 +620,7 @@ function threadTitleTextSegments(
}
if (cursor < title.length) {
segments.push({
rawThreadId: null,
unresolvedThreadId: null,
resource: null,
serializedText: null,
text: title.slice(cursor),
Expand All @@ -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<string>();
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],
);
}

/**
Expand Down Expand Up @@ -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<ThreadResponse>(
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<string, PromptMentionResource>();
Expand All @@ -743,36 +796,56 @@ 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 ? (
<PromptMentionPill
interactive={false}
resource={unresolvedThreadMentionResource(threadId)}
serializedText={serializedText}
/>
) : (
threadId
);
}
return (
<PromptMentionPill
interactive={false}
resource={resource}
serializedText={threadId}
serializedText={serializedText}
/>
);
}

function ThreadTitleMentionsContent({ title }: { title: string }) {
const resources = useContext(ThreadTitleMentionResourcesContext);
return threadTitleTextSegments(title, resources).map((segment, index) =>
segment.rawThreadId !== null ? (
<RawThreadTitleMention
key={`${index}:${segment.rawThreadId}`}
threadId={segment.rawThreadId}
segment.unresolvedThreadId !== null && segment.serializedText !== null ? (
<ResolvingThreadTitleMention
key={`${index}:${segment.unresolvedThreadId}`}
renderFallbackPill={segment.serializedText.startsWith("@thread:")}
serializedText={segment.serializedText}
threadId={segment.unresolvedThreadId}
/>
) : segment.resource === null || segment.serializedText === null ? (
segment.text
Expand Down
Loading