1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 DEFAULT_CLIENT_SETTINGS,
4 EnvironmentId,
5 ProjectId,
6 ThreadId,
7 type ScopedThreadRef,
8} from "@t3tools/contracts";
9import { isMacPlatform } from "@t3tools/shared/keybindings";
10import { describe, expect, it, vi } from "vite-plus/test";
11import { userEvent } from "vite-plus/test/browser";
12
13import { act, fakeTimers, render } from "../../test/render";
14import { openCommandPalette } from "../commandPaletteBus";
15import { makeThreadFixture } from "../test-fixtures";
16import { useHandleNewThread } from "../hooks/useHandleNewThread";
17import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings";
18import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget";
19import { useProjects, useQuietThreadShells } from "../state/entities";
20import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
21import { useOrchestrationCommand } from "../state/use-orchestration-command";
22import { useTerminalUiStateStore } from "../terminalUiStateStore";
23import type { Project } from "../types";
24import Sidebar from "./Sidebar";
25import { THREAD_JUMP_HINT_SHOW_DELAY_MS } from "./Sidebar.logic";
26import { SortableThreadRow } from "./sidebar/SidebarThreadRow";
27import { SidebarProvider } from "./ui/sidebar";
28
29vi.mock("../state/entities", { spy: true });
30vi.mock("../hooks/useHandleNewThread", { spy: true });
31vi.mock("../hooks/useThreadRouteTarget", { spy: true });
32vi.mock("./sidebar/SidebarThreadRow", { spy: true });
33vi.mock("../state/session", { spy: true });
34vi.mock("../state/use-orchestration-command", { spy: true });
35vi.mock(import("../state/server"), async (original) => {
36 const { Atom } = await import("effect/reactivity");
37 const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings");
38 // The desktop defaults: holding Mod alone already matches a thread jump.
39 const keybindings = compileResolvedKeybindingsConfig([
40 { key: "mod+1", command: "thread.jump.1" },
41 { key: "mod+2", command: "thread.jump.2" },
42 ]);
43 return { ...(await original()), primaryServerKeybindingsAtom: Atom.make(keybindings) };
44});
45vi.mock(import("../connection/catalog"), async (original) => {
46 const actual = await original();
47 const { Atom } = await import("effect/reactivity");
48 return {
49 ...actual,
50 environmentCatalog: {
51 ...actual.environmentCatalog,
52 catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }) as never,
53 },
54 };
55});
56vi.mock("../commandPaletteBus", () => ({
57 openCommandPalette: vi.fn(),
58 isCommandPaletteOpen: () => false,
59}));
60vi.mock("./sidebar/SidebarChrome", () => ({
61 SidebarChromeHeader: () => null,
62 SidebarChromeFooter: () => null,
63}));
64
65function project(id: string): Project {
66 return {
67 id: ProjectId.make(id),
68 environmentId: EnvironmentId.make("local"),
69 title: id,
70 workspaceRoot: `/projects/${id}`,
71 repositoryIdentity: null,
72 defaultModelSelection: null,
73 createdAt: "2026-01-01T00:00:00.000Z",
74 updatedAt: "2026-01-01T00:00:00.000Z",
75 scripts: [],
76 };
77}
78
79describe("sidebar header new thread", () => {
80 it("does not render the thread list when terminal focus changes without held modifiers", async () => {
81 vi.mocked(useProjects).mockReturnValue([]);
82 vi.mocked(useHandleNewThread).mockReturnValue({
83 activeDraftThread: null,
84 activeThread: null,
85 defaultProjectRef: null,
86 defaultProjectFallbackReady: true,
87 handleNewThread: vi.fn().mockResolvedValue(null),
88 routeDraftId: null,
89 routeThreadRef: null,
90 });
91 await using screen = await render(
92 <SidebarProvider>
93 <Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
94 <input aria-label="Outside" />
95 <div data-terminal-owner="drawer">
96 <input aria-label="Terminal" />
97 </div>
98 </SidebarProvider>,
99 );
100 await act(() =>
101 screen.container.querySelector<HTMLInputElement>('[aria-label="Outside"]')!.focus(),
102 );
103 vi.mocked(useProjects).mockClear();
104
105 await act(() =>
106 screen.container.querySelector<HTMLInputElement>('[aria-label="Terminal"]')!.focus(),
107 );
108 await act(() =>
109 screen.container.querySelector<HTMLInputElement>('[aria-label="Outside"]')!.focus(),
110 );
111
112 expect(useProjects).not.toHaveBeenCalled();
113 });
114 it.each([
115 { mode: "normal", count: 2, shift: false, picker: false },
116 { mode: "boring", count: 2, shift: false, picker: false },
117 { mode: "boring", count: 2, shift: true, picker: false },
118 { mode: "boring", count: 1, shift: false, picker: false },
119 ] as const)("$mode sidebar, $count projects, shift=$shift: picker=$picker", async (scenario) => {
120 using stack = new DisposableStack();
121 const previousSettings = getClientSettings();
122 stack.defer(() => __setClientSettingsForTests(previousSettings));
123 __setClientSettingsForTests({
124 ...DEFAULT_CLIENT_SETTINGS,
125 boringSidebarEnabled: scenario.mode === "boring",
126 });
127 const projects = [project("active"), project("idle")].slice(0, scenario.count);
128 const currentProjectRef = {
129 environmentId: projects[0]!.environmentId,
130 projectId: projects[0]!.id,
131 };
132 const startThread = vi.fn().mockResolvedValue(null);
133 vi.mocked(useProjects).mockReturnValue(projects);
134 vi.mocked(useHandleNewThread).mockReturnValue({
135 activeDraftThread: null,
136 activeThread: null,
137 defaultProjectRef: currentProjectRef,
138 defaultProjectFallbackReady: true,
139 handleNewThread: startThread,
140 routeDraftId: null,
141 routeThreadRef: null,
142 });
143 vi.mocked(openCommandPalette).mockClear();
144 await using screen = await render(
145 <SidebarProvider>
146 <Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
147 </SidebarProvider>,
148 );
149
150 await userEvent.click(screen.getByRole("button", { name: "New thread", exact: true }), {
151 modifiers: scenario.shift ? ["Shift"] : [],
152 });
153
154 if (scenario.picker) {
155 expect(openCommandPalette).toHaveBeenCalledExactlyOnceWith({ open: "new-thread-in" });
156 expect(startThread).not.toHaveBeenCalled();
157 } else {
158 expect(openCommandPalette).not.toHaveBeenCalled();
159 expect(startThread).toHaveBeenCalledExactlyOnceWith(currentProjectRef);
160 }
161 });
162});
163
164function threadShell(projectId: ProjectId, index: number): EnvironmentThreadShell {
165 return makeThreadFixture({
166 environmentId: EnvironmentId.make("local"),
167 id: ThreadId.make(`thread-${index}`),
168 projectId,
169 title: `Thread ${index}`,
170 });
171}
172
173describe("sidebar render isolation", () => {
174 // regression: #33100 Cmd+J re-rendered every sidebar row, which took hundreds of ms with many threads
175 it("does not re-render thread rows when a quick Mod shortcut opens and focuses the terminal", async () => {
176 using stack = new DisposableStack();
177 const activeProject = project("active");
178 const threads = Array.from({ length: 5 }, (_, index) => threadShell(activeProject.id, index));
179 const routeThreadRef: ScopedThreadRef = {
180 environmentId: activeProject.environmentId,
181 threadId: threads[0]!.id,
182 };
183 vi.mocked(useProjects).mockReturnValue([activeProject]);
184 vi.mocked(useQuietThreadShells).mockReturnValue(threads);
185 vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "server", threadRef: routeThreadRef });
186 vi.mocked(useHandleNewThread).mockReturnValue({
187 activeDraftThread: null,
188 activeThread: null,
189 defaultProjectRef: {
190 environmentId: activeProject.environmentId,
191 projectId: activeProject.id,
192 },
193 defaultProjectFallbackReady: true,
194 handleNewThread: vi.fn().mockResolvedValue(null),
195 routeDraftId: null,
196 routeThreadRef,
197 });
198 const terminal = document.createElement("div");
199 terminal.dataset.terminalOwner = "drawer";
200 const terminalInput = document.createElement("textarea");
201 terminal.append(terminalInput);
202 document.body.append(terminal);
203 stack.defer(() => terminal.remove());
204 stack.defer(() => useTerminalUiStateStore.getState().setTerminalOpen(routeThreadRef, false));
205 await using screen = await render(
206 <SidebarProvider>
207 <Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
208 </SidebarProvider>,
209 );
210 await expect.element(screen.getByText("Thread 4").first()).toBeInTheDocument();
211 const rowRenders = () => vi.mocked(SortableThreadRow).mock.calls.length;
212
213 using _timers = fakeTimers();
214 const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control";
215 const beforeShortcut = rowRenders();
216 await userEvent.keyboard(`{${modifier}>}`);
217 await act(() => useTerminalUiStateStore.getState().setTerminalOpen(routeThreadRef, true));
218 await act(() => terminalInput.focus());
219 await userEvent.keyboard(`{/${modifier}}`);
220 await act(() => terminalInput.blur());
221 expect(
222 rowRenders() - beforeShortcut,
223 "thread rows re-rendered while opening the terminal with a quick Mod shortcut",
224 ).toBe(0);
225
226 const jumpLabelsSince = (start: number) =>
227 vi
228 .mocked(SortableThreadRow)
229 .mock.calls.slice(start)
230 .map(([props]) => props.jumpLabel)
231 .filter((label) => label !== null);
232 const beforeHold = rowRenders();
233 await userEvent.keyboard(`{${modifier}>}`);
234 await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS));
235 expect(jumpLabelsSince(beforeHold), "holding Mod showed no jump hints").toHaveLength(2);
236 const beforeRelease = rowRenders();
237 await userEvent.keyboard(`{/${modifier}}`);
238 expect(rowRenders()).toBeGreaterThan(beforeRelease);
239 expect(jumpLabelsSince(beforeRelease)).toEqual([]);
240 });
241});
242
243describe("sidebar thread rename", () => {
244 // regression: #35151 an untouched rename wrote back the title it opened with over a newer one
245 it.each(["{Enter}", "{Tab}"])(
246 "ending an untouched rename with %s keeps a title that arrived while it was open",
247 async (finish) => {
248 const activeProject = project("active");
249 const routeThreadRef: ScopedThreadRef = {
250 environmentId: activeProject.environmentId,
251 threadId: ThreadId.make("thread-0"),
252 };
253 const shellTitled = (title: string) => ({ ...threadShell(activeProject.id, 0), title });
254 const updateMetadata = vi.fn().mockResolvedValue({ _tag: "Success" });
255 vi.mocked(useOrchestrationCommand).mockReturnValue(updateMetadata);
256 vi.mocked(readEnvironmentScope).mockReturnValue(true);
257 vi.mocked(useEnvironmentScope).mockReturnValue(true);
258 vi.mocked(useProjects).mockReturnValue([activeProject]);
259 vi.mocked(useQuietThreadShells).mockReturnValue([shellTitled("Fix the login redirect")]);
260 vi.mocked(useThreadRouteTarget).mockReturnValue({
261 kind: "server",
262 threadRef: routeThreadRef,
263 });
264 vi.mocked(useHandleNewThread).mockReturnValue({
265 activeDraftThread: null,
266 activeThread: null,
267 defaultProjectRef: {
268 environmentId: activeProject.environmentId,
269 projectId: activeProject.id,
270 },
271 defaultProjectFallbackReady: true,
272 handleNewThread: vi.fn().mockResolvedValue(null),
273 routeDraftId: null,
274 routeThreadRef,
275 });
276 const tree = () => (
277 <SidebarProvider>
278 <Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
279 </SidebarProvider>
280 );
281 await using screen = await render(tree());
282 await userEvent.dblClick(screen.getByText("Fix the login redirect").first());
283 const input = screen.getByRole("textbox", { name: "Thread title" });
284 await expect.element(input).toHaveValue("Fix the login redirect");
285
286 vi.mocked(useQuietThreadShells).mockReturnValue([shellTitled("Generated title")]);
287 await screen.rerender(tree());
288 await userEvent.keyboard(finish);
289
290 await expect.element(input).not.toBeInTheDocument();
291 expect(updateMetadata).not.toHaveBeenCalled();
292 },
293 );
294});