Skip to content

Commit cc32018

Browse files
committed
Refactor command palette and fix browser tests
1 parent 019e830 commit cc32018

6 files changed

Lines changed: 548 additions & 376 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
13911391

13921392
try {
13931393
const useMetaForMod = isMacPlatform(navigator.platform);
1394+
const palette = page.getByTestId("command-palette");
13941395
window.dispatchEvent(
13951396
new KeyboardEvent("keydown", {
13961397
key: "k",
@@ -1401,9 +1402,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
14011402
}),
14021403
);
14031404

1404-
await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
1405-
await expect.element(page.getByText("New thread")).toBeInTheDocument();
1406-
await page.getByText("New thread").click();
1405+
await expect.element(palette).toBeInTheDocument();
1406+
await expect
1407+
.element(palette.getByText("New thread in Project", { exact: true }))
1408+
.toBeInTheDocument();
1409+
await palette.getByText("New thread in Project", { exact: true }).click();
14071410

14081411
await waitForURL(
14091412
mounted.router,
@@ -1448,6 +1451,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
14481451

14491452
try {
14501453
const useMetaForMod = isMacPlatform(navigator.platform);
1454+
const palette = page.getByTestId("command-palette");
14511455
window.dispatchEvent(
14521456
new KeyboardEvent("keydown", {
14531457
key: "k",
@@ -1458,10 +1462,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
14581462
}),
14591463
);
14601464

1461-
await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
1465+
await expect.element(palette).toBeInTheDocument();
14621466
await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings");
1463-
await expect.element(page.getByText("Open settings")).toBeInTheDocument();
1464-
await expect.element(page.getByText("New thread")).not.toBeInTheDocument();
1467+
await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument();
1468+
await expect
1469+
.element(palette.getByText("New thread in Project", { exact: true }))
1470+
.not.toBeInTheDocument();
14651471
} finally {
14661472
await mounted.cleanup();
14671473
}
@@ -1500,6 +1506,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
15001506

15011507
try {
15021508
const useMetaForMod = isMacPlatform(navigator.platform);
1509+
const palette = page.getByTestId("command-palette");
15031510
window.dispatchEvent(
15041511
new KeyboardEvent("keydown", {
15051512
key: "k",
@@ -1510,10 +1517,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
15101517
}),
15111518
);
15121519

1513-
await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
1520+
await expect.element(palette).toBeInTheDocument();
15141521
await page.getByPlaceholder("Search commands, projects, and threads...").fill("project");
1515-
await expect.element(page.getByText("Project")).toBeInTheDocument();
1516-
await expect.element(page.getByText("New thread")).not.toBeInTheDocument();
1522+
await expect.element(palette.getByText("Project", { exact: true })).toBeInTheDocument();
1523+
await expect
1524+
.element(palette.getByText("New thread in Project", { exact: true }))
1525+
.not.toBeInTheDocument();
15171526
} finally {
15181527
await mounted.cleanup();
15191528
}
@@ -1522,7 +1531,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
15221531
it("searches projects by path and opens a new thread using the default env mode", async () => {
15231532
localStorage.setItem(
15241533
"t3code:app-settings:v1",
1525-
JSON.stringify({ defaultThreadEnvMode: "worktree" }),
1534+
JSON.stringify({
1535+
codexBinaryPath: "",
1536+
codexHomePath: "",
1537+
defaultThreadEnvMode: "worktree",
1538+
confirmThreadDelete: true,
1539+
enableAssistantStreaming: false,
1540+
timestampFormat: "locale",
1541+
customCodexModels: [],
1542+
}),
15261543
);
15271544

15281545
const mounted = await mountChatView({
@@ -1554,6 +1571,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
15541571

15551572
try {
15561573
const useMetaForMod = isMacPlatform(navigator.platform);
1574+
const palette = page.getByTestId("command-palette");
15571575
window.dispatchEvent(
15581576
new KeyboardEvent("keydown", {
15591577
key: "k",
@@ -1564,11 +1582,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
15641582
}),
15651583
);
15661584

1567-
await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
1585+
await expect.element(palette).toBeInTheDocument();
15681586
await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs");
1569-
await expect.element(page.getByText("Docs Portal")).toBeInTheDocument();
1570-
await expect.element(page.getByText("/repo/clients/docs-portal")).toBeInTheDocument();
1571-
await page.getByText("Docs Portal").click();
1587+
await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument();
1588+
await expect
1589+
.element(palette.getByText("/repo/clients/docs-portal", { exact: true }))
1590+
.toBeInTheDocument();
1591+
await palette.getByText("Docs Portal", { exact: true }).click();
15721592

15731593
const nextPath = await waitForURL(
15741594
mounted.router,
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts";
2+
import { type ReactNode } from "react";
3+
import { formatRelativeTime } from "../relativeTime";
4+
import { type Project, type Thread } from "../types";
5+
6+
export const RECENT_THREAD_LIMIT = 12;
7+
export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
8+
export const ADDON_ICON_CLASS = "size-4";
9+
10+
export interface CommandPaletteItem {
11+
readonly kind: "action" | "submenu";
12+
readonly value: string;
13+
readonly label: string;
14+
readonly title: ReactNode;
15+
readonly description?: string;
16+
readonly searchText?: string;
17+
readonly timestamp?: string;
18+
readonly icon: ReactNode;
19+
readonly shortcutCommand?: KeybindingCommand;
20+
}
21+
22+
export interface CommandPaletteActionItem extends CommandPaletteItem {
23+
readonly kind: "action";
24+
readonly keepOpen?: boolean;
25+
readonly run: () => Promise<void>;
26+
}
27+
28+
export interface CommandPaletteSubmenuItem extends CommandPaletteItem {
29+
readonly kind: "submenu";
30+
readonly addonIcon: ReactNode;
31+
readonly groups: ReadonlyArray<CommandPaletteGroup>;
32+
readonly initialQuery?: string;
33+
}
34+
35+
export interface CommandPaletteGroup {
36+
readonly value: string;
37+
readonly label: string;
38+
readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
39+
}
40+
41+
export interface CommandPaletteView {
42+
readonly addonIcon: ReactNode;
43+
readonly groups: ReadonlyArray<CommandPaletteGroup>;
44+
readonly initialQuery?: string;
45+
}
46+
47+
export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
48+
49+
export function compareThreadsByCreatedAtDesc(
50+
left: { id: string; createdAt: string },
51+
right: { id: string; createdAt: string },
52+
): number {
53+
const byTimestamp = Date.parse(right.createdAt) - Date.parse(left.createdAt);
54+
if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) {
55+
return byTimestamp;
56+
}
57+
return right.id.localeCompare(left.id);
58+
}
59+
60+
export function normalizeSearchText(value: string): string {
61+
return value.trim().toLowerCase().replace(/\s+/g, " ");
62+
}
63+
64+
export function buildProjectActionItems(input: {
65+
projects: ReadonlyArray<Project>;
66+
valuePrefix: string;
67+
icon: ReactNode;
68+
runProject: (projectId: Project["id"]) => Promise<void>;
69+
}): CommandPaletteActionItem[] {
70+
return input.projects.map((project) => ({
71+
kind: "action",
72+
value: `${input.valuePrefix}:${project.id}`,
73+
label: `${project.name} ${project.cwd}`.trim(),
74+
title: project.name,
75+
description: project.cwd,
76+
icon: input.icon,
77+
run: async () => {
78+
await input.runProject(project.id);
79+
},
80+
}));
81+
}
82+
83+
export function buildThreadActionItems(input: {
84+
threads: ReadonlyArray<Thread>;
85+
activeThreadId?: Thread["id"];
86+
projectTitleById: ReadonlyMap<Project["id"], string>;
87+
icon: ReactNode;
88+
runThread: (threadId: Thread["id"]) => Promise<void>;
89+
limit?: number;
90+
}): CommandPaletteActionItem[] {
91+
const sortedThreads = input.threads.toSorted(compareThreadsByCreatedAtDesc);
92+
const visibleThreads =
93+
input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit);
94+
95+
return visibleThreads.map((thread) => {
96+
const projectTitle = input.projectTitleById.get(thread.projectId);
97+
const descriptionParts: string[] = [];
98+
99+
if (projectTitle) {
100+
descriptionParts.push(projectTitle);
101+
}
102+
if (thread.branch) {
103+
descriptionParts.push(`#${thread.branch}`);
104+
}
105+
if (thread.id === input.activeThreadId) {
106+
descriptionParts.push("Current thread");
107+
}
108+
109+
return {
110+
kind: "action",
111+
value: `thread:${thread.id}`,
112+
label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(),
113+
title: thread.title,
114+
description: descriptionParts.join(" · "),
115+
timestamp: formatRelativeTime(thread.createdAt),
116+
icon: input.icon,
117+
run: async () => {
118+
await input.runThread(thread.id);
119+
},
120+
};
121+
});
122+
}
123+
124+
export function filterCommandPaletteGroups(input: {
125+
activeGroups: ReadonlyArray<CommandPaletteGroup>;
126+
query: string;
127+
isInSubmenu: boolean;
128+
projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
129+
threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
130+
}): CommandPaletteGroup[] {
131+
const isActionsFilter = input.query.startsWith(">");
132+
const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
133+
const normalizedQuery = normalizeSearchText(searchQuery);
134+
135+
if (normalizedQuery.length === 0) {
136+
if (isActionsFilter) {
137+
return input.activeGroups.filter((group) => group.value === "actions");
138+
}
139+
return [...input.activeGroups];
140+
}
141+
142+
let baseGroups = [...input.activeGroups];
143+
if (isActionsFilter) {
144+
baseGroups = baseGroups.filter((group) => group.value === "actions");
145+
} else if (!input.isInSubmenu) {
146+
baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");
147+
}
148+
149+
const searchableGroups = [...baseGroups];
150+
if (!input.isInSubmenu && !isActionsFilter) {
151+
if (input.projectSearchItems.length > 0) {
152+
searchableGroups.push({
153+
value: "projects-search",
154+
label: "Projects",
155+
items: input.projectSearchItems,
156+
});
157+
}
158+
if (input.threadSearchItems.length > 0) {
159+
searchableGroups.push({
160+
value: "threads-search",
161+
label: "Threads",
162+
items: input.threadSearchItems,
163+
});
164+
}
165+
}
166+
167+
return searchableGroups.flatMap((group) => {
168+
const items = group.items.filter((item) => {
169+
const haystack = normalizeSearchText(
170+
[item.searchText ?? item.label, item.searchText ? "" : (item.description ?? "")].join(" "),
171+
);
172+
return haystack.includes(normalizedQuery);
173+
});
174+
175+
if (items.length === 0) {
176+
return [];
177+
}
178+
179+
return [{ value: group.value, label: group.label, items }];
180+
});
181+
}
182+
183+
export function buildBrowseGroups(input: {
184+
browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
185+
canBrowseUp: boolean;
186+
upIcon: ReactNode;
187+
directoryIcon: ReactNode;
188+
browseUp: () => void;
189+
browseTo: (name: string) => void;
190+
}): CommandPaletteGroup[] {
191+
const items: CommandPaletteActionItem[] = [];
192+
193+
if (input.canBrowseUp) {
194+
items.push({
195+
kind: "action",
196+
value: "browse:up",
197+
label: "..",
198+
title: "..",
199+
icon: input.upIcon,
200+
keepOpen: true,
201+
run: async () => {
202+
input.browseUp();
203+
},
204+
});
205+
}
206+
207+
for (const entry of input.browseEntries) {
208+
items.push({
209+
kind: "action",
210+
value: `browse:${entry.fullPath}`,
211+
label: entry.name,
212+
title: entry.name,
213+
icon: input.directoryIcon,
214+
keepOpen: true,
215+
run: async () => {
216+
input.browseTo(entry.name);
217+
},
218+
});
219+
}
220+
221+
return [{ value: "directories", label: "Directories", items }];
222+
}
223+
224+
export function getCommandPaletteMode(input: {
225+
currentView: CommandPaletteView | null;
226+
isBrowsing: boolean;
227+
}): CommandPaletteMode {
228+
if (input.currentView) {
229+
return input.isBrowsing ? "submenu-browse" : "submenu";
230+
}
231+
return input.isBrowsing ? "root-browse" : "root";
232+
}
233+
234+
export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
235+
switch (mode) {
236+
case "root":
237+
return "Search commands, projects, and threads...";
238+
case "root-browse":
239+
return "Enter project path (e.g. ~/projects/my-app)";
240+
case "submenu":
241+
return "Search...";
242+
case "submenu-browse":
243+
return "Enter path (e.g. ~/projects/my-app)";
244+
}
245+
}
246+
247+
export function getCommandPaletteInputStartAddon(input: {
248+
mode: CommandPaletteMode;
249+
currentViewAddonIcon: ReactNode | null;
250+
browseIcon: ReactNode;
251+
}): ReactNode | undefined {
252+
if (input.mode === "submenu" || input.mode === "submenu-browse") {
253+
return input.currentViewAddonIcon ?? undefined;
254+
}
255+
if (input.mode === "root-browse") {
256+
return input.browseIcon;
257+
}
258+
return undefined;
259+
}

0 commit comments

Comments
 (0)