From a89b2321eea83107baa098943740a3efed0a1985 Mon Sep 17 00:00:00 2001 From: LHagfoss Date: Sun, 15 Nov 2026 15:51:16 +0100 Subject: [PATCH] feat(desktop): add a sidebar toolbar with search, sort and filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's view options lived only in ⌘K, and Only this project hid other projects' threads with nothing on screen saying so. A toolbar under Needs you now holds a search field, a Sort and filter menu (Activity, Last updated, Title; the four view options) and New thread. An active project filter shows as a chip with a clear button. The toolbar can be turned off, and how many idle days settle a thread (or never) is a setting. --- apps/desktop/src/state/sidebarPrefs.ts | 49 +++++ apps/desktop/src/state/sidebarRows.ts | 46 ++++- apps/desktop/src/state/sidebarToolbar.test.ts | 71 +++++++ apps/desktop/src/state/thread.ts | 9 +- apps/desktop/src/ui/Settings.tsx | 4 +- apps/desktop/src/ui/Sidebar.tsx | 75 +++++-- apps/desktop/src/ui/SidebarDensitySetting.tsx | 47 ++++- apps/desktop/src/ui/SidebarToolbar.tsx | 183 ++++++++++++++++++ apps/desktop/src/ui/paletteActions.ts | 37 +++- apps/desktop/src/ui/settingsIndex.ts | 11 ++ docs/design.md | 23 ++- 11 files changed, 524 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/state/sidebarToolbar.test.ts create mode 100644 apps/desktop/src/ui/SidebarToolbar.tsx diff --git a/apps/desktop/src/state/sidebarPrefs.ts b/apps/desktop/src/state/sidebarPrefs.ts index 4ce95df5d..954ded23a 100644 --- a/apps/desktop/src/state/sidebarPrefs.ts +++ b/apps/desktop/src/state/sidebarPrefs.ts @@ -11,8 +11,57 @@ export const PREF = { onlyProject: "rustcode.sidebar.onlyProject", folded: "rustcode.sidebar.folded", density: "rustcode.sidebar.density", + toolbar: "rustcode.sidebar.toolbar", + sort: "rustcode.sidebar.sort", + settleAfterDays: "rustcode.sidebar.settleAfterDays", } as const; +/** + * How the Threads list orders the rows nobody arranged: `activity` puts what runs, then what asks + * you, then what came back unseen first (rows move as turns start and end); `recent` is by when + * each thread last changed; `title` is A–Z and never moves on its own. + */ +export type SidebarSort = "activity" | "recent" | "title"; + +export const SIDEBAR_SORTS: readonly { id: SidebarSort; label: string }[] = [ + { id: "activity", label: "Activity" }, + { id: "recent", label: "Last updated" }, + { id: "title", label: "Title" }, +]; + +/** A stored sort, read leniently: anything unknown is the default, `activity`. */ +export function sidebarSort(raw: unknown): SidebarSort { + return raw === "recent" || raw === "title" ? raw : "activity"; +} + +/** The sidebar's sort and its setter, shared by the toolbar's menu and ⌘K. */ +export function useSidebarSort() { + const [raw, set] = usePref(PREF.sort, "activity"); + return [sidebarSort(raw), set as (next: SidebarSort) => void] as const; +} + +/** How many idle days settle a thread on its own; 0 never does. */ +export const SETTLE_AFTER_CHOICES: readonly { days: number; label: string }[] = [ + { days: 0, label: "Never" }, + { days: 1, label: "1 day" }, + { days: 3, label: "3 days" }, + { days: 7, label: "7 days" }, + { days: 14, label: "14 days" }, +]; + +/** The default: three idle days, what the sidebar always did. */ +export const SETTLE_AFTER_DEFAULT_DAYS = 3; + +/** A stored day count, read leniently: anything but one of the choices is the default. */ +export function settleAfterDays(raw: unknown): number { + return SETTLE_AFTER_CHOICES.some((c) => c.days === raw) ? (raw as number) : SETTLE_AFTER_DEFAULT_DAYS; +} + +/** The idle time that settles a thread, in ms; 0 never settles one. */ +export function settleAfterMs(days: number): number { + return days * 86_400_000; +} + /** * How much a thread row says: `detailed` is the three-line row (title, where it runs, the branch), * `compact` one line (status, harness, title, age) with the rest on its hover card. diff --git a/apps/desktop/src/state/sidebarRows.ts b/apps/desktop/src/state/sidebarRows.ts index 4aa524d03..0fc5ffbda 100644 --- a/apps/desktop/src/state/sidebarRows.ts +++ b/apps/desktop/src/state/sidebarRows.ts @@ -469,6 +469,8 @@ export interface OrderKey { pinRank?: number; /** When its snooze ends. */ wakeAt?: number; + /** Its title, for the `title` sort. */ + title?: string; } /** After every real time: a thread with no mark of its own sorts behind the ones that have one. */ @@ -486,13 +488,53 @@ const LAST = Number.MAX_SAFE_INTEGER; * and a row the user arranged still keeps its place over this order * (`state/sidebarOrder.ts` `applyOrder`). */ -export function sortSection(section: SectionId, items: readonly T[], key: (item: T) => OrderKey): T[] { +export function sortSection( + section: SectionId, + items: readonly T[], + key: (item: T) => OrderKey, + sort: "activity" | "recent" | "title" = "activity", +): T[] { if (section === "settled") return [...items]; const keyed = items.map((item) => ({ item, at: key(item) })); - keyed.sort((a, b) => compare(section, a.at, b.at)); + // The chosen sort orders the one Threads list; Pinned, Working and Snoozed keep their own order. + const own = section === "pinned" || section === "working" || section === "snoozed"; + keyed.sort((a, b) => + own || sort === "activity" + ? compare(section, a.at, b.at) + : sort === "recent" + ? b.at.backAt - a.at.backAt + : (a.at.title ?? "").localeCompare(b.at.title ?? "", undefined, { sensitivity: "base" }) || + b.at.backAt - a.at.backAt, + ); return keyed.map((row) => row.item); } +/** What the sidebar's search reads of a thread: its title, project folder and branch. */ +export interface Searchable { + title?: string; + cwd?: string; + worktree?: { branch?: string; repo?: string } | null; +} + +/** + * Whether a thread matches the sidebar's search: every word of the query is in its title, its + * project's folder name or its branch, ignoring case. An empty query matches every thread. + */ +export function threadMatches(thread: Searchable, query: string): boolean { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return true; + const base = (path?: string) => path?.split(/[\\/]/).filter(Boolean).pop() ?? ""; + const hay = [ + thread.title ?? "", + base(thread.cwd), + base(thread.worktree?.repo), + thread.worktree?.branch ?? "", + ] + .join(" ") + .toLowerCase(); + return words.every((word) => hay.includes(word)); +} + function compare(section: SectionId, a: OrderKey, b: OrderKey): number { switch (section) { case "working": diff --git a/apps/desktop/src/state/sidebarToolbar.test.ts b/apps/desktop/src/state/sidebarToolbar.test.ts new file mode 100644 index 000000000..81f2d5a30 --- /dev/null +++ b/apps/desktop/src/state/sidebarToolbar.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { settleAfterDays, settleAfterMs, sidebarSort } from "./sidebarPrefs"; +import { type OrderKey, sortSection, threadMatches } from "./sidebarRows"; +import { idleSettled } from "./thread"; + +const DAY = 86_400_000; + +describe("the sidebar's sort", () => { + const rows: { id: string; key: OrderKey }[] = [ + { id: "old-running", key: { backAt: 1, running: true, title: "zeta" } }, + { id: "new", key: { backAt: 30, title: "Alpha" } }, + { id: "mid-unseen", key: { backAt: 20, unseen: true, title: "beta" } }, + ]; + const order = (sort: "activity" | "recent" | "title", section: "threads" | "pinned" = "threads") => + sortSection(section, rows, (r) => r.key, sort).map((r) => r.id); + + test("activity leads with what runs, then what came back unseen", () => { + expect(order("activity")).toEqual(["old-running", "mid-unseen", "new"]); + }); + + test("last updated ignores what runs: only when each thread last changed", () => { + expect(order("recent")).toEqual(["new", "mid-unseen", "old-running"]); + }); + + test("title is A to Z, ignoring case", () => { + expect(order("title")).toEqual(["new", "mid-unseen", "old-running"]); + }); + + test("a stored sort is read leniently", () => { + expect(sidebarSort("title")).toBe("title"); + expect(sidebarSort("nonsense")).toBe("activity"); + expect(sidebarSort(undefined)).toBe("activity"); + }); +}); + +describe("the sidebar's search", () => { + const thread = { + title: "Fix the flaky CI job", + cwd: "/home/me/code/rustcode", + worktree: { branch: "fix/ci-flake", repo: "/home/me/code/rustcode" }, + }; + + test("every word must be in the title, the project or the branch", () => { + expect(threadMatches(thread, "")).toBe(true); + expect(threadMatches(thread, "flaky")).toBe(true); + expect(threadMatches(thread, "RUSTCODE ci")).toBe(true); + expect(threadMatches(thread, "ci-flake")).toBe(true); + expect(threadMatches(thread, "flaky website")).toBe(false); + }); + + test("only the folder's name counts, not its parents", () => { + expect(threadMatches(thread, "home")).toBe(false); + }); +}); + +describe("settling idle threads", () => { + test("after the chosen days, and never at 0", () => { + const now = 100 * DAY; + expect(idleSettled(now - 2 * DAY, now)).toBe(false); + expect(idleSettled(now - 3 * DAY, now)).toBe(true); + expect(idleSettled(now - 2 * DAY, now, settleAfterMs(1))).toBe(true); + expect(idleSettled(now - 90 * DAY, now, settleAfterMs(0))).toBe(false); + }); + + test("a stored day count is one of the choices, else the default", () => { + expect(settleAfterDays(7)).toBe(7); + expect(settleAfterDays(0)).toBe(0); + expect(settleAfterDays(5)).toBe(3); + expect(settleAfterDays("7")).toBe(3); + }); +}); diff --git a/apps/desktop/src/state/thread.ts b/apps/desktop/src/state/thread.ts index e06c72680..14dc33033 100644 --- a/apps/desktop/src/state/thread.ts +++ b/apps/desktop/src/state/thread.ts @@ -1301,9 +1301,12 @@ export function formatDuration(ms: number): string { /** How long a thread rests untouched before the sidebar settles it on its own. */ export const SETTLE_AFTER_MS = 3 * 86_400_000; -/** Whether a thread last active at `at` has been idle long enough to settle. */ -export function idleSettled(at: number | undefined, now = Date.now()): boolean { - return at !== undefined && now - at >= SETTLE_AFTER_MS; +/** + * Whether a thread last active at `at` has been idle long enough to settle. `afterMs` is the + * user's choice (Settings › Appearance › Settle idle threads); 0 never settles one. + */ +export function idleSettled(at: number | undefined, now = Date.now(), afterMs = SETTLE_AFTER_MS): boolean { + return afterMs > 0 && at !== undefined && now - at >= afterMs; } /** A running turn's clock: `0:42`, `12:03`, `1:02:03`. */ diff --git a/apps/desktop/src/ui/Settings.tsx b/apps/desktop/src/ui/Settings.tsx index 63ba5c8fe..6441e61ea 100644 --- a/apps/desktop/src/ui/Settings.tsx +++ b/apps/desktop/src/ui/Settings.tsx @@ -154,7 +154,7 @@ import { ShortcutsPage } from "./ShortcutSettings"; const StatisticsPage = lazy(() => import("./StatisticsPage").then((m) => ({ default: m.StatisticsPage }))); -import { SidebarDensitySetting } from "./SidebarDensitySetting"; +import { SettleAfterSetting, SidebarDensitySetting, SidebarToolbarSetting } from "./SidebarDensitySetting"; import { type SettingEntry, searchSettings } from "./settingsIndex"; import { Action, @@ -1337,6 +1337,8 @@ function AppearancePage() { + + clearInterval(t); }, []); const [onlyProject] = usePref(PREF.onlyProject, false); + // The toolbar over the list (ui/SidebarToolbar.tsx), the order it chose, and when an idle + // thread settles on its own (Settings › Appearance). + const [toolbarOn] = usePref(PREF.toolbar, true); + const [sort] = useSidebarSort(); + const [settleDays] = usePref(PREF.settleAfterDays, SETTLE_AFTER_DEFAULT_DAYS); + const settleMs = settleAfterMs(settleAfterDays(settleDays)); + // The search narrows the list as it is typed; it is this window's, not a stored preference, and + // goes when the toolbar is hidden. + const [rawQuery, setQuery] = useState(""); + const query = toolbarOn ? rawQuery.trim() : ""; + const searching = query !== ""; // The projects folded under "group by project" (`project:`); the shelves keep their own. const [folded, setFolded] = usePref(PREF.folded, NO_FOLDS); // A section header never folds, so a `working` or `threads` entry an older release stored would @@ -1699,9 +1720,11 @@ export function Sidebar() { const filtered = useMemo( () => sessions.filter( - (s) => !onlyProject || !projectCwd || s.cwd === projectCwd || s.worktree?.repo === projectCwd, + (s) => + (!onlyProject || !projectCwd || s.cwd === projectCwd || s.worktree?.repo === projectCwd) && + threadMatches(s, query), ), - [sessions, onlyProject, projectCwd], + [sessions, onlyProject, projectCwd, query], ); // The threads waiting on work they handed out, though no turn of theirs runs: the Threads list // holds them beside the ones running (state/waiting.ts). @@ -1821,13 +1844,15 @@ export function Sidebar() { // row will be, so it keeps its place when it becomes one. const pendingRow = pending && + !searching && (!onlyProject || !projectCwd || pending.cwd === projectCwd || pending.worktree?.repo === projectCwd) ? pendingSummary(pending) : null; for (const s of filtered) { // A delegated thread has no row of its own, unless the filter left the open one with no row // to hang under (`orphanOpen`). - if (parentOf.has(s.id) && s.id !== orphanOpen) continue; + // A search shows a delegate that matches as a row of its own, saying which thread it is under. + if (parentOf.has(s.id) && s.id !== orphanOpen && !searching) continue; const listed = s.status === "done" || s.status === "review" ? s.status : "idle"; // Settled: threads settled (by the user, or the engine for a merged branch), and // threads untouched for three days unless they wait for review. @@ -1836,7 +1861,7 @@ export function Sidebar() { placeThread({ pinned: pinned.includes(s.id), settled: status === "done", - aged: status !== "review" && idleSettled(s.updatedAt, now), + aged: status !== "review" && idleSettled(s.updatedAt, now, settleMs), snoozed: !!snoozed[s.id], needs: flagged.has(`${s.id}:n`), working: flagged.has(`${s.id}:w`), @@ -1860,12 +1885,13 @@ export function Sidebar() { wakeAt: snoozed[s.id], running: works(s.id), needs: flagged.has(`${s.id}:n`), + title: s.title || "New thread", }); // The order the user arranged comes over the section's own (state/sidebarOrder.ts): a row that // was dragged or ⌥↑/↓'d is drawn where it was put, and the rows nobody arranged keep the order // their section gives them (state/sidebarRows.ts `sortSection`). const arranged = (id: SectionId, items: SessionSummary[], project?: string) => - applyOrder(sortSection(id, items, keyOf), layout[sectionOrderKey(id, project)]); + applyOrder(sortSection(id, items, keyOf, sort), layout[sectionOrderKey(id, project)]); // The threads by project, when the pref is on and there is more than one project. const grouped = sidebarProjects(active); let projects: ProjectGroup[] | undefined; @@ -1916,6 +1942,9 @@ export function Sidebar() { projectCwd, orphanOpen, layout, + sort, + settleMs, + searching, ]); const settledSection = sections.find((g) => g.id === "settled"); @@ -1944,6 +1973,8 @@ export function Sidebar() { // whatever `folded` holds, so an id an older release stored can never shut a list with no control // to open it. const isOpen = (id: string) => { + // A search shows every match, in a shut shelf or folder too. + if (searching) return true; if (isShelf(id, layoutMode)) return shelfIsOpen[id]; if (id.startsWith("project:")) return !folded.includes(id); return true; @@ -1954,7 +1985,7 @@ export function Sidebar() { else if (id === "settled") setSettledStored(!isOpen(id)); else setFolded(folded.includes(id) ? folded.filter((x) => x !== id) : [...folded, id]); }; - const settledLimit = Math.max(settledShown, settledAt + 1); + const settledLimit = searching ? Number.POSITIVE_INFINITY : Math.max(settledShown, settledAt + 1); // Which list each drawn row arranges, and the rows drawn in it: a drag and ⌥↑/↓ both move a row // inside the one list it was drawn in — a section, or one project's run of rows under its heading @@ -2430,6 +2461,16 @@ export function Sidebar() { + {toolbarOn && ( + { + const first = orderIds[0]; + if (first) selectThread(rowIdOf(first)); + }} + /> + )}