1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { findForMessageMatch, openThreadFindOnQuery } from "./chat/threadFindStore";
3import {
4 claimThreadOpen,
5 nextShownOrder,
6 type EnvironmentThreadSearchMatch,
7 type ShownOrder,
8} from "@t3tools/client-runtime/state/thread-search";
9import {
10 foldSearchQuery,
11 PALETTE_MESSAGE_LIMIT,
12 pickedQuery,
13 rankMessageMatches,
14} from "@t3tools/shared/paletteSearch";
15import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
16import { useAtomValue } from "@effect/atom-react";
17import { useNavigate } from "@tanstack/react-router";
18import { MessageSquareIcon } from "lucide-react";
19import { useState } from "react";
20
21import {
22 applyProviderInstanceSettings,
23 deriveProviderInstanceEntries,
24 type ProviderInstanceEntry,
25} from "../providerInstances";
26import { useVisibleThreadShells } from "../state/entities";
27import {
28 useEnvironmentProviders,
29 useEnvironments,
30 usePrimaryEnvironmentId,
31} from "../state/environments";
32import { useThreadSearch } from "../state/queries";
33import { primaryServerProvidersAtom } from "../state/server";
34import { threadEnvironment } from "../state/threads";
35import { useAtomCommand } from "../state/use-atom-command";
36import { buildThreadRouteParams } from "../threadRoutes";
37import { formatRelativeTimeLabel } from "../timestampFormat";
38import { buildThreadActionItems, ITEM_ICON_CLASS } from "./CommandPalette.logic";
39import { useEnvironmentLookups, useProjectLookups } from "./CommandPaletteLookups";
40import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle";
41import {
42 ThreadRowLeadingStatus,
43 ThreadRowTrailingStatus,
44 ThreadSearchPullRequestNumber,
45} from "./ThreadStatusIndicators";
46
47function useProviderEntryByEnvironmentAndInstanceId() {
48 const { environments } = useEnvironments();
49 const primaryEnvironmentId = usePrimaryEnvironmentId();
50 const providers = useAtomValue(primaryServerProvidersAtom);
51 const providersByEnvironment = useEnvironmentProviders();
52 return new Map<string, ProviderInstanceEntry>(
53 environments.flatMap((environment) => {
54 const serverConfig = environment.serverConfig;
55 const derived = deriveProviderInstanceEntries(
56 providersByEnvironment.get(environment.environmentId) ??
57 (environment.environmentId === primaryEnvironmentId ? providers : []),
58 );
59 const entries = serverConfig
60 ? applyProviderInstanceSettings(derived, serverConfig.settings, null)
61 : derived;
62 return entries.map(
63 (entry) => [`${environment.environmentId}:${entry.instanceId}`, entry] as const,
64 );
65 }),
66 );
67}
68
69function useConnectedThreadSearch(query: string) {
70 const { environments } = useEnvironments();
71 const { projectEnvironmentLocationById } = useEnvironmentLookups();
72 const environmentIds: ReadonlyArray<EnvironmentId> = environments
73 .filter((environment) => environment.connection.phase === "connected")
74 .map((environment) => environment.environmentId);
75 // The palette matches environment names when there are several; older rows must match the same.
76 const environmentLabels =
77 environments.length > 1
78 ? Object.fromEntries(
79 environmentIds.flatMap((environmentId) => {
80 const label = projectEnvironmentLocationById.get(environmentId)?.label;
81 return label === undefined || label === null ? [] : [[environmentId, label]];
82 }),
83 )
84 : undefined;
85 return useThreadSearch(
86 environmentIds,
87 query,
88 environmentLabels === undefined ? {} : { environmentLabels },
89 );
90}
91
92/**
93 * Every visible thread as a palette item, plus, for a non-empty
94 * `threadSearchQuery`, the threads whose messages match, best first.
95 */
96export function useCommandPaletteThreadItems(input: {
97 readonly activeThread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null;
98 readonly threadSearchQuery: string;
99}) {
100 const { activeThread, threadSearchQuery } = input;
101 const navigate = useNavigate();
102 const threads = useVisibleThreadShells();
103 const { projectEnvironmentLocationById } = useEnvironmentLookups();
104 const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId();
105 const messageSearch = useConnectedThreadSearch(threadSearchQuery);
106 const { projectByKey } = useProjectLookups();
107 const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
108 // Ranking is relative to when the palette opened, so rows do not reorder as time passes.
109 const [now] = useState(() => Date.now());
110
111 const { environments } = useEnvironments();
112 const threadItems = buildThreadActionItems({
113 threads,
114 ...(environments.length > 1
115 ? {
116 environmentLabelOf: (thread: (typeof threads)[number]) =>
117 projectEnvironmentLocationById.get(thread.environmentId)?.label ?? null,
118 }
119 : {}),
120 ...(activeThread ? { activeThread } : {}),
121 projectByKey,
122 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
123 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
124 renderTrailingContent: (thread) => (
125 <>
126 <ThreadSearchPullRequestNumber
127 thread={thread}
128 query={threadSearchQuery}
129 settled={thread.settledOverride === "settled"}
130 />
131 <ThreadRowTrailingStatus thread={thread} />
132 </>
133 ),
134 renderDescription: (thread, { projectTitle }) => {
135 const modelInstanceId =
136 thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
137 const providerEntry =
138 providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${modelInstanceId}`) ??
139 null;
140 return (
141 <ThreadCommandSubtitle
142 project={projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null}
143 projectTitle={projectTitle ?? null}
144 environmentLabel={
145 projectEnvironmentLocationById.get(thread.environmentId)?.label ?? "Remote"
146 }
147 branch={thread.branch}
148 worktreePath={thread.worktreePath}
149 isCurrent={
150 thread.id === activeThread?.id && thread.environmentId === activeThread.environmentId
151 }
152 driverKind={providerEntry?.driverKind ?? null}
153 providerDisplayName={
154 thread.runtime?.providerName ?? providerEntry?.displayName ?? modelInstanceId
155 }
156 acpRegistryAgentId={providerEntry?.acpRegistryAgentId}
157 acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl}
158 />
159 );
160 },
161 runThread: async (thread) => {
162 // A pick sends its open with the query, always, so the server records
163 // it; the thread view then skips its own open. The open thread is no pick.
164 const isOpen =
165 activeThread?.environmentId === thread.environmentId && activeThread.id === thread.id;
166 if (threadSearchQuery.trim().length > 0 && !threadSearchQuery.startsWith(">") && !isOpen) {
167 // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
168 claimThreadOpen(thread.environmentId, thread.id, Date.now());
169 }
170 if (
171 threadSearchQuery.trim().length > 0 &&
172 !threadSearchQuery.startsWith(">") &&
173 thread.lastVisitedAt !== undefined &&
174 !isOpen
175 ) {
176 void visitThread({
177 environmentId: thread.environmentId,
178 input: {
179 threadId: thread.id,
180 visitedAt: thread.updatedAt,
181 opened: true,
182 searchQuery: pickedQuery(threadSearchQuery),
183 },
184 });
185 }
186 await navigate({
187 to: "/$environmentId/$threadId",
188 params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
189 });
190 },
191 });
192
193 const itemByValue = new Map(threadItems.map((item) => [item.value, item]));
194 const itemForMatch = (match: EnvironmentThreadSearchMatch) =>
195 itemByValue.get(`thread:${match.environmentId}:${match.threadId}`);
196 const rankedMessageItems = rankMessageMatches(
197 messageSearch.matches,
198 (match) => itemForMatch(match)?.threadSearch,
199 foldSearchQuery(threadSearchQuery),
200 now,
201 { withoutTitleMatches: true },
202 ).flatMap((match) => {
203 const item = itemForMatch(match);
204 if (!item) return [];
205 // The snippet replaces the project line, so a message row is as tall as a title row.
206 const { description: _description, ...rest } = item;
207 return [
208 {
209 ...rest,
210 // When the matching message was written, not when the thread last moved.
211 ...(match.messageCreatedAt
212 ? { timestamp: formatRelativeTimeLabel(match.messageCreatedAt) }
213 : {}),
214 threadContentMatch: {
215 source: match.source,
216 snippet: match.snippet,
217 query: messageSearch.query,
218 },
219 // Opens the thread with find on the word at the matched message, so it is in view.
220 run: async () => {
221 await item.run();
222 openThreadFindOnQuery(
223 scopedThreadKey(scopeThreadRef(match.environmentId, match.threadId)),
224 findForMessageMatch(match, messageSearch.query),
225 );
226 },
227 },
228 ];
229 });
230
231 // Rows already shown keep their places when a slower environment answers.
232 const [shownOrder, setShownOrder] = useState<ShownOrder | null>(null);
233 const order = nextShownOrder(
234 shownOrder,
235 messageSearch.query,
236 rankedMessageItems.map((item) => item.value),
237 PALETTE_MESSAGE_LIMIT,
238 );
239 if (order !== shownOrder) setShownOrder(order);
240 const messageItemByValue = new Map(rankedMessageItems.map((item) => [item.value, item]));
241 const threadMessageItems = order.keys
242 .slice(0, order.shown)
243 .flatMap((value) => messageItemByValue.get(value) ?? []);
244
245 return {
246 threadItems,
247 threadMessageItems,
248 now,
249 isSearchPending: messageSearch.isPending,
250 };
251}