Skip to content
Source
Sign inSign up

CommandPalette.logic.test.ts

1,008 lines33.1 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import type { Thread } from "../types";
4import { makeThreadFixture } from "../test-fixtures";
5import {
6 newThreadNoProjectIndex,
7 buildNestedSearchItems,
8 resolveCommandPaletteProject,
9 buildThreadActionItems,
10 buildLinkedThreadActionItems,
11 filterPinnedBrowseEntries,
12 filterCommandPaletteGroups,
13 buildRootGroups,
14 RECENT_THREAD_LIMIT,
15 reduceCommandPaletteUiState,
16 resolveAddProjectEntry,
17 createCommandPaletteSearchState,
18 reduceCommandPaletteSearchState,
19 type CommandPaletteUiState,
20} from "./CommandPalette.logic";
21​
22describe("linked pull request thread navigation", () => {
23 it("keeps archived relations searchable and routes them through the PR environment", async () => {
24 const environmentId = EnvironmentId.make("remote");
25 const id = ThreadId.make("archived-thread");
26 const runThread = vi.fn(async () => {});
27 const query = "https://github.com/acme/web/pull/42";
28 const linkedThreads = {
29 environmentId,
30 threads: [
31 {
32 id,
33 projectId: ProjectId.make("project"),
34 title: "Completed work",
35 archivedAt: "2026-09-01T00:00:00.000Z",
36 },
37 ],
38 };
39 const state = reduceCommandPaletteUiState(
40 { open: false, mode: "command", openIntent: null, session: 0 },
41 {
42 _tag: "OpenSearch",
43 query,
44 linkedThreads,
45 },
46 );
47 expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads });
48 const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread });
49 const groups = filterCommandPaletteGroups({
50 activeGroups: [],
51 query,
52 isInSubmenu: false,
53 projectSearchItems: [],
54 settingsSearchItems: [],
55 threadSearchItems: items,
56 });
57 expect(groups.flatMap((group) => group.items)).toEqual(items);
58 expect(items[0]?.description).toBe("Archived thread");
59 await items[0]?.run();
60 expect(runThread).toHaveBeenCalledWith({ environmentId, id });
61 });
62});
63​
64describe("reduceCommandPaletteUiState", () => {
65 it("opens PR search from another overlay and replaces an earlier search", () => {
66 const first = reduceCommandPaletteUiState(
67 { open: true, mode: "files", openIntent: null, session: 0 },
68 {
69 _tag: "OpenSearch",
70 query: "https://github.com/acme/web/pull/7",
71 },
72 );
73 expect(first).toEqual({
74 open: true,
75 mode: "command",
76 openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" },
77 session: 1,
78 });
79 const second = reduceCommandPaletteUiState(first, {
80 _tag: "OpenSearch",
81 query: "https://github.com/acme/web/pull/8",
82 });
83 expect(second.openIntent).toEqual({
84 kind: "search",
85 query: "https://github.com/acme/web/pull/8",
86 });
87 // The second intent starts a new session, so the open palette starts over from it.
88 expect(second.session).toBe(2);
89 expect(
90 reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent,
91 ).toBeNull();
92 });
93​
94 it("keeps the session across mode switches so only intents start the palette over", () => {
95 let state: CommandPaletteUiState = {
96 open: false,
97 mode: "command",
98 openIntent: null,
99 session: 0,
100 };
101 state = reduceCommandPaletteUiState(state, { _tag: "OpenNewThreadIn" });
102 expect(state).toMatchObject({ open: true, openIntent: { kind: "new-thread-in" }, session: 1 });
103 state = reduceCommandPaletteUiState(state, { _tag: "ToggleMode", mode: "files" });
104 expect(state).toMatchObject({ mode: "files", openIntent: null, session: 1 });
105 state = reduceCommandPaletteUiState(state, { _tag: "OpenAddProject" });
106 expect(state).toMatchObject({
107 mode: "command",
108 openIntent: { kind: "add-project" },
109 session: 2,
110 });
111 });
112});
113​
114describe("resolveAddProjectEntry", () => {
115 const local = EnvironmentId.make("local");
116 const remote = EnvironmentId.make("remote");
117​
118 it("sends the user to connect an environment when none can add a project", () => {
119 expect(resolveAddProjectEntry([], null)).toEqual({ kind: "connect-environment" });
120 });
121​
122 it("asks for an environment when there is a choice or no connected default", () => {
123 expect(
124 resolveAddProjectEntry([{ environmentId: local }, { environmentId: remote }], local),
125 ).toEqual({ kind: "choose-environment" });
126 expect(resolveAddProjectEntry([{ environmentId: local }], null)).toEqual({
127 kind: "choose-environment",
128 });
129 });
130​
131 it("goes straight to sources for the only environment", () => {
132 expect(resolveAddProjectEntry([{ environmentId: local }], local)).toEqual({
133 kind: "choose-source",
134 environmentId: local,
135 });
136 });
137});
138​
139const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
140const PROJECT_ID = ProjectId.make("project-1");
141​
142function makeThread(overrides: Partial<Thread> = {}): Thread {
143 return makeThreadFixture({
144 id: ThreadId.make("thread-1"),
145 environmentId: LOCAL_ENVIRONMENT_ID,
146 projectId: PROJECT_ID,
147 title: "Thread",
148 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
149 runtimeMode: "full-access",
150 interactionMode: "default",
151 runtime: null,
152 messages: [],
153 proposedPlans: [],
154 createdAt: "2026-03-01T00:00:00.000Z",
155 archivedAt: null,
156 settledOverride: null,
157 settledAt: null,
158 deletedAt: null,
159 updatedAt: "2026-03-01T00:00:00.000Z",
160 latestRun: null,
161 branch: null,
162 worktreePath: null,
163 ...overrides,
164 });
166​
167describe("buildThreadActionItems", () => {
168 it("keeps main's empty-query actions and twelve recent threads in order", () => {
169 const items = buildThreadActionItems({
170 threads: Array.from({ length: 13 }, (_, index) =>
171 makeThread({
172 id: ThreadId.make(`daily-${index}`),
173 title: "Daily T3 Code trace hotspot check",
174 latestUserMessageAt: `2026-03-${String(index + 1).padStart(2, "0")}T00:00:00.000Z`,
175 }),
176 ),
177 projectByKey: new Map(),
178 icon: null,
179 runThread: async () => undefined,
180 });
181 const action = { ...items[0]!, value: "action:settings", title: "Open settings" };
182 const groups = filterCommandPaletteGroups({
183 activeGroups: buildRootGroups({
184 actionItems: [action],
185 recentThreadItems: items.slice(0, RECENT_THREAD_LIMIT),
186 }),
187 query: "",
188 isInSubmenu: false,
189 projectSearchItems: [],
190 threadSearchItems: items,
191 });
192 expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
193 action.value,
194 ...items.slice(0, 12).map((item) => item.value),
195 ]);
196 const matches = filterCommandPaletteGroups({
197 activeGroups: [],
198 query: "dail",
199 isInSubmenu: false,
200 projectSearchItems: [],
201 threadSearchItems: items,
202 now: Date.parse("2026-03-14T00:00:00.000Z"),
203 });
204 expect(matches.flatMap((group) => group.items.map((item) => item.value))).toEqual(
205 items.toReversed().map((item) => item.value),
206 );
207 });
208​
209 it("keeps threads apart when two environments share a thread ID", async () => {
210 const local = makeThread();
211 const remote = makeThread({ environmentId: EnvironmentId.make("remote-copy") });
212 const runThread = vi.fn(async () => {});
213 const items = buildThreadActionItems({
214 threads: [local, remote],
215 activeThread: remote,
216 projectByKey: new Map([
217 [`${local.environmentId}:${local.projectId}`, { title: "Local project" }],
218 [`${remote.environmentId}:${remote.projectId}`, { title: "Remote project" }],
219 ]),
220 icon: null,
221 runThread,
222 });
223 expect(new Set(items.map((item) => item.value)).size).toBe(2);
224 const remoteItem = items.find(
225 (item) => item.value === `thread:${remote.environmentId}:${remote.id}`,
226 );
227 await remoteItem?.run();
228 expect(runThread).toHaveBeenCalledWith(remote);
229 });
230​
231 it("ranks thread title matches ahead of contextual project-name matches", () => {
232 const threadItems = buildThreadActionItems({
233 threads: [
234 makeThread({
235 id: ThreadId.make("thread-context-match"),
236 title: "Fix navbar spacing",
237 updatedAt: "2026-03-20T00:00:00.000Z",
238 }),
239 makeThread({
240 id: ThreadId.make("thread-title-match"),
241 title: "Project kickoff notes",
242 createdAt: "2026-03-02T00:00:00.000Z",
243 updatedAt: "2026-03-19T00:00:00.000Z",
244 }),
245 ],
246 projectByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, { title: "Project" }]]),
247 icon: null,
248 runThread: async (_thread) => undefined,
249 });
250​
251 const groups = filterCommandPaletteGroups({
252 activeGroups: [],
253 query: "project",
254 isInSubmenu: false,
255 projectSearchItems: [],
256 threadSearchItems: threadItems,
257 });
258​
259 expect(groups).toHaveLength(1);
260 expect(groups[0]?.value).toBe("threads-search");
261 expect(groups[0]?.items.map((item) => item.value)).toEqual([
262 "thread:environment-local:thread-title-match",
263 "thread:environment-local:thread-context-match",
264 ]);
265 });
266​
267 it("searches submenu options from the root and from a submenu with no match", () => {
268 const action = (value: string, title: string) => ({
269 kind: "action" as const,
270 value,
271 searchTerms: [title],
272 title,
273 icon: null,
274 run: async () => undefined,
275 });
276 const projects = [action("new-thread-in:t3code", "t3code"), action("new-thread-in:web", "web")];
277 const newThreadIn = {
278 kind: "submenu" as const,
279 value: "action:new-thread-in",
280 searchTerms: ["new thread"],
281 title: "New thread in...",
282 icon: null,
283 addonIcon: null,
284 groups: [{ value: "projects", label: "Projects", items: projects }],
285 };
286 const addProject = action("action:add-project", "Add project");
287 const rootGroups = [{ value: "actions", label: "Actions", items: [newThreadIn, addProject] }];
288 const search = (query: string, isInSubmenu: boolean) =>
289 filterCommandPaletteGroups({
290 activeGroups: isInSubmenu ? newThreadIn.groups : rootGroups,
291 query,
292 isInSubmenu,
293 allOptions: {
294 rootGroups,
295 nestedItems: buildNestedSearchItems([newThreadIn, addProject], query),
296 },
297 projectSearchItems: [],
298 threadSearchItems: [
299 ...buildThreadActionItems({
300 threads: [makeThread({ title: "Thread about t3code" })],
301 projectByKey: new Map(),
302 icon: null,
303 runThread: async () => undefined,
304 }),
305 ],
306 }).map((group) => [group.label, group.items.map((item) => item.title)]);
307​
308 expect(search("new thread in t3code", false)[0]).toEqual([
309 "Suggestions",
310 ["New thread in t3code"],
311 ]);
312 expect(search("new thread", false).map(([label]) => label)).not.toContain("Suggestions");
313 expect(search("t3", true)).toEqual([["Projects", ["t3code"]]]);
314 expect(search("add project", true)).toEqual([["Actions", ["Add project"]]]);
315 });
316​
317 it("lists message matches last, once, without moving the title matches above them", () => {
318 const threads = [
319 makeThread({ id: ThreadId.make("title"), title: "Disable Convex schema validation" }),
320 makeThread({ id: ThreadId.make("message"), title: "Fix schema validation" }),
321 ];
322 const items = buildThreadActionItems({
323 threads,
324 projectByKey: new Map(),
325 icon: null,
326 runThread: async () => undefined,
327 });
328 const filter = (threadMessageItems: typeof items) =>
329 filterCommandPaletteGroups({
330 activeGroups: [],
331 query: "convex",
332 isInSubmenu: false,
333 projectSearchItems: [],
334 threadSearchItems: items,
335 threadMessageItems,
336 }).map((group) => [group.label, group.items.map((item) => item.value)]);
337​
338 const before = filter([]);
339 // The server matched both; the title match stays where it was.
340 const after = filter([items[0]!, items[1]!].toReversed());
341 expect(before).toEqual([["Threads", ["thread:environment-local:title"]]]);
342 expect(after).toEqual([
343 ["Threads", ["thread:environment-local:title"]],
344 ["In messages", ["thread:environment-local:message"]],
345 ]);
346 });
347​
348 it("keeps an active thread first while its title is typed, above settings and actions", () => {
349 const now = Date.parse("2026-10-23T00:00:00.000Z");
350 const action = (value: string, title: string, searchTerms: string[] = [title]) => ({
351 kind: "action" as const,
352 value,
353 searchTerms,
354 title,
355 icon: null,
356 run: async () => undefined,
357 });
358 const threadSearchItems = buildThreadActionItems({
359 threads: [
360 makeThread({
361 title: "Compaction prompt timing and cache age",
362 latestUserMessageAt: "2026-10-11T00:00:00.000Z",
363 }),
364 ],
365 projectByKey: new Map(),
366 icon: null,
367 runThread: async () => undefined,
368 });
369 const firstGroup = (query: string) =>
370 filterCommandPaletteGroups({
371 activeGroups: [
372 { value: "actions", label: "Actions", items: [action("action:copy", "Copy thread ID")] },
373 ],
374 query,
375 isInSubmenu: false,
376 projectSearchItems: [],
377 settingsSearchItems: [
378 action("setting:compact", "Compact idle threads"),
379 action("setting:providers", "Providers", ["Providers", "compatibility"]),
380 ],
381 threadSearchItems,
382 now,
383 })[0]?.label;
384​
385 for (const query of ["comp", "compa", "compac", "compact", "compacti", "compaction"]) {
386 expect(firstGroup(query), query).toBe("Threads");
387 }
388 });
389​
390 it("orders groups by their best match, so a strong thread is not under a weak action", () => {
391 const action = (value: string, title: string) => ({
392 kind: "action" as const,
393 value,
394 searchTerms: [title],
395 title,
396 icon: null,
397 run: async () => undefined,
398 });
399 const threadSearchItems = buildThreadActionItems({
400 threads: [makeThread({ title: "Upgrade to pnpm 12" })],
401 projectByKey: new Map(),
402 icon: null,
403 runThread: async () => undefined,
404 });
405 const labels = (query: string) =>
406 filterCommandPaletteGroups({
407 activeGroups: [
408 { value: "actions", label: "Actions", items: [action("action:upload", "Upload file")] },
409 ],
410 query,
411 isInSubmenu: false,
412 projectSearchItems: [],
413 threadSearchItems,
414 }).map((group) => group.label);
415​
416 expect(labels("upgrade")).toEqual(["Threads"]);
417 expect(labels("upl")).toEqual(["Actions"]);
418 // A short query still finds a command by a keyword it equals.
419 const prGroups = filterCommandPaletteGroups({
420 activeGroups: [
421 {
422 value: "actions",
423 label: "Actions",
424 items: [
425 {
426 kind: "action",
427 value: "action:open-prs",
428 searchTerms: ["Open pull requests", "pr", "prs", "review"],
429 title: "Open pull requests",
430 icon: null,
431 run: async () => undefined,
432 },
433 action("action:project", "Project settings"),
434 ],
435 },
436 ],
437 query: "pr",
438 isInSubmenu: false,
439 projectSearchItems: [],
440 threadSearchItems: [],
441 });
442 expect(prGroups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
443 "action:open-prs",
444 "action:project",
445 ]);
446 expect(labels("up")).toEqual(["Actions", "Threads"]);
447 expect(labels("pnpm up")).toEqual(["Threads"]);
448 });
449​
450 it("runs the command for its whole name over a thread with that title, and lists the open thread last", () => {
451 const threads = [
452 makeThread({ id: ThreadId.make("thread-new"), title: "New thread" }),
453 makeThread({ id: ThreadId.make("thread-open"), title: "New thread layout" }),
454 makeThread({ id: ThreadId.make("thread-other"), title: "New thread button" }),
455 ];
456 const threadSearchItems = buildThreadActionItems({
457 threads,
458 activeThread: threads[1]!,
459 projectByKey: new Map(),
460 icon: null,
461 runThread: async () => undefined,
462 });
463 const groups = filterCommandPaletteGroups({
464 activeGroups: [
465 {
466 value: "actions",
467 label: "Actions",
468 items: [
469 {
470 kind: "action",
471 value: "action:new-thread",
472 searchTerms: ["New thread in t3code"],
473 title: "New thread in t3code",
474 icon: null,
475 run: async () => undefined,
476 },
477 ],
478 },
479 ],
480 query: "new thread",
481 isInSubmenu: false,
482 projectSearchItems: [],
483 threadSearchItems,
484 });
485 expect(groups.map((group) => group.label)).toEqual(["Actions", "Threads"]);
486 expect(groups[1]?.items.at(-1)?.title).toBe("New thread layout");
487 });
488​
489 it("lists a project above threads that only hold its name when the name is typed", () => {
490 const threadSearchItems = buildThreadActionItems({
491 threads: [makeThread({ title: "Lakebed performance gains" })],
492 projectByKey: new Map(),
493 icon: null,
494 runThread: async () => undefined,
495 });
496 const projectSearchItems = [
497 {
498 kind: "action" as const,
499 value: "project:lakebed",
500 searchTerms: ["Lakebed", "/code/lakebed"],
501 title: "Lakebed",
502 icon: null,
503 run: async () => undefined,
504 },
505 ];
506 const labels = (query: string) =>
507 filterCommandPaletteGroups({
508 activeGroups: [],
509 query,
510 isInSubmenu: false,
511 projectSearchItems,
512 threadSearchItems,
513 }).map((group) => group.label);
514 expect(labels("lakebed")).toEqual(["Projects", "Threads"]);
515 expect(labels("lake")).toEqual(["Threads", "Projects"]);
516 });
517​
518 it("keeps a command first over a thread in daily use that matches as well", () => {
519 const now = Date.parse("2026-10-23T00:00:00.000Z");
520 const threadSearchItems = buildThreadActionItems({
521 threads: [
522 makeThread({
523 title: "New onboarding flow",
524 latestUserMessageAt: "2026-10-22T00:00:00.000Z",
525 opens: { openedAt: Date.parse("2026-10-22T00:00:00.000Z"), score: 7, picks: [] },
526 }),
527 ],
528 projectByKey: new Map(),
529 icon: null,
530 runThread: async () => undefined,
531 });
532 const groups = filterCommandPaletteGroups({
533 activeGroups: [
534 {
535 value: "actions",
536 label: "Actions",
537 items: [
538 {
539 kind: "action",
540 value: "action:new-thread",
541 searchTerms: ["new thread", "chat"],
542 title: "New thread",
543 icon: null,
544 run: async () => undefined,
545 },
546 ],
547 },
548 ],
549 query: "new",
550 isInSubmenu: false,
551 projectSearchItems: [],
552 threadSearchItems,
553 now,
554 });
555 expect(groups.map((group) => group.label)).toEqual(["Actions", "Threads"]);
556 });
557​
558 it("ranks an order-independent setting title match above a split context match", () => {
559 const settingsSearchItems = [
560 {
561 kind: "action" as const,
562 value: "setting:context-match",
563 searchTerms: ["Pairing settings", "remote backend"],
564 title: "Context match",
565 icon: null,
566 run: async () => undefined,
567 },
568 {
569 kind: "action" as const,
570 value: "setting:remote-pairing",
571 searchTerms: ["Remote pairing", "connections"],
572 title: "Remote pairing",
573 icon: null,
574 run: async () => undefined,
575 },
576 ];
577​
578 const groups = filterCommandPaletteGroups({
579 activeGroups: [],
580 query: "pairing remote",
581 isInSubmenu: false,
582 projectSearchItems: [],
583 settingsSearchItems,
584 threadSearchItems: [],
585 });
586​
587 expect(groups).toHaveLength(1);
588 expect(groups[0]?.value).toBe("settings-search");
589 expect(groups[0]?.items.map((item) => item.value)).toEqual([
590 "setting:remote-pairing",
591 "setting:context-match",
592 ]);
593 });
594​
595 it("searches a styled command title by its plain text", () => {
596 const groups = filterCommandPaletteGroups({
597 activeGroups: [
598 {
599 value: "actions",
600 label: "Actions",
601 items: [
602 {
603 kind: "action",
604 value: "action:new-thread",
605 searchTerms: ["new thread", "chat"],
606 // Styled: not a string, so only `searchTitle` holds its words.
607 title: ["New thread in ", "t3code"],
608 searchTitle: "New thread in t3code",
609 icon: null,
610 run: async () => undefined,
611 },
612 ],
613 },
614 ],
615 query: "> new t3code",
616 isInSubmenu: false,
617 projectSearchItems: [],
618 threadSearchItems: [],
619 });
620 expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
621 "action:new-thread",
622 ]);
623 });
624​
625 it("forgives a typo in a setting name when nothing matches exactly", () => {
626 const groups = filterCommandPaletteGroups({
627 activeGroups: [],
628 query: "keybindngs",
629 isInSubmenu: false,
630 projectSearchItems: [],
631 threadSearchItems: [],
632 settingsSearchItems: [
633 {
634 kind: "action",
635 value: "setting:keybindings",
636 title: "Keybindings",
637 searchTerms: ["Keybindings", "General", "shortcuts"],
638 icon: null,
639 run: async () => undefined,
640 },
641 ],
642 });
643 expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
644 "setting:keybindings",
645 ]);
646 });
647​
648 it("normalizes case independently of the host locale", () => {
649 const toLocaleLowerCase = String.prototype.toLocaleLowerCase;
650 const localeLowerCase = vi
651 .spyOn(String.prototype, "toLocaleLowerCase")
652 .mockImplementation(function (this: string) {
653 return toLocaleLowerCase.call(this, "tr");
654 });
655 try {
656 const groups = filterCommandPaletteGroups({
657 activeGroups: [],
658 query: "GIT",
659 isInSubmenu: false,
660 projectSearchItems: [],
661 threadSearchItems: [],
662 settingsSearchItems: [
663 {
664 kind: "action",
665 value: "setting:version-control",
666 title: "Version control",
667 searchTerms: ["git"],
668 icon: null,
669 run: async () => undefined,
670 },
671 ],
672 });
673 expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
674 "setting:version-control",
675 ]);
676 } finally {
677 localeLowerCase.mockRestore();
678 }
679 });
680​
681 it("surfaces threads when the query is their ID, without outranking title matches", () => {
682 const idThread = makeThread({
683 id: ThreadId.make("thread-alpha-1234"),
684 title: "Unrelated work",
685 updatedAt: "2026-03-05T00:00:00.000Z",
686 });
687 const titleThread = makeThread({
688 id: ThreadId.make("thread-other-9999"),
689 title: "Fix thread-alpha-1234 flakes",
690 updatedAt: "2026-03-04T00:00:00.000Z",
691 });
692 const items = buildThreadActionItems({
693 threads: [idThread, titleThread],
694 projectByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, { title: "T3 Code" }]]),
695 icon: null,
696 runThread: async (_thread) => undefined,
697 });
698​
699 const groups = filterCommandPaletteGroups({
700 activeGroups: [],
701 query: " THREAD-ALPHA-1234 ",
702 isInSubmenu: false,
703 projectSearchItems: [],
704 settingsSearchItems: [],
705 threadSearchItems: items,
706 });
707​
708 expect(groups.flatMap((group) => group.items)).toEqual([
709 expect.objectContaining({ value: `thread:${LOCAL_ENVIRONMENT_ID}:${titleThread.id}` }),
710 expect.objectContaining({ value: `thread:${LOCAL_ENVIRONMENT_ID}:${idThread.id}` }),
711 ]);
712 });
713​
714 it("hides subagent threads but keeps forks", () => {
715 const parentId = ThreadId.make("parent");
716 const items = buildThreadActionItems({
717 threads: [
718 makeThread({ id: parentId, title: "Parent" }),
719 makeThread({
720 id: ThreadId.make("subagent"),
721 title: "Subagent",
722 lineage: {
723 rootThreadId: parentId,
724 parentThreadId: parentId,
725 relationshipToParent: "subagent",
726 },
727 }),
728 makeThread({
729 id: ThreadId.make("fork"),
730 title: "Fork",
731 lineage: {
732 rootThreadId: parentId,
733 parentThreadId: parentId,
734 relationshipToParent: "fork",
735 },
736 }),
737 ],
738 projectByKey: new Map(),
739 icon: null,
740 runThread: async () => undefined,
741 });
742​
743 expect(items.map((item) => item.value).toSorted()).toEqual([
744 "thread:environment-local:fork",
745 "thread:environment-local:parent",
746 ]);
747 });
748​
749 it("ranks word-start title matches above newer mid-word matches", () => {
750 const items = buildThreadActionItems({
751 threads: [
752 makeThread({
753 id: ThreadId.make("mid-word"),
754 title: "Fix oauth redirect",
755 updatedAt: "2026-03-20T00:00:00.000Z",
756 }),
757 makeThread({
758 id: ThreadId.make("word-start"),
759 title: "Clean up auth tokens",
760 updatedAt: "2026-03-02T00:00:00.000Z",
761 }),
762 makeThread({
763 id: ThreadId.make("branch-word-start"),
764 title: "Refactor login",
765 branch: "feature/auth-cleanup",
766 updatedAt: "2026-03-01T00:00:00.000Z",
767 }),
768 ],
769 projectByKey: new Map(),
770 icon: null,
771 runThread: async () => undefined,
772 });
773​
774 const groups = filterCommandPaletteGroups({
775 activeGroups: [],
776 query: "auth",
777 isInSubmenu: false,
778 projectSearchItems: [],
779 threadSearchItems: items,
780 });
781​
782 expect(groups[0]?.items.map((item) => item.value)).toEqual([
783 "thread:environment-local:word-start",
784 "thread:environment-local:branch-word-start",
785 "thread:environment-local:mid-word",
786 ]);
787 });
788​
789 it("breaks ties between equal matches by latest user message", () => {
790 const items = buildThreadActionItems({
791 threads: [
792 makeThread({
793 id: ThreadId.make("prefix-stale"),
794 title: "Auth refactor",
795 updatedAt: "2026-03-20T00:00:00.000Z",
796 latestUserMessageAt: "2025-09-01T00:00:00.000Z",
797 }),
798 makeThread({
799 id: ThreadId.make("word-fresh"),
800 title: "Fix auth refresh",
801 updatedAt: "2026-03-10T00:00:00.000Z",
802 latestUserMessageAt: "2026-03-10T00:00:00.000Z",
803 }),
804 ],
805 projectByKey: new Map(),
806 icon: null,
807 runThread: async () => undefined,
808 });
809​
810 const groups = filterCommandPaletteGroups({
811 activeGroups: [],
812 query: "auth",
813 isInSubmenu: false,
814 projectSearchItems: [],
815 threadSearchItems: items,
816 now: Date.parse("2026-03-20T00:00:00.000Z"),
817 });
818​
819 expect(groups[0]?.items.map((item) => item.value)).toEqual([
820 "thread:environment-local:word-fresh",
821 "thread:environment-local:prefix-stale",
822 ]);
823 });
824});
825​
826describe("filterPinnedBrowseEntries", () => {
827 it("matches an existing pinned destination without Windows casing", () => {
828 const windowsEntries = [
829 { name: "Repo", fullPath: "C:\\projects\\Repo" },
830 { name: "work", fullPath: "C:\\projects\\work" },
831 ];
832 expect(
833 filterPinnedBrowseEntries({
834 browseEntries: windowsEntries,
835 filterQuery: "repo",
836 pinnedDirectoryName: "repo",
837 caseSensitive: false,
838 }),
839 ).toEqual({
840 visibleEntries: windowsEntries,
841 exactEntry: windowsEntries[0],
842 });
843 });
844});
845​
846it.each([
847 "#10839",
848 "10839",
849 "pingdotgg/t3code#10839",
850 "https://github.com/pingdotgg/t3code/pull/10839",
851])("finds linked threads from PR query %s", (query) => {
852 const items = buildThreadActionItems({
853 threads: [
854 makeThread({
855 title: "Implementation",
856 pullRequests: [
857 {
858 host: "github.com",
859 repository: "pingdotgg/t3code",
860 number: 10839,
861 url: "https://github.com/pingdotgg/t3code/pull/10839",
862 source: "manual",
863 linkedAt: "2026-09-08T00:00:00Z",
864 snapshot: null,
865 stack: null,
866 },
867 ],
868 }),
869 makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }),
870 ],
871 projectByKey: new Map(),
872 icon: null,
873 runThread: async () => undefined,
874 });
875 const groups = filterCommandPaletteGroups({
876 activeGroups: [],
877 query,
878 isInSubmenu: false,
879 projectSearchItems: [],
880 threadSearchItems: items,
881 });
882 expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([
883 "Implementation",
884 ]);
885});
886​
887describe("command palette search return", () => {
888 const picker = { addonIcon: null, groups: [] };
889​
890 it("restores each parent query when backing out of nested pickers", () => {
891 let state = createCommandPaletteSearchState(">new thread");
892 state = reduceCommandPaletteSearchState(state, { type: "push", view: picker });
893 expect(state.query).toBe("");
894 state = reduceCommandPaletteSearchState(state, { type: "query", query: "remote project" });
895 state = reduceCommandPaletteSearchState(state, {
896 type: "push",
897 view: { ...picker, initialQuery: "~/" },
898 });
899 expect(state.query).toBe("~/");
900​
901 state = reduceCommandPaletteSearchState(state, { type: "query", query: "" });
902 state = reduceCommandPaletteSearchState(state, { type: "back" });
903 expect(state.query).toBe("remote project");
904 expect(state.views).toHaveLength(1);
905 state = reduceCommandPaletteSearchState(state, { type: "back" });
906 expect(state.query).toBe(">new thread");
907 expect(state.views).toHaveLength(0);
908 });
909​
910 it("cannot return to an old picker after a new search replaces it", () => {
911 let state = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), {
912 type: "push",
913 view: picker,
914 });
915 state = reduceCommandPaletteSearchState(state, { type: "reset", query: "fresh" });
916 state = reduceCommandPaletteSearchState(state, { type: "back" });
917 expect(state.query).toBe("fresh");
918 expect(state.views).toHaveLength(0);
919 });
920});
921​
922describe("returning from file and content search", () => {
923 const root: CommandPaletteUiState = {
924 open: true,
925 mode: "command",
926 openIntent: null,
927 session: 0,
928 };
929 const query = "https://github.com/acme/web/pull/7";
930 const context = {
931 query,
932 linkedThreadSearch: {
933 kind: "search" as const,
934 query,
935 linkedThreads: {
936 environmentId: EnvironmentId.make("remote"),
937 threads: [
938 {
939 id: ThreadId.make("linked-thread"),
940 projectId: ProjectId.make("project"),
941 title: "Completed work",
942 archivedAt: "2026-09-01T00:00:00.000Z",
943 },
944 ],
945 },
946 },
947 };
948​
949 it.each(["files", "content"] as const)(
950 "Escape from %s restores the query and linked-thread results",
951 (mode) => {
952 const child = reduceCommandPaletteUiState(root, { _tag: "OpenChildSearch", mode, context });
953 expect(child.mode).toBe(mode);
954 const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
955 expect(returned).toEqual({ ...root, restoredSearch: context });
956 expect(reduceCommandPaletteUiState(returned, { _tag: "BackToCommand" })).toEqual(returned);
957 },
958 );
959​
960 it("closing a child search discards its return context before reopening", () => {
961 const child: CommandPaletteUiState = { ...root, mode: "files", parentSearch: context };
962 const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false });
963 expect(closed).toEqual({ open: false, mode: "files", openIntent: null, session: 0 });
964 expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toEqual(closed);
965 expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root);
966 });
967});
968​
969it("resolves project metadata and browse roots within the active environment", () => {
970 const local = { title: "Local project", workspaceRoot: "/local/repo" };
971 const remote = { title: "Remote project", workspaceRoot: "/remote/repo" };
972 const projects = new Map([
973 ["local:shared-project", local],
974 ["remote:shared-project", remote],
975 ]);
976 expect(
977 resolveCommandPaletteProject(projects, EnvironmentId.make("local"), PROJECT_ID),
978 ).toBeUndefined();
979 const sharedId = ProjectId.make("shared-project");
980 expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), sharedId)).toBe(local);
981 expect(resolveCommandPaletteProject(projects, EnvironmentId.make("remote"), sharedId)).toBe(
982 remote,
983 );
984 expect(resolveCommandPaletteProject(projects, null, sharedId)).toBeUndefined();
985 expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), null)).toBeUndefined();
986});
987​
988describe("newThreadNoProjectIndex", () => {
989 const entry = (pinned = false, isPreferred = false, isScratch = false) => ({
990 pinned,
991 isPreferred,
992 isScratch,
993 });
994​
995 it("goes after the pinned projects and the first unpinned one", () => {
996 expect(newThreadNoProjectIndex([entry(false, true), entry(true), entry(true), entry()])).toBe(
997 3,
998 );
999 expect(newThreadNoProjectIndex([entry(false, true), entry(), entry(false, false, true)])).toBe(
1000 1,
1001 );
1002 });
1003​
1004 it("keeps a pinned or current scratch project in its own place", () => {
1005 expect(newThreadNoProjectIndex([entry(false, true, true), entry()])).toBe(0);
1006 expect(newThreadNoProjectIndex([entry(true), entry(true, false, true), entry()])).toBe(1);
1007 });
1008});