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
68 changes: 3 additions & 65 deletions apps/web/src/components/CommandPaletteResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,71 +14,9 @@ import {
CommandList,
CommandShortcut,
} from "./ui/command";
import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
import { cn } from "~/lib/utils";

function foldAsciiCase(value: string): string {
return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
}

function HighlightedSearchText(props: { text: string; query: string }) {
const query = props.query.trim();
if (query.length === 0) return props.text;

const normalizedText = foldAsciiCase(props.text);
const normalizedQuery = foldAsciiCase(query);
const parts: Array<{
readonly text: string;
readonly highlighted: boolean;
readonly start: number;
}> = [];
let cursor = 0;

while (cursor < props.text.length) {
const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
if (matchIndex === -1) {
parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
break;
}
if (matchIndex > cursor) {
parts.push({
text: props.text.slice(cursor, matchIndex),
highlighted: false,
start: cursor,
});
}
parts.push({
text: props.text.slice(matchIndex, matchIndex + query.length),
highlighted: true,
start: matchIndex,
});
cursor = matchIndex + query.length;
}

return parts.map((part) =>
part.highlighted ? (
<mark className="bg-transparent font-semibold text-foreground" key={part.start}>
{part.text}
</mark>
) : (
part.text
),
);
}

function ThreadContentMatch(props: {
match: NonNullable<CommandPaletteActionItem["threadContentMatch"]>;
}) {
const isUser = props.match.source === "user";
return (
<span className="truncate text-xs text-muted-foreground/85">
<span className={isUser ? "text-blue-400" : "text-emerald-400"}>
{isUser ? "You:" : "Agent:"}
</span>{" "}
<HighlightedSearchText text={props.match.snippet} query={props.match.query} />
</span>
);
}

interface CommandPaletteResultsProps {
emptyStateMessage?: string;
groups: ReadonlyArray<CommandPaletteGroup>;
Expand Down Expand Up @@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: {
<span className="truncate">{props.item.title}</span>
</span>
{props.item.threadContentMatch ? (
<ThreadContentMatch match={props.item.threadContentMatch} />
<ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
) : null}
{props.item.description ? (
<span className="min-w-0 text-muted-foreground/70 text-xs">
Expand Down Expand Up @@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: {
<span className="truncate">{props.item.title}</span>
</span>
{props.item.threadContentMatch ? (
<ThreadContentMatch match={props.item.threadContentMatch} />
<ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
) : null}
{props.item.description ? (
<span className="min-w-0 text-muted-foreground/70 text-xs">
Expand Down
41 changes: 38 additions & 3 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
type SidebarSection,
resolveSidebarDropVerb,
} from "./Sidebar.logic";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
EnvironmentId,
OrchestrationLatestTurn,
Expand Down Expand Up @@ -898,11 +899,23 @@ describe("resolveSidebarThreadStatus", () => {
});

describe("searchSidebarThreads", () => {
const searchThread = (id: string, title: string, project: string) => ({
environmentId: localEnvironmentId,
id: ThreadId.make(id),
title,
project,
});
const threads = [
{ id: "thread-1", title: "Fix workspace search", project: "Alpha" },
{ id: "thread-2", title: "Review providers", project: "Workspace" },
{ id: "thread-3", title: "WORKTREE cleanup", project: "Beta" },
searchThread("thread-1", "Fix workspace search", "Alpha"),
searchThread("thread-2", "Review providers", "Workspace"),
searchThread("thread-3", "WORKTREE cleanup", "Beta"),
];
const contentKeys = (...ids: ReadonlyArray<string>) =>
new Set(
ids.map((id) =>
threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }),
),
);

it("matches thread titles case-insensitively and preserves their order", () => {
expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
Expand All @@ -915,6 +928,28 @@ describe("searchSidebarThreads", () => {
it("returns no results for an empty query", () => {
expect(searchSidebarThreads(threads, " ")).toEqual([]);
});

it("appends content-only matches after every title match", () => {
expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([
threads[0],
threads[2],
threads[1],
]);
});

it("lists a thread matching both title and content once", () => {
expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([
threads[0],
threads[2],
]);
});

it("ignores content matches for threads outside the sidebar collection", () => {
expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([
threads[0],
threads[2],
]);
});
});

describe("filterSidebarProjectScopeItems", () => {
Expand Down
50 changes: 40 additions & 10 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import {
isAtomCommandInterrupted,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import type { ContextMenuItem, OrchestrationThreadShell } from "@t3tools/contracts";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import type {
ContextMenuItem,
EnvironmentId,
OrchestrationThreadShell,
ThreadId,
} from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/unstable/reactivity";
import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
Expand Down Expand Up @@ -942,21 +948,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c
export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";

const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>();

/**
* Search the already-ordered sidebar thread collection by title or linked PR.
* Keeping the input order means lifecycle ordering (active, snoozed, settled)
* remains stable while the user narrows the list.
* Search the already-ordered sidebar thread collection by title or linked PR,
* plus any thread whose messages the server matched (`contentMatchKeys`, keyed
* by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering
* (active, snoozed, settled) remains stable while the user narrows the list.
*/
export function searchSidebarThreads<
T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0],
>(threads: readonly T[], query: string): T[] {
T extends {
readonly environmentId: EnvironmentId;
readonly id: ThreadId;
readonly title: string;
} & Parameters<typeof threadPullRequestSearchTerms>[0],
>(
threads: readonly T[],
query: string,
contentMatchKeys: ReadonlySet<string> = EMPTY_CONTENT_MATCH_KEYS,
): T[] {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) return [];
return threads.filter((thread) =>
[thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
const titleMatches: T[] = [];
const contentMatches: T[] = [];
for (const thread of threads) {
const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
term.toLowerCase().includes(normalizedQuery),
),
);
);
if (matchesTitle) {
titleMatches.push(thread);
} else if (
contentMatchKeys.size > 0 &&
contentMatchKeys.has(
threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
)
) {
contentMatches.push(thread);
}
}
return [...titleMatches, ...contentMatches];
}

export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
Expand Down
63 changes: 56 additions & 7 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
nestDelegatedThreads,
nestedRowContainsThread,
} from "@t3tools/client-runtime/state/delegated-threads";
import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import {
parseScopedThreadKey,
Expand Down Expand Up @@ -138,6 +142,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s
import { vcsEnvironment } from "../state/vcs";
import { threadEnvironment } from "../state/threads";
import { useEnvironmentQuery } from "../state/query";
import { useThreadSearch } from "../state/queries";
import { useAtomCommand } from "../state/use-atom-command";
import {
buildThreadRouteParams,
Expand Down Expand Up @@ -224,6 +229,7 @@ import {
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
Expand Down Expand Up @@ -2107,6 +2113,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
isHighlighted: boolean;
isRouteActive: boolean;
resultId: string;
searchMatch: EnvironmentThreadSearchMatch | null;
searchQuery: string;
onHighlight: () => void;
onSelect: () => void;
onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
Expand Down Expand Up @@ -2196,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
onMouseMove={props.onHighlight}
onClick={props.onSelect}
className={cn(
"flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
"flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none",
props.isHighlighted || props.isRouteActive
? "bg-sidebar-row-active text-sidebar-foreground"
: "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
Expand All @@ -2209,9 +2217,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
{props.project ? (
<ProjectFavicon project={props.project} className="size-4 shrink-0" />
) : null}
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
<span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
{threadTimeLabel(thread)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2.5">
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
<span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
{threadTimeLabel(thread)}
</span>
</span>
{props.searchMatch ? (
<ThreadSearchMatchExcerpt
match={{
source: props.searchMatch.source,
snippet: props.searchMatch.snippet,
query: props.searchQuery,
}}
/>
) : null}
</span>
</TooltipTrigger>
<SidebarThreadTooltip
Expand Down Expand Up @@ -2792,9 +2813,28 @@ export default function Sidebar() {
() => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
[activeThreads, pinnedThreads, settledThreads, snoozedThreads],
);
const searchEnvironmentIds = useMemo(
() =>
environments
.filter((environment) => environment.connection.phase === "connected")
.map((environment) => environment.environmentId),
[environments],
);
// useThreadSearch owns the debounce and the two-character floor.
const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery);
const threadSearchMatchByKey = useMemo(
() =>
new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)),
[threadSearch.matches],
);
const threadSearchResults = useMemo(
() => searchSidebarThreads(searchableThreads, threadSearchQuery),
[searchableThreads, threadSearchQuery],
() =>
searchSidebarThreads(
searchableThreads,
threadSearchQuery,
new Set(threadSearchMatchByKey.keys()),
),
[searchableThreads, threadSearchQuery, threadSearchMatchByKey],
);
const threadSearchResultOrderKey = threadSearchResults
.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
Expand Down Expand Up @@ -4762,6 +4802,15 @@ export default function Sidebar() {
isHighlighted={activeSearchResultIndex === index}
isRouteActive={routeThreadKey === threadKey}
resultId={`sidebar-thread-search-result-${index}`}
searchMatch={
threadSearchMatchByKey.get(
threadSearchMatchKey({
environmentId: thread.environmentId,
threadId: thread.id,
}),
) ?? null
}
searchQuery={threadSearchQuery}
onHighlight={() => setActiveSearchResultIndex(index)}
onSelect={() => selectThreadSearchResult(thread)}
onFileDropThreads={handleThreadFileDrop}
Expand All @@ -4775,7 +4824,7 @@ export default function Sidebar() {
role="status"
className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
>
No threads found
{threadSearch.isPending ? "Searching thread messages…" : "No threads found"}
</p>
)
) : null}
Expand Down
Loading
Loading