| author | |
| committer | |
| log | c557bb10aa7cbb78a50df230d1e8e24af79cb662 |
| tree | 601a5a9d464158301149e76c6348ed76584be66f |
| parent | c03b240265a1d4954536c5c0758ac2b7fa762ba8 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 198 insertions(+), 85 deletions(-)
apps/web/src/components/CommandPaletteResults.tsx+3-65| ... | ... | @@ -14,71 +14,9 @@ import { |
| 14 | 14 | CommandList, |
| 15 | 15 | CommandShortcut, |
| 16 | 16 | } from "./ui/command"; |
| 17 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; | |
| 17 | 18 | import { cn } from "~/lib/utils"; |
| 18 | 19 | |
| 19 | function foldAsciiCase(value: string): string { | |
| 20 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); | |
| 21 | } | |
| 22 | ||
| 23 | function HighlightedSearchText(props: { text: string; query: string }) { | |
| 24 | const query = props.query.trim(); | |
| 25 | if (query.length === 0) return props.text; | |
| 26 | ||
| 27 | const normalizedText = foldAsciiCase(props.text); | |
| 28 | const normalizedQuery = foldAsciiCase(query); | |
| 29 | const parts: Array<{ | |
| 30 | readonly text: string; | |
| 31 | readonly highlighted: boolean; | |
| 32 | readonly start: number; | |
| 33 | }> = []; | |
| 34 | let cursor = 0; | |
| 35 | ||
| 36 | while (cursor < props.text.length) { | |
| 37 | const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); | |
| 38 | if (matchIndex === -1) { | |
| 39 | parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); | |
| 40 | break; | |
| 41 | } | |
| 42 | if (matchIndex > cursor) { | |
| 43 | parts.push({ | |
| 44 | text: props.text.slice(cursor, matchIndex), | |
| 45 | highlighted: false, | |
| 46 | start: cursor, | |
| 47 | }); | |
| 48 | } | |
| 49 | parts.push({ | |
| 50 | text: props.text.slice(matchIndex, matchIndex + query.length), | |
| 51 | highlighted: true, | |
| 52 | start: matchIndex, | |
| 53 | }); | |
| 54 | cursor = matchIndex + query.length; | |
| 55 | } | |
| 56 | ||
| 57 | return parts.map((part) => | |
| 58 | part.highlighted ? ( | |
| 59 | <mark className="bg-transparent font-semibold text-foreground" key={part.start}> | |
| 60 | {part.text} | |
| 61 | </mark> | |
| 62 | ) : ( | |
| 63 | part.text | |
| 64 | ), | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | function ThreadContentMatch(props: { | |
| 69 | match: NonNullable<CommandPaletteActionItem["threadContentMatch"]>; | |
| 70 | }) { | |
| 71 | const isUser = props.match.source === "user"; | |
| 72 | return ( | |
| 73 | <span className="truncate text-xs text-muted-foreground/85"> | |
| 74 | <span className={isUser ? "text-blue-400" : "text-emerald-400"}> | |
| 75 | {isUser ? "You:" : "Agent:"} | |
| 76 | </span>{" "} | |
| 77 | <HighlightedSearchText text={props.match.snippet} query={props.match.query} /> | |
| 78 | </span> | |
| 79 | ); | |
| 80 | } | |
| 81 | ||
| 82 | 20 | interface CommandPaletteResultsProps { |
| 83 | 21 | emptyStateMessage?: string; |
| 84 | 22 | groups: ReadonlyArray<CommandPaletteGroup>; |
| ... | ... | @@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: { |
| 139 | 77 | <span className="truncate">{props.item.title}</span> |
| 140 | 78 | </span> |
| 141 | 79 | {props.item.threadContentMatch ? ( |
| 142 | <ThreadContentMatch match={props.item.threadContentMatch} /> | |
| 80 | <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} /> | |
| 143 | 81 | ) : null} |
| 144 | 82 | {props.item.description ? ( |
| 145 | 83 | <span className="min-w-0 text-muted-foreground/70 text-xs"> |
| ... | ... | @@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: { |
| 190 | 128 | <span className="truncate">{props.item.title}</span> |
| 191 | 129 | </span> |
| 192 | 130 | {props.item.threadContentMatch ? ( |
| 193 | <ThreadContentMatch match={props.item.threadContentMatch} /> | |
| 131 | <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} /> | |
| 194 | 132 | ) : null} |
| 195 | 133 | {props.item.description ? ( |
| 196 | 134 | <span className="min-w-0 text-muted-foreground/70 text-xs"> |
apps/web/src/components/Sidebar.logic.test.ts+38-3| ... | ... | @@ -51,6 +51,7 @@ import { |
| 51 | 51 | type SidebarSection, |
| 52 | 52 | resolveSidebarDropVerb, |
| 53 | 53 | } from "./Sidebar.logic"; |
| 54 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | |
| 54 | 55 | import { |
| 55 | 56 | EnvironmentId, |
| 56 | 57 | OrchestrationLatestTurn, |
| ... | ... | @@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => { |
| 816 | 817 | }); |
| 817 | 818 | |
| 818 | 819 | describe("searchSidebarThreads", () => { |
| 820 | const searchThread = (id: string, title: string, project: string) => ({ | |
| 821 | environmentId: localEnvironmentId, | |
| 822 | id: ThreadId.make(id), | |
| 823 | title, | |
| 824 | project, | |
| 825 | }); | |
| 819 | 826 | const threads = [ |
| 820 | { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, | |
| 821 | { id: "thread-2", title: "Review providers", project: "Workspace" }, | |
| 822 | { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" }, | |
| 827 | searchThread("thread-1", "Fix workspace search", "Alpha"), | |
| 828 | searchThread("thread-2", "Review providers", "Workspace"), | |
| 829 | searchThread("thread-3", "WORKTREE cleanup", "Beta"), | |
| 823 | 830 | ]; |
| 831 | const contentKeys = (...ids: ReadonlyArray<string>) => | |
| 832 | new Set( | |
| 833 | ids.map((id) => | |
| 834 | threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }), | |
| 835 | ), | |
| 836 | ); | |
| 824 | 837 | |
| 825 | 838 | it("matches thread titles case-insensitively and preserves their order", () => { |
| 826 | 839 | expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); |
| ... | ... | @@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => { |
| 833 | 846 | it("returns no results for an empty query", () => { |
| 834 | 847 | expect(searchSidebarThreads(threads, " ")).toEqual([]); |
| 835 | 848 | }); |
| 849 | ||
| 850 | it("appends content-only matches after every title match", () => { | |
| 851 | expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([ | |
| 852 | threads[0], | |
| 853 | threads[2], | |
| 854 | threads[1], | |
| 855 | ]); | |
| 856 | }); | |
| 857 | ||
| 858 | it("lists a thread matching both title and content once", () => { | |
| 859 | expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([ | |
| 860 | threads[0], | |
| 861 | threads[2], | |
| 862 | ]); | |
| 863 | }); | |
| 864 | ||
| 865 | it("ignores content matches for threads outside the sidebar collection", () => { | |
| 866 | expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([ | |
| 867 | threads[0], | |
| 868 | threads[2], | |
| 869 | ]); | |
| 870 | }); | |
| 836 | 871 | }); |
| 837 | 872 | |
| 838 | 873 | describe("filterSidebarProjectScopeItems", () => { |
apps/web/src/components/Sidebar.logic.ts+35-10| ... | ... | @@ -5,7 +5,8 @@ import { |
| 5 | 5 | isAtomCommandInterrupted, |
| 6 | 6 | type AtomCommandResult, |
| 7 | 7 | } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 8 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | |
| 9 | import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 9 | 10 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 10 | 11 | import type { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | 12 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; |
| ... | ... | @@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c |
| 895 | 896 | export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; |
| 896 | 897 | export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; |
| 897 | 898 | |
| 899 | const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>(); | |
| 900 | ||
| 898 | 901 | /** |
| 899 | * Search the already-ordered sidebar thread collection by title or linked PR. | |
| 900 | * Keeping the input order means lifecycle ordering (active, snoozed, settled) | |
| 901 | * remains stable while the user narrows the list. | |
| 902 | * Search the already-ordered sidebar thread collection by title or linked PR, | |
| 903 | * plus any thread whose messages the server matched (`contentMatchKeys`, keyed | |
| 904 | * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering | |
| 905 | * (active, snoozed, settled) remains stable while the user narrows the list. | |
| 902 | 906 | */ |
| 903 | 907 | export function searchSidebarThreads< |
| 904 | T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0], | |
| 905 | >(threads: readonly T[], query: string): T[] { | |
| 908 | T extends { | |
| 909 | readonly environmentId: EnvironmentId; | |
| 910 | readonly id: ThreadId; | |
| 911 | readonly title: string; | |
| 912 | } & Parameters<typeof threadPullRequestSearchTerms>[0], | |
| 913 | >( | |
| 914 | threads: readonly T[], | |
| 915 | query: string, | |
| 916 | contentMatchKeys: ReadonlySet<string> = EMPTY_CONTENT_MATCH_KEYS, | |
| 917 | ): T[] { | |
| 906 | 918 | const normalizedQuery = query.trim().toLowerCase(); |
| 907 | 919 | if (normalizedQuery.length === 0) return []; |
| 908 | return threads.filter((thread) => | |
| 909 | [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => | |
| 920 | const titleMatches: T[] = []; | |
| 921 | const contentMatches: T[] = []; | |
| 922 | for (const thread of threads) { | |
| 923 | const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => | |
| 910 | 924 | term.toLowerCase().includes(normalizedQuery), |
| 911 | ), | |
| 912 | ); | |
| 925 | ); | |
| 926 | if (matchesTitle) { | |
| 927 | titleMatches.push(thread); | |
| 928 | } else if ( | |
| 929 | contentMatchKeys.size > 0 && | |
| 930 | contentMatchKeys.has( | |
| 931 | threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), | |
| 932 | ) | |
| 933 | ) { | |
| 934 | contentMatches.push(thread); | |
| 935 | } | |
| 936 | } | |
| 937 | return [...titleMatches, ...contentMatches]; | |
| 913 | 938 | } |
| 914 | 939 | |
| 915 | 940 | export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: { |
apps/web/src/components/Sidebar.tsx+56-7| ... | ... | @@ -22,6 +22,10 @@ import { |
| 22 | 22 | threadWokeAt, |
| 23 | 23 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 24 | 24 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; |
| 25 | import { | |
| 26 | threadSearchMatchKey, | |
| 27 | type EnvironmentThreadSearchMatch, | |
| 28 | } from "@t3tools/client-runtime/state/thread-search"; | |
| 25 | 29 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 26 | 30 | import { |
| 27 | 31 | parseScopedThreadKey, |
| ... | ... | @@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s |
| 134 | 138 | import { vcsEnvironment } from "../state/vcs"; |
| 135 | 139 | import { threadEnvironment } from "../state/threads"; |
| 136 | 140 | import { useEnvironmentQuery } from "../state/query"; |
| 141 | import { useThreadSearch } from "../state/queries"; | |
| 137 | 142 | import { useAtomCommand } from "../state/use-atom-command"; |
| 138 | 143 | import { |
| 139 | 144 | buildThreadRouteParams, |
| ... | ... | @@ -210,6 +215,7 @@ import { |
| 210 | 215 | type SnoozePreset, |
| 211 | 216 | } from "./Sidebar.snooze"; |
| 212 | 217 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 218 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; | |
| 213 | 219 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| 214 | 220 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 215 | 221 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| ... | ... | @@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2001 | 2007 | isHighlighted: boolean; |
| 2002 | 2008 | isRouteActive: boolean; |
| 2003 | 2009 | resultId: string; |
| 2010 | searchMatch: EnvironmentThreadSearchMatch | null; | |
| 2011 | searchQuery: string; | |
| 2004 | 2012 | onHighlight: () => void; |
| 2005 | 2013 | onSelect: () => void; |
| 2006 | 2014 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; |
| ... | ... | @@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2091 | 2099 | onMouseMove={props.onHighlight} |
| 2092 | 2100 | onClick={props.onSelect} |
| 2093 | 2101 | className={cn( |
| 2094 | "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", | |
| 2102 | "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", | |
| 2095 | 2103 | props.isHighlighted || props.isRouteActive |
| 2096 | 2104 | ? "bg-sidebar-row-active text-sidebar-foreground" |
| 2097 | 2105 | : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| ... | ... | @@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2104 | 2112 | {props.project ? ( |
| 2105 | 2113 | <ProjectFavicon project={props.project} className="size-4 shrink-0" /> |
| 2106 | 2114 | ) : null} |
| 2107 | <span className="min-w-0 flex-1 truncate">{thread.title}</span> | |
| 2108 | <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums"> | |
| 2109 | {threadTimeLabel(thread)} | |
| 2115 | <span className="flex min-w-0 flex-1 flex-col"> | |
| 2116 | <span className="flex min-w-0 items-center gap-2.5"> | |
| 2117 | <span className="min-w-0 flex-1 truncate">{thread.title}</span> | |
| 2118 | <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums"> | |
| 2119 | {threadTimeLabel(thread)} | |
| 2120 | </span> | |
| 2121 | </span> | |
| 2122 | {props.searchMatch ? ( | |
| 2123 | <ThreadSearchMatchExcerpt | |
| 2124 | match={{ | |
| 2125 | source: props.searchMatch.source, | |
| 2126 | snippet: props.searchMatch.snippet, | |
| 2127 | query: props.searchQuery, | |
| 2128 | }} | |
| 2129 | /> | |
| 2130 | ) : null} | |
| 2110 | 2131 | </span> |
| 2111 | 2132 | </TooltipTrigger> |
| 2112 | 2133 | <SidebarThreadTooltip |
| ... | ... | @@ -2625,9 +2646,28 @@ export default function Sidebar() { |
| 2625 | 2646 | () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], |
| 2626 | 2647 | [activeThreads, pinnedThreads, settledThreads, snoozedThreads], |
| 2627 | 2648 | ); |
| 2649 | const searchEnvironmentIds = useMemo( | |
| 2650 | () => | |
| 2651 | environments | |
| 2652 | .filter((environment) => environment.connection.phase === "connected") | |
| 2653 | .map((environment) => environment.environmentId), | |
| 2654 | [environments], | |
| 2655 | ); | |
| 2656 | // useThreadSearch owns the debounce and the two-character floor. | |
| 2657 | const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); | |
| 2658 | const threadSearchMatchByKey = useMemo( | |
| 2659 | () => | |
| 2660 | new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)), | |
| 2661 | [threadSearch.matches], | |
| 2662 | ); | |
| 2628 | 2663 | const threadSearchResults = useMemo( |
| 2629 | () => searchSidebarThreads(searchableThreads, threadSearchQuery), | |
| 2630 | [searchableThreads, threadSearchQuery], | |
| 2664 | () => | |
| 2665 | searchSidebarThreads( | |
| 2666 | searchableThreads, | |
| 2667 | threadSearchQuery, | |
| 2668 | new Set(threadSearchMatchByKey.keys()), | |
| 2669 | ), | |
| 2670 | [searchableThreads, threadSearchQuery, threadSearchMatchByKey], | |
| 2631 | 2671 | ); |
| 2632 | 2672 | const threadSearchResultOrderKey = threadSearchResults |
| 2633 | 2673 | .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) |
| ... | ... | @@ -4594,6 +4634,15 @@ export default function Sidebar() { |
| 4594 | 4634 | isHighlighted={activeSearchResultIndex === index} |
| 4595 | 4635 | isRouteActive={routeThreadKey === threadKey} |
| 4596 | 4636 | resultId={`sidebar-thread-search-result-${index}`} |
| 4637 | searchMatch={ | |
| 4638 | threadSearchMatchByKey.get( | |
| 4639 | threadSearchMatchKey({ | |
| 4640 | environmentId: thread.environmentId, | |
| 4641 | threadId: thread.id, | |
| 4642 | }), | |
| 4643 | ) ?? null | |
| 4644 | } | |
| 4645 | searchQuery={threadSearchQuery} | |
| 4597 | 4646 | onHighlight={() => setActiveSearchResultIndex(index)} |
| 4598 | 4647 | onSelect={() => selectThreadSearchResult(thread)} |
| 4599 | 4648 | onFileDropThreads={handleThreadFileDrop} |
| ... | ... | @@ -4607,7 +4656,7 @@ export default function Sidebar() { |
| 4607 | 4656 | role="status" |
| 4608 | 4657 | className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" |
| 4609 | 4658 | > |
| 4610 | No threads found | |
| 4659 | {threadSearch.isPending ? "Searching thread messages…" : "No threads found"} | |
| 4611 | 4660 | </p> |
| 4612 | 4661 | ) |
| 4613 | 4662 | ) : null} |
apps/web/src/components/ThreadSearchMatch.tsx created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | function foldAsciiCase(value: string): string { | |
| 2 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); | |
| 3 | } | |
| 4 | ||
| 5 | function HighlightedSearchText(props: { text: string; query: string }) { | |
| 6 | const query = props.query.trim(); | |
| 7 | if (query.length === 0) return props.text; | |
| 8 | ||
| 9 | const normalizedText = foldAsciiCase(props.text); | |
| 10 | const normalizedQuery = foldAsciiCase(query); | |
| 11 | const parts: Array<{ | |
| 12 | readonly text: string; | |
| 13 | readonly highlighted: boolean; | |
| 14 | readonly start: number; | |
| 15 | }> = []; | |
| 16 | let cursor = 0; | |
| 17 | ||
| 18 | while (cursor < props.text.length) { | |
| 19 | const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); | |
| 20 | if (matchIndex === -1) { | |
| 21 | parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); | |
| 22 | break; | |
| 23 | } | |
| 24 | if (matchIndex > cursor) { | |
| 25 | parts.push({ | |
| 26 | text: props.text.slice(cursor, matchIndex), | |
| 27 | highlighted: false, | |
| 28 | start: cursor, | |
| 29 | }); | |
| 30 | } | |
| 31 | parts.push({ | |
| 32 | text: props.text.slice(matchIndex, matchIndex + query.length), | |
| 33 | highlighted: true, | |
| 34 | start: matchIndex, | |
| 35 | }); | |
| 36 | cursor = matchIndex + query.length; | |
| 37 | } | |
| 38 | ||
| 39 | return parts.map((part) => | |
| 40 | part.highlighted ? ( | |
| 41 | <mark className="bg-transparent font-semibold text-foreground" key={part.start}> | |
| 42 | {part.text} | |
| 43 | </mark> | |
| 44 | ) : ( | |
| 45 | part.text | |
| 46 | ), | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 50 | export function ThreadSearchMatchExcerpt(props: { | |
| 51 | match: { | |
| 52 | readonly source: "user" | "assistant"; | |
| 53 | readonly snippet: string; | |
| 54 | readonly query: string; | |
| 55 | }; | |
| 56 | }) { | |
| 57 | const isUser = props.match.source === "user"; | |
| 58 | return ( | |
| 59 | <span className="truncate text-xs text-muted-foreground/85"> | |
| 60 | <span className={isUser ? "text-blue-400" : "text-emerald-400"}> | |
| 61 | {isUser ? "You:" : "Agent:"} | |
| 62 | </span>{" "} | |
| 63 | <HighlightedSearchText text={props.match.snippet} query={props.match.query} /> | |
| 64 | </span> | |
| 65 | ); | |
| 66 | } |