1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import type { Project, Thread } from "../types";
4import { makeThreadFixture } from "../test-fixtures";
5import {
6 buildBrowseGroups,
7 buildCommandPaletteProjectMetadata,
8 buildCommandPaletteRows,
9 buildProjectActionItems,
10 buildThreadActionItems,
11 buildLinkedThreadActionItems,
12 enumerateCommandPaletteItems,
13 filterPinnedBrowseEntries,
14 filterCommandPaletteGroups,
15 findHighlightedCommandPaletteItem,
16 reduceCommandPaletteUiState,
17 type CommandPaletteActionItem,
18 type CommandPaletteGroup,
19} from "./CommandPalette.logic";
20
21describe("linked pull request thread navigation", () => {
22 it("keeps archived relations searchable and routes them through the PR environment", async () => {
23 const environmentId = EnvironmentId.make("remote");
24 const id = ThreadId.make("archived-thread");
25 const runThread = vi.fn(async () => {});
26 const query = "https://github.com/acme/web/pull/42";
27 const linkedThreads = {
28 environmentId,
29 threads: [
30 {
31 id,
32 projectId: ProjectId.make("project"),
33 title: "Completed work",
34 archivedAt: "2026-09-01T00:00:00.000Z",
35 },
36 ],
37 };
38 const state = reduceCommandPaletteUiState(
39 { open: false, mode: "command", openIntent: null },
40 {
41 _tag: "OpenSearch",
42 query,
43 linkedThreads,
44 },
45 );
46 expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads });
47 const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread });
48 const groups = filterCommandPaletteGroups({
49 activeGroups: [],
50 query,
51 isInSubmenu: false,
52 projectSearchItems: [],
53 settingsSearchItems: [],
54 threadSearchItems: items,
55 });
56 expect(groups.flatMap((group) => group.items)).toEqual(items);
57 expect(items[0]?.description).toBe("Archived thread");
58 await items[0]?.run();
59 expect(runThread).toHaveBeenCalledWith({ environmentId, id });
60 });
61});
62
63describe("buildCommandPaletteProjectMetadata", () => {
64 const localEnvironmentId = EnvironmentId.make("environment-local");
65 const remoteEnvironmentId = EnvironmentId.make("environment-build-box");
66 const locations = new Map([
67 [localEnvironmentId, { kind: "local" as const, label: "Local", machine: "laptop" as const }],
68 [
69 remoteEnvironmentId,
70 { kind: "remote" as const, label: "Build box", machine: "server" as const },
71 ],
72 ]);
73
74 it("makes every member environment and path searchable", () => {
75 const metadata = buildCommandPaletteProjectMetadata({
76 projects: [
77 {
78 environmentId: localEnvironmentId,
79 title: "T3 Code",
80 workspaceRoot: "/Users/theo/Projects/t3code",
81 },
82 {
83 environmentId: remoteEnvironmentId,
84 title: "t3code",
85 workspaceRoot: "/srv/t3code",
86 },
87 ],
88 locationByEnvironmentId: locations,
89 });
90
91 expect(metadata.searchTerms).toEqual([
92 "T3 Code",
93 "/Users/theo/Projects/t3code",
94 "Local",
95 "t3code",
96 "/srv/t3code",
97 "Build box",
98 ]);
99 expect(metadata.environmentLabels).toEqual(["Local", "Build box"]);
100
101 const [filteredGroup] = filterCommandPaletteGroups({
102 activeGroups: [],
103 query: "build box",
104 isInSubmenu: false,
105 projectSearchItems: [
106 {
107 kind: "action",
108 value: "project:t3code",
109 title: "T3 Code",
110 searchTerms: metadata.searchTerms,
111 icon: null,
112 run: async () => undefined,
113 },
114 ],
115 threadSearchItems: [],
116 });
117 expect(filteredGroup?.items).toHaveLength(1);
118 });
119
120 it("deduplicates grouped checkouts by environment", () => {
121 const metadata = buildCommandPaletteProjectMetadata({
122 projects: [
123 {
124 environmentId: remoteEnvironmentId,
125 title: "T3 Code",
126 workspaceRoot: "/srv/t3code",
127 },
128 {
129 environmentId: remoteEnvironmentId,
130 title: "T3 Code worktree",
131 workspaceRoot: "/srv/t3code-feature",
132 },
133 ],
134 locationByEnvironmentId: locations,
135 });
136
137 expect(metadata.environmentLabels).toEqual(["Build box"]);
138 });
139
140 it("deduplicates distinct environments with the same label", () => {
141 const secondRemoteEnvironmentId = EnvironmentId.make("environment-build-box-2");
142 const metadata = buildCommandPaletteProjectMetadata({
143 projects: [
144 {
145 environmentId: remoteEnvironmentId,
146 title: "T3 Code",
147 workspaceRoot: "/srv/t3code",
148 },
149 {
150 environmentId: secondRemoteEnvironmentId,
151 title: "T3 Code mirror",
152 workspaceRoot: "/srv/mirror/t3code",
153 },
154 ],
155 locationByEnvironmentId: new Map([
156 [remoteEnvironmentId, { label: "Build box" }],
157 [secondRemoteEnvironmentId, { label: "Build box" }],
158 ]),
159 });
160
161 expect(metadata.environmentLabels).toEqual(["Build box"]);
162 });
163
164 it("uses a human-readable fallback when presentation data is unavailable", () => {
165 const metadata = buildCommandPaletteProjectMetadata({
166 projects: [
167 {
168 environmentId: remoteEnvironmentId,
169 title: "T3 Code",
170 workspaceRoot: "/srv/t3code",
171 },
172 ],
173 locationByEnvironmentId: new Map(),
174 });
175
176 expect(metadata.searchTerms).toContain("Remote");
177 expect(metadata.environmentLabels).toEqual(["Remote"]);
178 });
179});
180
181describe("reduceCommandPaletteUiState", () => {
182 const closedState = { open: false, mode: "command", openIntent: null } as const;
183
184 it("toggles each overlay mode open and closed", () => {
185 const filesOpen = reduceCommandPaletteUiState(closedState, {
186 _tag: "ToggleMode",
187 mode: "files",
188 });
189 expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null });
190
191 const contentOpen = reduceCommandPaletteUiState(filesOpen, {
192 _tag: "ToggleMode",
193 mode: "content",
194 });
195 expect(contentOpen).toEqual({ open: true, mode: "content", openIntent: null });
196
197 expect(
198 reduceCommandPaletteUiState(contentOpen, { _tag: "ToggleMode", mode: "content" }),
199 ).toEqual({ open: false, mode: "content", openIntent: null });
200 });
201
202 it("switches between open modes without closing", () => {
203 const filesOpen = reduceCommandPaletteUiState(closedState, {
204 _tag: "ToggleMode",
205 mode: "files",
206 });
207 expect(reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleMode", mode: "command" })).toEqual(
208 {
209 open: true,
210 mode: "command",
211 openIntent: null,
212 },
213 );
214 });
215
216 it("opens PR search from another overlay and replaces an earlier search", () => {
217 const first = reduceCommandPaletteUiState(
218 { open: true, mode: "files", openIntent: null },
219 {
220 _tag: "OpenSearch",
221 query: "https://github.com/acme/web/pull/7",
222 },
223 );
224 expect(first).toEqual({
225 open: true,
226 mode: "command",
227 openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" },
228 });
229 const second = reduceCommandPaletteUiState(first, {
230 _tag: "OpenSearch",
231 query: "https://github.com/acme/web/pull/8",
232 });
233 expect(second.openIntent).toEqual({
234 kind: "search",
235 query: "https://github.com/acme/web/pull/8",
236 });
237 expect(
238 reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent,
239 ).toBeNull();
240 });
241
242 it("routes open intents to command mode", () => {
243 const filesOpen = reduceCommandPaletteUiState(closedState, {
244 _tag: "ToggleMode",
245 mode: "files",
246 });
247 expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({
248 open: true,
249 mode: "command",
250 openIntent: { kind: "add-project" },
251 });
252 expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({
253 open: true,
254 mode: "command",
255 openIntent: { kind: "new-thread-in" },
256 });
257 });
258
259 it("preserves the mode on close and resets it on open", () => {
260 const filesOpen = reduceCommandPaletteUiState(closedState, {
261 _tag: "ToggleMode",
262 mode: "files",
263 });
264
265 expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({
266 open: false,
267 mode: "files",
268 openIntent: null,
269 });
270 expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({
271 open: true,
272 mode: "command",
273 openIntent: null,
274 });
275 });
276});
277
278describe("enumerateCommandPaletteItems", () => {
279 it("assigns positional jump shortcuts to the first nine displayed items", () => {
280 const items = Array.from({ length: 10 }, (_, index) => ({
281 kind: "action" as const,
282 value: `project-${index + 1}`,
283 searchTerms: [],
284 title: `Project ${index + 1}`,
285 icon: null,
286 shortcutCommand: "chat.new" as const,
287 run: async () => undefined,
288 }));
289
290 expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([
291 "thread.jump.1",
292 "thread.jump.2",
293 "thread.jump.3",
294 "thread.jump.4",
295 "thread.jump.5",
296 "thread.jump.6",
297 "thread.jump.7",
298 "thread.jump.8",
299 "thread.jump.9",
300 undefined,
301 ]);
302 });
303});
304
305const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
306const PROJECT_ID = ProjectId.make("project-1");
307
308function makeProject(overrides: Partial<Project> = {}): Project {
309 return {
310 id: PROJECT_ID,
311 environmentId: LOCAL_ENVIRONMENT_ID,
312 title: "Project",
313 workspaceRoot: "/workspace/project",
314 defaultModelSelection: null,
315 scripts: [],
316 createdAt: "2026-03-01T00:00:00.000Z",
317 updatedAt: "2026-03-01T00:00:00.000Z",
318 ...overrides,
319 };
320}
321
322function makeThread(overrides: Partial<Thread> = {}): Thread {
323 return makeThreadFixture({
324 id: ThreadId.make("thread-1"),
325 environmentId: LOCAL_ENVIRONMENT_ID,
326 projectId: PROJECT_ID,
327 title: "Thread",
328 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
329 runtimeMode: "full-access",
330 interactionMode: "default",
331 runtime: null,
332 messages: [],
333 proposedPlans: [],
334 createdAt: "2026-03-01T00:00:00.000Z",
335 archivedAt: null,
336 settledOverride: null,
337 settledAt: null,
338 deletedAt: null,
339 updatedAt: "2026-03-01T00:00:00.000Z",
340 latestRun: null,
341 branch: null,
342 worktreePath: null,
343 ...overrides,
344 });
345}
346
347describe("buildProjectActionItems", () => {
348 it("shows the grouped display name but keeps the real title for icons", () => {
349 const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" });
350 const iconTitles: string[] = [];
351 const [item] = buildProjectActionItems({
352 projects: [{ ...project, displayName: "t3dotgg/fleet" }],
353 valuePrefix: "project",
354 icon: (candidate) => {
355 iconTitles.push(candidate.title);
356 return null;
357 },
358 runProject: async () => undefined,
359 });
360
361 expect(item?.title).toBe("t3dotgg/fleet");
362 expect(item?.searchTerms).toEqual(
363 expect.arrayContaining(["t3dotgg/fleet", "fleet", "/Users/theo/Code/p/fleet"]),
364 );
365 expect(iconTitles).toEqual(["fleet"]);
366 });
367});
368
369describe("buildThreadActionItems", () => {
370 it("orders threads by most recent activity and formats timestamps from updatedAt", () => {
371 vi.useFakeTimers();
372 vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z"));
373
374 try {
375 const items = buildThreadActionItems({
376 threads: [
377 makeThread({
378 id: ThreadId.make("thread-older"),
379 title: "Older thread",
380 updatedAt: "2026-03-24T12:00:00.000Z",
381 }),
382 makeThread({
383 id: ThreadId.make("thread-newer"),
384 title: "Newer thread",
385 createdAt: "2026-03-20T00:00:00.000Z",
386 updatedAt: "2026-03-20T00:00:00.000Z",
387 }),
388 ],
389 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
390 sortOrder: "updated_at",
391 icon: null,
392 runThread: async (_thread) => undefined,
393 });
394
395 expect(items.map((item) => item.value)).toEqual([
396 "thread:thread-older",
397 "thread:thread-newer",
398 ]);
399 expect(items[0]?.timestamp).toBe("1d ago");
400 expect(items[1]?.timestamp).toBe("5d ago");
401 } finally {
402 vi.useRealTimers();
403 }
404 });
405
406 it("ranks thread title matches ahead of contextual project-name matches", () => {
407 const threadItems = buildThreadActionItems({
408 threads: [
409 makeThread({
410 id: ThreadId.make("thread-context-match"),
411 title: "Fix navbar spacing",
412 updatedAt: "2026-03-20T00:00:00.000Z",
413 }),
414 makeThread({
415 id: ThreadId.make("thread-title-match"),
416 title: "Project kickoff notes",
417 createdAt: "2026-03-02T00:00:00.000Z",
418 updatedAt: "2026-03-19T00:00:00.000Z",
419 }),
420 ],
421 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
422 sortOrder: "updated_at",
423 icon: null,
424 runThread: async (_thread) => undefined,
425 });
426
427 const groups = filterCommandPaletteGroups({
428 activeGroups: [],
429 query: "project",
430 isInSubmenu: false,
431 projectSearchItems: [],
432 threadSearchItems: threadItems,
433 });
434
435 expect(groups).toHaveLength(1);
436 expect(groups[0]?.value).toBe("threads-search");
437 expect(groups[0]?.items.map((item) => item.value)).toEqual([
438 "thread:thread-title-match",
439 "thread:thread-context-match",
440 ]);
441 });
442
443 it("orders title matches by recent activity before older prefix matches", () => {
444 const threads = [
445 makeThread({
446 id: ThreadId.make("old-prefix"),
447 title: "Convex InvalidCursor in Convex threads query",
448 createdAt: "2026-01-01T00:00:00.000Z",
449 updatedAt: "2026-01-02T00:00:00.000Z",
450 }),
451 makeThread({
452 id: ThreadId.make("recent-title"),
453 title: "Disable Convex schema validation",
454 createdAt: "2025-12-01T00:00:00.000Z",
455 updatedAt: "2026-03-24T00:00:00.000Z",
456 }),
457 makeThread({
458 id: ThreadId.make("recent-content"),
459 title: "Fix schema validation",
460 createdAt: "2026-03-25T00:00:00.000Z",
461 updatedAt: "2026-03-25T00:00:00.000Z",
462 }),
463 ];
464 const items = buildThreadActionItems({
465 threads,
466 projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]),
467 sortOrder: "created_at",
468 icon: null,
469 getContentMatch: (thread) =>
470 thread.id === ThreadId.make("recent-content")
471 ? { source: "user", snippet: "Please check Convex", query: "convex" }
472 : undefined,
473 runThread: async () => undefined,
474 });
475
476 const groups = filterCommandPaletteGroups({
477 activeGroups: [],
478 query: "convex",
479 isInSubmenu: false,
480 projectSearchItems: [],
481 threadSearchItems: items,
482 });
483
484 expect(groups[0]?.items.map((item) => item.value)).toEqual([
485 "thread:recent-title",
486 "thread:old-prefix",
487 "thread:recent-content",
488 ]);
489 });
490
491 it("preserves thread project-name matches when there is no stronger title match", () => {
492 const group: CommandPaletteGroup = {
493 value: "threads-search",
494 label: "Threads",
495 items: [
496 {
497 kind: "action",
498 value: "thread:project-context-only",
499 searchTerms: ["Fix navbar spacing", "Project"],
500 title: "Fix navbar spacing",
501 description: "Project",
502 icon: null,
503 run: async () => undefined,
504 },
505 ],
506 };
507
508 const groups = filterCommandPaletteGroups({
509 activeGroups: [group],
510 query: "project",
511 isInSubmenu: false,
512 projectSearchItems: [],
513 threadSearchItems: [],
514 });
515
516 expect(groups).toHaveLength(1);
517 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
518 });
519
520 it("ranks an order-independent setting title match above a split context match", () => {
521 const settingsSearchItems = [
522 {
523 kind: "action" as const,
524 value: "setting:context-match",
525 searchTerms: ["Pairing settings", "remote backend"],
526 title: "Context match",
527 icon: null,
528 run: async () => undefined,
529 },
530 {
531 kind: "action" as const,
532 value: "setting:remote-pairing",
533 searchTerms: ["Remote pairing", "connections"],
534 title: "Remote pairing",
535 icon: null,
536 run: async () => undefined,
537 },
538 ];
539
540 const groups = filterCommandPaletteGroups({
541 activeGroups: [],
542 query: "pairing remote",
543 isInSubmenu: false,
544 projectSearchItems: [],
545 settingsSearchItems,
546 threadSearchItems: [],
547 });
548
549 expect(groups).toHaveLength(1);
550 expect(groups[0]?.value).toBe("settings-search");
551 expect(groups[0]?.items.map((item) => item.value)).toEqual([
552 "setting:remote-pairing",
553 "setting:context-match",
554 ]);
555 });
556
557 it("keeps accent-insensitive setting results", () => {
558 const groups = filterCommandPaletteGroups({
559 activeGroups: [],
560 query: "thè\u{1ab0}mes",
561 isInSubmenu: false,
562 projectSearchItems: [],
563 settingsSearchItems: [
564 {
565 kind: "action",
566 value: "setting:theme",
567 searchTerms: ["Themes", "Appearance"],
568 title: "Themes",
569 icon: null,
570 run: async () => undefined,
571 },
572 ],
573 threadSearchItems: [],
574 });
575
576 expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]);
577 });
578
579 it("normalizes case independently of the host locale", () => {
580 const toLocaleLowerCase = String.prototype.toLocaleLowerCase;
581 const localeLowerCase = vi
582 .spyOn(String.prototype, "toLocaleLowerCase")
583 .mockImplementation(function (this: string) {
584 return toLocaleLowerCase.call(this, "tr");
585 });
586 try {
587 const groups = filterCommandPaletteGroups({
588 activeGroups: [],
589 query: "GIT",
590 isInSubmenu: false,
591 projectSearchItems: [],
592 threadSearchItems: [],
593 settingsSearchItems: [
594 {
595 kind: "action",
596 value: "setting:version-control",
597 title: "Version control",
598 searchTerms: ["git"],
599 icon: null,
600 run: async () => undefined,
601 },
602 ],
603 });
604 expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([
605 "setting:version-control",
606 ]);
607 } finally {
608 localeLowerCase.mockRestore();
609 }
610 });
611
612 it("keeps message excerpts searchable without replacing thread metadata", () => {
613 const [item] = buildThreadActionItems({
614 threads: [makeThread({ branch: "feat/search" })],
615 projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]),
616 sortOrder: "updated_at",
617 icon: null,
618 getContentMatch: () => ({
619 source: "assistant",
620 snippet: "The relay reconnect is now bounded.",
621 query: "reconnect",
622 }),
623 runThread: async (_thread) => undefined,
624 });
625
626 expect(item?.searchTerms).toContain("The relay reconnect is now bounded.");
627 expect(item?.threadContentMatch).toEqual({
628 source: "assistant",
629 snippet: "The relay reconnect is now bounded.",
630 query: "reconnect",
631 });
632 expect(item?.description).toBe("T3 Code · #feat/search");
633 });
634
635 it("surfaces threads when the query is their ID, without outranking title matches", () => {
636 const idThread = makeThread({
637 id: ThreadId.make("thread-alpha-1234"),
638 title: "Unrelated work",
639 updatedAt: "2026-03-05T00:00:00.000Z",
640 });
641 const titleThread = makeThread({
642 id: ThreadId.make("thread-other-9999"),
643 title: "Fix thread-alpha-1234 flakes",
644 updatedAt: "2026-03-04T00:00:00.000Z",
645 });
646 const items = buildThreadActionItems({
647 threads: [idThread, titleThread],
648 projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]),
649 sortOrder: "updated_at",
650 icon: null,
651 runThread: async (_thread) => undefined,
652 });
653
654 const groups = filterCommandPaletteGroups({
655 activeGroups: [],
656 query: " THREAD-ALPHA-1234 ",
657 isInSubmenu: false,
658 projectSearchItems: [],
659 settingsSearchItems: [],
660 threadSearchItems: items,
661 });
662
663 expect(groups.flatMap((group) => group.items)).toEqual([
664 expect.objectContaining({ value: `thread:${titleThread.id}` }),
665 expect.objectContaining({ value: `thread:${idThread.id}` }),
666 ]);
667 });
668
669 it("prefers renderDescription when provided", () => {
670 const [item] = buildThreadActionItems({
671 threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })],
672 projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]),
673 sortOrder: "updated_at",
674 icon: null,
675 renderDescription: (thread, { projectTitle }) =>
676 `${projectTitle}:${thread.branch}:${thread.worktreePath ? "wt" : "local"}`,
677 runThread: async (_thread) => undefined,
678 });
679
680 expect(item?.description).toBe("T3 Code:feat/search:wt");
681 });
682
683 it("filters archived threads out of thread search items", () => {
684 const items = buildThreadActionItems({
685 threads: [
686 makeThread({
687 id: ThreadId.make("thread-active"),
688 title: "Active thread",
689 createdAt: "2026-03-02T00:00:00.000Z",
690 updatedAt: "2026-03-19T00:00:00.000Z",
691 }),
692 makeThread({
693 id: ThreadId.make("thread-archived"),
694 title: "Archived thread",
695 archivedAt: "2026-03-20T00:00:00.000Z",
696 updatedAt: "2026-03-20T00:00:00.000Z",
697 }),
698 ],
699 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
700 sortOrder: "updated_at",
701 icon: null,
702 runThread: async (_thread) => undefined,
703 });
704
705 expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]);
706 });
707});
708
709describe("buildBrowseGroups", () => {
710 it("waits for asynchronous browse navigation actions", async () => {
711 let finishNavigation: (() => void) | undefined;
712 const browseTo = vi.fn(
713 () =>
714 new Promise<void>((resolve) => {
715 finishNavigation = resolve;
716 }),
717 );
718 const groups = buildBrowseGroups({
719 browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }],
720 browseQuery: "~/",
721 canBrowseUp: false,
722 upIcon: null,
723 directoryIcon: null,
724 browseUp: vi.fn(),
725 browseTo,
726 });
727 const item = groups[0]?.items[0];
728 if (!item || item.kind !== "action") {
729 throw new Error("Expected a browse action");
730 }
731
732 let actionSettled = false;
733 const action = item.run().then(() => {
734 actionSettled = true;
735 });
736 await Promise.resolve();
737
738 expect(browseTo).toHaveBeenCalledWith("Downloads");
739 expect(actionSettled).toBe(false);
740
741 finishNavigation?.();
742 await action;
743 expect(actionSettled).toBe(true);
744 });
745});
746
747describe("filterPinnedBrowseEntries", () => {
748 const entries = [
749 { name: "repo", fullPath: "/projects/repo" },
750 { name: "work", fullPath: "/projects/work" },
751 ];
752
753 it("shows sibling folders without losing an existing pinned destination", () => {
754 expect(
755 filterPinnedBrowseEntries({
756 browseEntries: entries,
757 filterQuery: "repo",
758 pinnedDirectoryName: "repo",
759 caseSensitive: true,
760 }),
761 ).toEqual({ visibleEntries: entries, exactEntry: entries[0] });
762 });
763
764 it("matches an existing pinned destination without Windows casing", () => {
765 const windowsEntries = [
766 { name: "Repo", fullPath: "C:\\projects\\Repo" },
767 { name: "work", fullPath: "C:\\projects\\work" },
768 ];
769 expect(
770 filterPinnedBrowseEntries({
771 browseEntries: windowsEntries,
772 filterQuery: "repo",
773 pinnedDirectoryName: "repo",
774 caseSensitive: false,
775 }),
776 ).toEqual({
777 visibleEntries: windowsEntries,
778 exactEntry: windowsEntries[0],
779 });
780 });
781});
782
783it.each([
784 "#10839",
785 "10839",
786 "pingdotgg/t3code#10839",
787 "https://github.com/pingdotgg/t3code/pull/10839",
788])("finds linked threads from PR query %s", (query) => {
789 const items = buildThreadActionItems({
790 threads: [
791 makeThread({
792 title: "Implementation",
793 pullRequests: [
794 {
795 host: "github.com",
796 repository: "pingdotgg/t3code",
797 number: 10839,
798 url: "https://github.com/pingdotgg/t3code/pull/10839",
799 source: "manual",
800 linkedAt: "2026-09-08T00:00:00Z",
801 snapshot: null,
802 stack: null,
803 },
804 ],
805 }),
806 makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }),
807 ],
808 projectTitleById: new Map(),
809 sortOrder: "updated_at",
810 icon: null,
811 runThread: async () => undefined,
812 });
813 const groups = filterCommandPaletteGroups({
814 activeGroups: [],
815 query,
816 isInSubmenu: false,
817 projectSearchItems: [],
818 threadSearchItems: items,
819 });
820 expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([
821 "Implementation",
822 ]);
823});
824
825describe("filterCommandPaletteGroups", () => {
826 it("sorts secondary settings results after other matches", () => {
827 const item = (value: string, title: string, secondary?: boolean) =>
828 ({
829 kind: "action",
830 value,
831 title,
832 searchTerms: [title, "General"],
833 icon: null,
834 run: async () => undefined,
835 ...(secondary ? { secondary } : {}),
836 }) satisfies CommandPaletteActionItem;
837 const [group] = filterCommandPaletteGroups({
838 activeGroups: [],
839 query: "model",
840 isInSubmenu: false,
841 projectSearchItems: [],
842 settingsSearchItems: [
843 item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true),
844 item("setting:default-model", "Default model"),
845 ],
846 threadSearchItems: [],
847 });
848 expect(group?.items.map((entry) => entry.value)).toEqual([
849 "setting:default-model",
850 "setting:keybinding-modelPicker.toggle",
851 ]);
852 });
853});
854
855describe("virtualized command palette rows", () => {
856 const action = (value: string, disabled = false): CommandPaletteActionItem => ({
857 kind: "action",
858 value,
859 searchTerms: [],
860 title: value,
861 icon: null,
862 ...(disabled ? { disabled } : {}),
863 run: async () => {},
864 });
865 const groups: CommandPaletteGroup[] = [
866 { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] },
867 { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] },
868 ];
869
870 it("keeps group order and headings while indexing only enabled items", () => {
871 const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups);
872
873 expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([
874 "# Actions",
875 "actions:new-thread",
876 "actions:offline",
877 "# Threads",
878 "threads:thread-a",
879 "threads:thread-b",
880 ]);
881 expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]);
882 expect(rowIndexByItemIndex).toEqual([1, 4, 5]);
883 expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([
884 0,
885 null,
886 1,
887 2,
888 ]);
889 });
890
891 it("resolves Enter to the highlighted item without needing its row mounted", () => {
892 expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b");
893 expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull();
894 expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
895 });
896});