Open History to see the last commit
1import { threadKey } from "@t3tools/client-runtime/state/entities";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";
4import { page, userEvent } from "vite-plus/test/browser";
5
6import { act, render } from "../../test/render";
7import { openCommandPalette } from "../commandPaletteBus";
8import { useCommandPaletteStore } from "../commandPaletteStore";
9import { LAST_USED_PROJECT_STORAGE_KEY } from "../hooks/useHandleNewThread";
10import { removeLocalStorageItem } from "../hooks/useLocalStorage";
11import { makeThreadFixture } from "../test-fixtures";
12import type { Project, Thread } from "../types";
13import { CommandPalette } from "./CommandPalette";
14
15const entities = vi.hoisted(() => ({
16 projects: [] as ReadonlyArray<Project>,
17 threads: [] as ReadonlyArray<Thread>,
18 delegatedUnderParent: new Set<string>() as ReadonlySet<string>,
19}));
20vi.mock("../state/entities", async (original) => ({
21 ...(await original<typeof import("../state/entities")>()),
22 useProjects: () => entities.projects,
23 useVisibleThreadShells: () => entities.threads,
24 useDelegatedUnderParent: () => entities.delegatedUnderParent,
25}));
26
27function setup(state: Partial<typeof entities> = {}) {
28 // Earlier browser test files share this origin's localStorage; a remembered project
29 // that is not listed here hides the default "New thread in <project>" row.
30 removeLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY);
31 useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true);
32 entities.projects = state.projects ?? [];
33 entities.threads = state.threads ?? [];
34 entities.delegatedUnderParent = state.delegatedUnderParent ?? new Set();
35}
36
37describe("CommandPalette open intents", () => {
38 it("drags a searched thread into the current composer without navigating", async () => {
39 const environmentId = EnvironmentId.make("remote");
40 const threadId = ThreadId.make("dragged-thread");
41 setup({
42 threads: [makeThreadFixture({ environmentId, id: threadId, title: "Drag this thread" })],
43 });
44 const onDrop = vi.fn();
45 await using screen = await render(
46 <CommandPalette>
47 <div
48 data-thread-context-drop="true"
49 ref={(node) => {
50 node?.addEventListener("t3-thread-context-drop", onDrop);
51 }}
52 style={{ position: "fixed", bottom: 0, width: 300, height: 100 }}
53 >
54 Composer
55 </div>
56 </CommandPalette>,
57 );
58 const pathname = screen.router?.state.location.pathname;
59 openCommandPalette({ query: "Drag this thread" });
60 const row = page.getByRole("option", { name: /Drag this thread/ });
61 await expect.element(row).toBeVisible();
62 const bounds = row.element().getBoundingClientRect();
63 const pointer = (type: string, x: number, y: number) =>
64 new PointerEvent(type, {
65 bubbles: true,
66 cancelable: true,
67 pointerId: 1,
68 isPrimary: true,
69 button: 0,
70 buttons: type === "pointerup" ? 0 : 1,
71 clientX: x,
72 clientY: y,
73 });
74 await act(() => {
75 row.element().dispatchEvent(pointer("pointerdown", bounds.x + 20, bounds.y + 10));
76 document.dispatchEvent(pointer("pointermove", bounds.x + 30, bounds.y + 20));
77 });
78 expect(useCommandPaletteStore.getState().open).toBe(false);
79 await act(() => {
80 document.dispatchEvent(pointer("pointermove", 50, window.innerHeight - 30));
81 document.dispatchEvent(pointer("pointerup", 50, window.innerHeight - 30));
82 });
83 expect(onDrop).toHaveBeenCalledOnce();
84 expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([
85 { environmentId, threadId, title: "Drag this thread" },
86 ]);
87 expect(screen.router?.state.location.pathname).toBe(pathname);
88 });
89
90 it("opens New thread in... with focus in the search input, not the Back button", async () => {
91 setup();
92 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
93
94 openCommandPalette({ open: "new-thread-in" });
95
96 await expect.element(page.getByPlaceholder("Search...")).toHaveFocus();
97 });
98
99 it("highlights the first project when New thread in... opens from the root list", async () => {
100 const environmentId = EnvironmentId.make("local");
101 const project = (id: string, title: string): Project => ({
102 id: ProjectId.make(id),
103 environmentId,
104 title,
105 workspaceRoot: `/work/${id}`,
106 repositoryIdentity: null,
107 defaultModelSelection: null,
108 createdAt: "2026-03-01T00:00:00.000Z",
109 updatedAt: "2026-03-01T00:00:00.000Z",
110 scripts: [],
111 });
112 setup({
113 projects: [project("acme", "Acme"), project("beta", "Beta"), project("gamma", "Gamma")],
114 });
115 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
116
117 openCommandPalette({});
118 await expect
119 .element(page.getByPlaceholder("Search commands, projects, and threads..."))
120 .toHaveFocus();
121 await userEvent.keyboard("{ArrowDown}");
122 await expect
123 .element(page.getByRole("option", { name: /^New thread in\.\.\./ }))
124 .toHaveAttribute("data-highlighted");
125 await userEvent.keyboard("{Enter}");
126
127 await expect.element(page.getByPlaceholder("Search...")).toHaveFocus();
128 await expect.element(page.getByRole("option").first()).toHaveAttribute("data-highlighted");
129 await expect.element(page.getByRole("option").nth(1)).not.toHaveAttribute("data-highlighted");
130 });
131
132 it("opens a search intent with its query in the focused input", async () => {
133 setup();
134 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
135
136 openCommandPalette({ query: "acme" });
137
138 const input = page.getByPlaceholder("Search commands, projects, and threads...");
139 await expect.element(input).toHaveFocus();
140 await expect.element(input).toHaveValue("acme");
141 });
142
143 it("opens a searched project's latest listed thread, not a newer subagent, delegated task, or parked thread", async () => {
144 const environmentId = EnvironmentId.make("local");
145 const projectId = ProjectId.make("acme");
146 const parentId = ThreadId.make("parent");
147 const delegatedId = ThreadId.make("delegated");
148 setup({
149 delegatedUnderParent: new Set([threadKey({ environmentId, threadId: delegatedId })]),
150 projects: [
151 {
152 id: projectId,
153 environmentId,
154 title: "Acme",
155 workspaceRoot: "/work/acme",
156 repositoryIdentity: null,
157 defaultModelSelection: null,
158 createdAt: "2026-03-01T00:00:00.000Z",
159 updatedAt: "2026-03-01T00:00:00.000Z",
160 scripts: [],
161 },
162 ],
163 threads: [
164 makeThreadFixture({
165 id: parentId,
166 environmentId,
167 projectId,
168 latestUserMessageAt: "2026-03-02T00:00:00.000Z",
169 }),
170 makeThreadFixture({
171 id: ThreadId.make("subagent"),
172 environmentId,
173 projectId,
174 updatedAt: "2026-03-03T00:00:00.000Z",
175 lineage: {
176 rootThreadId: parentId,
177 parentThreadId: parentId,
178 relationshipToParent: "subagent",
179 },
180 }),
181 makeThreadFixture({
182 id: delegatedId,
183 environmentId,
184 projectId,
185 latestUserMessageAt: "2026-03-04T00:00:00.000Z",
186 }),
187 makeThreadFixture({
188 id: ThreadId.make("settled"),
189 environmentId,
190 projectId,
191 latestUserMessageAt: "2026-03-05T00:00:00.000Z",
192 settledOverride: "settled",
193 }),
194 makeThreadFixture({
195 id: ThreadId.make("snoozed"),
196 environmentId,
197 projectId,
198 latestUserMessageAt: "2026-03-06T00:00:00.000Z",
199 snoozedUntil: "2999-01-01T00:00:00.000Z",
200 }),
201 ],
202 });
203 await using screen = await render(<CommandPalette>{null}</CommandPalette>);
204
205 openCommandPalette({ query: "Acme" });
206 await page.getByRole("option", { name: /Acme/ }).first().click();
207
208 await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent"));
209 });
210});