Skip to content

Commit c7ef76e

Browse files
committed
feat(ui): refine user interface
1 parent e2116e7 commit c7ef76e

32 files changed

Lines changed: 2472 additions & 915 deletions

src/features/app/components/PinnedThreadList.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("PinnedThreadList", () => {
6464
expect(row.querySelector(".thread-status")?.className).toContain(
6565
"reviewing",
6666
);
67-
expect(screen.getByLabelText("Pinned")).toBeTruthy();
67+
expect(screen.queryByText("Pinned")).toBeNull();
6868

6969
fireEvent.click(row);
7070
expect(onSelectThread).toHaveBeenCalledWith("ws-1", "thread-1");

src/features/app/components/PinnedThreadList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function PinnedThreadList({
9191
hasSubagentChildren={visibility.rowsWithChildren.has(row)}
9292
subagentsExpanded={!collapsedThreadKeys.has(threadKey)}
9393
onToggleSubagents={toggleThreadSubagents}
94+
showPinnedLabel={false}
9495
/>
9596
);
9697
})}

src/features/app/components/Sidebar.test.tsx

Lines changed: 294 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @vitest-environment jsdom
2-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { afterEach, describe, expect, it, vi } from "vitest";
44
import { createRef } from "react";
55
import { Sidebar } from "./Sidebar";
@@ -74,20 +74,20 @@ describe("Sidebar", () => {
7474
render(<Sidebar {...baseProps} />);
7575

7676
const toggleButton = screen.getByRole("button", { name: "Toggle search" });
77-
expect(screen.queryByLabelText("Search projects")).toBeNull();
77+
expect(screen.queryByLabelText("Search conversations")).toBeNull();
7878

7979
fireEvent.click(toggleButton);
80-
const input = screen.getByLabelText("Search projects") as HTMLInputElement;
80+
const input = screen.getByLabelText("Search conversations") as HTMLInputElement;
8181
expect(input).toBeTruthy();
8282

8383
fireEvent.change(input, { target: { value: "alpha" } });
8484
expect(input.value).toBe("alpha");
8585

8686
fireEvent.click(toggleButton);
87-
expect(screen.queryByLabelText("Search projects")).toBeNull();
87+
expect(screen.queryByLabelText("Search conversations")).toBeNull();
8888

8989
fireEvent.click(toggleButton);
90-
const reopened = screen.getByLabelText("Search projects") as HTMLInputElement;
90+
const reopened = screen.getByLabelText("Search conversations") as HTMLInputElement;
9191
expect(reopened.value).toBe("");
9292
});
9393

@@ -208,12 +208,301 @@ describe("Sidebar", () => {
208208
const renderedNames = Array.from(container.querySelectorAll(".thread-row .thread-name")).map(
209209
(node) => node.textContent?.trim(),
210210
);
211+
expect(screen.getByText("Recent conversations")).toBeTruthy();
211212
expect(renderedNames[0]).toBe("Newer thread");
212213
expect(renderedNames[1]).toBe("Older thread");
213214
expect(screen.getByText("Alpha Project")).toBeTruthy();
214215
expect(screen.getByText("Beta Project")).toBeTruthy();
215216
});
216217

218+
it("keeps a project visible when its thread matches the search query", async () => {
219+
render(
220+
<Sidebar
221+
{...baseProps}
222+
workspaces={[
223+
{
224+
id: "ws-1",
225+
name: "Alpha Project",
226+
path: "/tmp/alpha",
227+
connected: true,
228+
settings: { sidebarCollapsed: false },
229+
},
230+
{
231+
id: "ws-2",
232+
name: "Beta Project",
233+
path: "/tmp/beta",
234+
connected: true,
235+
settings: { sidebarCollapsed: false },
236+
},
237+
]}
238+
groupedWorkspaces={[
239+
{
240+
id: null,
241+
name: "Workspaces",
242+
workspaces: [
243+
{
244+
id: "ws-1",
245+
name: "Alpha Project",
246+
path: "/tmp/alpha",
247+
connected: true,
248+
settings: { sidebarCollapsed: false },
249+
},
250+
{
251+
id: "ws-2",
252+
name: "Beta Project",
253+
path: "/tmp/beta",
254+
connected: true,
255+
settings: { sidebarCollapsed: false },
256+
},
257+
],
258+
},
259+
]}
260+
threadsByWorkspace={{
261+
"ws-1": [{ id: "thread-1", name: "Fix workspace restore", updatedAt: 1000 }],
262+
"ws-2": [{ id: "thread-2", name: "Unrelated thread", updatedAt: 900 }],
263+
}}
264+
/>,
265+
);
266+
267+
fireEvent.click(screen.getByRole("button", { name: "Toggle search" }));
268+
fireEvent.change(screen.getByLabelText("Search conversations"), {
269+
target: { value: "restore" },
270+
});
271+
272+
await waitFor(() => {
273+
expect(screen.getByText("Alpha Project")).toBeTruthy();
274+
expect(screen.getByText("Fix workspace restore")).toBeTruthy();
275+
expect(screen.queryByText("Beta Project")).toBeNull();
276+
expect(screen.queryByText("Unrelated thread")).toBeNull();
277+
});
278+
});
279+
280+
it("searches across loaded root threads before collapsed truncation", async () => {
281+
render(
282+
<Sidebar
283+
{...baseProps}
284+
workspaces={[
285+
{
286+
id: "ws-1",
287+
name: "Alpha Project",
288+
path: "/tmp/alpha",
289+
connected: true,
290+
settings: { sidebarCollapsed: false },
291+
},
292+
]}
293+
groupedWorkspaces={[
294+
{
295+
id: null,
296+
name: "Workspaces",
297+
workspaces: [
298+
{
299+
id: "ws-1",
300+
name: "Alpha Project",
301+
path: "/tmp/alpha",
302+
connected: true,
303+
settings: { sidebarCollapsed: false },
304+
},
305+
],
306+
},
307+
]}
308+
threadsByWorkspace={{
309+
"ws-1": [
310+
{ id: "thread-1", name: "Alpha thread", updatedAt: 1000 },
311+
{ id: "thread-2", name: "Beta thread", updatedAt: 900 },
312+
{ id: "thread-3", name: "Gamma thread", updatedAt: 800 },
313+
{ id: "thread-4", name: "Delta thread", updatedAt: 700 },
314+
],
315+
}}
316+
/>,
317+
);
318+
319+
fireEvent.click(screen.getByRole("button", { name: "Toggle search" }));
320+
fireEvent.change(screen.getByLabelText("Search conversations"), {
321+
target: { value: "delta" },
322+
});
323+
324+
await waitFor(() => {
325+
expect(screen.getByText("Alpha Project")).toBeTruthy();
326+
expect(screen.getByText("Delta thread")).toBeTruthy();
327+
expect(screen.queryByText("Alpha thread")).toBeNull();
328+
expect(screen.queryByText("More...")).toBeNull();
329+
});
330+
});
331+
332+
it("keeps a project visible during search when only older pages may contain matches", async () => {
333+
render(
334+
<Sidebar
335+
{...baseProps}
336+
workspaces={[
337+
{
338+
id: "ws-1",
339+
name: "Alpha Project",
340+
path: "/tmp/alpha",
341+
connected: true,
342+
settings: { sidebarCollapsed: false },
343+
},
344+
]}
345+
groupedWorkspaces={[
346+
{
347+
id: null,
348+
name: "Workspaces",
349+
workspaces: [
350+
{
351+
id: "ws-1",
352+
name: "Alpha Project",
353+
path: "/tmp/alpha",
354+
connected: true,
355+
settings: { sidebarCollapsed: false },
356+
},
357+
],
358+
},
359+
]}
360+
threadsByWorkspace={{
361+
"ws-1": [{ id: "thread-1", name: "Current page thread", updatedAt: 1000 }],
362+
}}
363+
threadListCursorByWorkspace={{ "ws-1": "cursor-1" }}
364+
/>,
365+
);
366+
367+
fireEvent.click(screen.getByRole("button", { name: "Toggle search" }));
368+
fireEvent.change(screen.getByLabelText("Search conversations"), {
369+
target: { value: "historical" },
370+
});
371+
372+
await waitFor(() => {
373+
expect(screen.getByText("Alpha Project")).toBeTruthy();
374+
expect(screen.getByRole("button", { name: "Search older..." })).toBeTruthy();
375+
expect(screen.queryByText("Current page thread")).toBeNull();
376+
});
377+
});
378+
379+
it("keeps the parent project visible when only a worktree thread matches search", async () => {
380+
render(
381+
<Sidebar
382+
{...baseProps}
383+
workspaces={[
384+
{
385+
id: "ws-root",
386+
name: "Main Project",
387+
path: "/tmp/main",
388+
connected: true,
389+
settings: { sidebarCollapsed: false },
390+
},
391+
{
392+
id: "ws-worktree",
393+
name: "Feature Worktree",
394+
path: "/tmp/main-feature",
395+
connected: true,
396+
kind: "worktree",
397+
parentId: "ws-root",
398+
settings: { sidebarCollapsed: false },
399+
},
400+
]}
401+
groupedWorkspaces={[
402+
{
403+
id: null,
404+
name: "Workspaces",
405+
workspaces: [
406+
{
407+
id: "ws-root",
408+
name: "Main Project",
409+
path: "/tmp/main",
410+
connected: true,
411+
settings: { sidebarCollapsed: false },
412+
},
413+
],
414+
},
415+
]}
416+
threadsByWorkspace={{
417+
"ws-worktree": [
418+
{ id: "thread-worktree", name: "Feature thread routing fix", updatedAt: 1000 },
419+
],
420+
}}
421+
/>,
422+
);
423+
424+
fireEvent.click(screen.getByRole("button", { name: "Toggle search" }));
425+
fireEvent.change(screen.getByLabelText("Search conversations"), {
426+
target: { value: "routing fix" },
427+
});
428+
429+
await waitFor(() => {
430+
expect(screen.getByText("Main Project")).toBeTruthy();
431+
expect(screen.getByText("Worktrees")).toBeTruthy();
432+
expect(screen.getByText("Feature Worktree")).toBeTruthy();
433+
expect(screen.getByText("Feature thread routing fix")).toBeTruthy();
434+
});
435+
});
436+
437+
it("keeps clone agents visible when their thread matches search", async () => {
438+
render(
439+
<Sidebar
440+
{...baseProps}
441+
workspaces={[
442+
{
443+
id: "ws-root",
444+
name: "Main Project",
445+
path: "/tmp/main",
446+
connected: true,
447+
settings: { sidebarCollapsed: false },
448+
},
449+
{
450+
id: "ws-clone",
451+
name: "Clone Agent",
452+
path: "/tmp/main-clone",
453+
connected: true,
454+
settings: {
455+
sidebarCollapsed: false,
456+
cloneSourceWorkspaceId: "ws-root",
457+
},
458+
},
459+
]}
460+
groupedWorkspaces={[
461+
{
462+
id: null,
463+
name: "Workspaces",
464+
workspaces: [
465+
{
466+
id: "ws-root",
467+
name: "Main Project",
468+
path: "/tmp/main",
469+
connected: true,
470+
settings: { sidebarCollapsed: false },
471+
},
472+
{
473+
id: "ws-clone",
474+
name: "Clone Agent",
475+
path: "/tmp/main-clone",
476+
connected: true,
477+
settings: {
478+
sidebarCollapsed: false,
479+
cloneSourceWorkspaceId: "ws-root",
480+
},
481+
},
482+
],
483+
},
484+
]}
485+
threadsByWorkspace={{
486+
"ws-clone": [
487+
{ id: "thread-clone", name: "Investigate clone search bug", updatedAt: 1000 },
488+
],
489+
}}
490+
/>,
491+
);
492+
493+
fireEvent.click(screen.getByRole("button", { name: "Toggle search" }));
494+
fireEvent.change(screen.getByLabelText("Search conversations"), {
495+
target: { value: "clone search bug" },
496+
});
497+
498+
await waitFor(() => {
499+
expect(screen.getByText("Main Project")).toBeTruthy();
500+
expect(screen.getByText("Clone agents")).toBeTruthy();
501+
expect(screen.getByText("Clone Agent")).toBeTruthy();
502+
expect(screen.getByText("Investigate clone search bug")).toBeTruthy();
503+
});
504+
});
505+
217506
it("creates a new thread from the all-threads project picker", () => {
218507
const onAddAgent = vi.fn();
219508
render(

0 commit comments

Comments
 (0)