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