From dec2b56ed8a53d3efed6d16f0f9c9a67244949e3 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 17:55:20 +0530 Subject: [PATCH 01/13] feat(tool-sidebar): pure rail registry, overflow split and shortcut predicate Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../lib/__tests__/tool-sidebar-rail.test.ts | 198 ++++++++++++++++++ apps/desktop-ui/src/lib/tool-sidebar-rail.ts | 150 +++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts create mode 100644 apps/desktop-ui/src/lib/tool-sidebar-rail.ts diff --git a/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts b/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts new file mode 100644 index 00000000..a0c7066c --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts @@ -0,0 +1,198 @@ +/** + * Pure logic behind the collapsed tool-sidebar rail. Kept out of the React + * component because this project's Jest runs in the node environment with no + * DOM harness. + */ +import { + DEFAULT_SIDEBAR_WIDTH, + RAIL_MAX_VISIBLE, + clampSidebarWidth, + emptyRailRegistry, + flattenRail, + isSidebarShortcut, + railEntriesKey, + registerRailGroup, + splitRailEntries, + unregisterRailGroup, + type ToolSidebarRailEntry, +} from "../tool-sidebar-rail" + +const entry = (id: string, over: Partial = {}): ToolSidebarRailEntry => ({ + id, + label: id.toUpperCase(), + ...over, +}) + +describe("rail registry", () => { + it("keeps groups in registration order", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "security", [entry("weak")]) + reg = registerRailGroup(reg, "tags", [entry("work")]) + expect(reg.order).toEqual(["security", "tags"]) + }) + + it("re-registering a group replaces its entries without reordering", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "b", [entry("two")]) + reg = registerRailGroup(reg, "a", [entry("three")]) + expect(reg.order).toEqual(["a", "b"]) + expect(reg.groups.a.map((e) => e.id)).toEqual(["three"]) + }) + + it("unregistering drops the group and its slot in the order", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "b", [entry("two")]) + reg = unregisterRailGroup(reg, "a") + expect(reg.order).toEqual(["b"]) + expect(reg.groups.a).toBeUndefined() + }) + + it("unregistering an unknown group returns the same object", () => { + const reg = registerRailGroup(emptyRailRegistry(), "a", [entry("one")]) + expect(unregisterRailGroup(reg, "nope")).toBe(reg) + }) + + it("flattens groups in order and marks where each new group starts", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one"), entry("two")]) + reg = registerRailGroup(reg, "b", [entry("three")]) + const flat = flattenRail(reg) + expect(flat.map((e) => e.label)).toEqual(["ONE", "TWO", "THREE"]) + // The first entry overall does not start a group — nothing precedes it to + // separate from, and a leading separator would draw a stray line. + expect(flat.map((e) => !!e.groupStart)).toEqual([false, false, true]) + }) + + it("skips empty groups so no stray separator appears", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "empty", []) + reg = registerRailGroup(reg, "b", [entry("two")]) + const flat = flattenRail(reg) + expect(flat.map((e) => e.label)).toEqual(["ONE", "TWO"]) + expect(flat.map((e) => !!e.groupStart)).toEqual([false, true]) + }) + + it("namespaces ids by group so two groups can reuse an entry id", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("all")]) + reg = registerRailGroup(reg, "b", [entry("all")]) + const flat = flattenRail(reg) + expect(new Set(flat.map((e) => e.id)).size).toBe(2) + }) +}) + +describe("splitRailEntries", () => { + it("shows everything when the count fits", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE }, (_, i) => entry(`e${i}`)) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible).toHaveLength(RAIL_MAX_VISIBLE) + expect(overflow).toHaveLength(0) + }) + + it("reserves the last slot for the overflow button when it does not fit", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE + 1 }, (_, i) => entry(`e${i}`)) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible).toHaveLength(RAIL_MAX_VISIBLE - 1) + expect(overflow).toHaveLength(2) + expect(overflow.map((e) => e.id)).toEqual([`e${RAIL_MAX_VISIBLE - 1}`, `e${RAIL_MAX_VISIBLE}`]) + }) + + it("keeps an active entry visible by swapping it with the last visible slot", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE + 3 }, (_, i) => + entry(`e${i}`, { active: i === RAIL_MAX_VISIBLE + 2 }), + ) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible.some((e) => e.active)).toBe(true) + expect(overflow.some((e) => e.active)).toBe(false) + }) +}) + +describe("railEntriesKey", () => { + it("is stable across a rebuilt array with equal contents", () => { + const a = [entry("one", { count: 3 }), entry("two")] + const b = [entry("one", { count: 3 }), entry("two")] + expect(railEntriesKey(a)).toBe(railEntriesKey(b)) + }) + + it("changes when a count changes", () => { + expect(railEntriesKey([entry("one", { count: 3 })])).not.toBe( + railEntriesKey([entry("one", { count: 4 })]), + ) + }) + + it("changes when the active entry changes", () => { + expect(railEntriesKey([entry("one", { active: true })])).not.toBe( + railEntriesKey([entry("one")]), + ) + }) + + it("ignores handler identity so a fresh closure does not re-register", () => { + expect(railEntriesKey([entry("one", { onSelect: () => {} })])).toBe( + railEntriesKey([entry("one", { onSelect: () => {} })]), + ) + }) +}) + +describe("isSidebarShortcut", () => { + const ev = (over: Partial[0]> = {}) => ({ + code: "Backslash", + metaKey: true, + ctrlKey: false, + target: null, + ...over, + }) + + it("accepts cmd-backslash", () => { + expect(isSidebarShortcut(ev())).toBe(true) + }) + + it("accepts ctrl-backslash", () => { + expect(isSidebarShortcut(ev({ metaKey: false, ctrlKey: true }))).toBe(true) + }) + + it("rejects backslash with no modifier, so typing a backslash still works", () => { + expect(isSidebarShortcut(ev({ metaKey: false, ctrlKey: false }))).toBe(false) + }) + + it("rejects another key with the modifier held", () => { + expect(isSidebarShortcut(ev({ code: "KeyB" }))).toBe(false) + }) + + it("rejects the shortcut while focus is in a text field", () => { + expect(isSidebarShortcut(ev({ target: { tagName: "INPUT" } }))).toBe(false) + expect(isSidebarShortcut(ev({ target: { tagName: "TEXTAREA" } }))).toBe(false) + expect(isSidebarShortcut(ev({ target: { tagName: "DIV", isContentEditable: true } }))).toBe( + false, + ) + }) + + it("accepts the shortcut when focus is on a plain element", () => { + expect(isSidebarShortcut(ev({ target: { tagName: "DIV" } }))).toBe(true) + }) +}) + +describe("clampSidebarWidth", () => { + it("clamps below the minimum", () => { + expect(clampSidebarWidth(199)).toBe(200) + }) + + it("passes the boundaries through", () => { + expect(clampSidebarWidth(200)).toBe(200) + expect(clampSidebarWidth(480)).toBe(480) + }) + + it("clamps above the maximum", () => { + expect(clampSidebarWidth(481)).toBe(480) + }) + + it("falls back to the default for a non-finite width", () => { + expect(clampSidebarWidth(Number.NaN)).toBe(DEFAULT_SIDEBAR_WIDTH) + }) + + it("rounds fractional drag positions", () => { + expect(clampSidebarWidth(300.6)).toBe(301) + }) +}) diff --git a/apps/desktop-ui/src/lib/tool-sidebar-rail.ts b/apps/desktop-ui/src/lib/tool-sidebar-rail.ts new file mode 100644 index 00000000..f43e9f72 --- /dev/null +++ b/apps/desktop-ui/src/lib/tool-sidebar-rail.ts @@ -0,0 +1,150 @@ +/** + * Pure logic behind the collapsed tool-sidebar rail — registry bookkeeping, + * overflow splitting, the keyboard shortcut predicate and the width clamp. + * + * This lives outside the React component on purpose: Jest here runs in the node + * environment with no DOM harness, so anything branchy has to be testable as a + * plain function. + */ +import type React from "react" + +export const DEFAULT_SIDEBAR_WIDTH = 256 +export const MIN_SIDEBAR_WIDTH = 200 +export const MAX_SIDEBAR_WIDTH = 480 + +/** Rail buttons that fit before the overflow menu takes the last slot. */ +export const RAIL_MAX_VISIBLE = 8 + +export interface ToolSidebarRailEntry { + /** Unique within its group. `flattenRail` namespaces it to `groupId/id`. */ + id: string + /** Tooltip text and accessible name. */ + label: string + icon?: React.ElementType + count?: number + active?: boolean + /** + * Set by `flattenRail` on the first entry of every group but the first, so + * the rail can draw a separator above it. Not set by producers. + */ + groupStart?: boolean + /** + * Invoked on rail click, after the layout expands the panel. Deliberately + * excluded from `railEntriesKey` — see the note there. + */ + onSelect?: () => void +} + +export interface RailRegistry { + /** Group ids in mount order. */ + order: string[] + groups: Record +} + +/** The handler-map key for an entry, shared by the registry and the layout. */ +export function railHandlerKey(groupId: string, entryId: string): string { + return `${groupId}/${entryId}` +} + +export function emptyRailRegistry(): RailRegistry { + return { order: [], groups: {} } +} + +export function registerRailGroup( + reg: RailRegistry, + groupId: string, + entries: ToolSidebarRailEntry[], +): RailRegistry { + return { + order: reg.order.includes(groupId) ? reg.order : [...reg.order, groupId], + groups: { ...reg.groups, [groupId]: entries }, + } +} + +export function unregisterRailGroup(reg: RailRegistry, groupId: string): RailRegistry { + if (!(groupId in reg.groups)) return reg + const groups = { ...reg.groups } + delete groups[groupId] + return { order: reg.order.filter((id) => id !== groupId), groups } +} + +/** + * Render order for the rail. Ids are namespaced to their group so two groups can + * both have an "all" entry, and the first entry of each group after the first is + * marked `groupStart` so the rail can draw a separator above it. Empty groups + * are skipped, so a tool whose facets are still loading leaves no stray line. + */ +export function flattenRail(reg: RailRegistry): ToolSidebarRailEntry[] { + const out: ToolSidebarRailEntry[] = [] + for (const groupId of reg.order) { + const entries = reg.groups[groupId] + if (!entries?.length) continue + entries.forEach((e, i) => { + out.push({ + ...e, + id: railHandlerKey(groupId, e.id), + groupStart: i === 0 && out.length > 0, + }) + }) + } + return out +} + +/** + * Splits entries into the buttons the rail draws and the ones behind the "…" + * menu. When anything overflows, the menu itself consumes a slot, so only + * `max - 1` entries stay visible. An active entry is always kept visible — + * a rail that hides the current selection tells you nothing. + */ +export function splitRailEntries( + entries: ToolSidebarRailEntry[], + max: number = RAIL_MAX_VISIBLE, +): { visible: ToolSidebarRailEntry[]; overflow: ToolSidebarRailEntry[] } { + if (entries.length <= max) return { visible: entries, overflow: [] } + + const visible = entries.slice(0, max - 1) + const overflow = entries.slice(max - 1) + + const activeIndex = overflow.findIndex((e) => e.active) + if (activeIndex !== -1 && visible.length) { + const [active] = overflow.splice(activeIndex, 1) + const displaced = visible[visible.length - 1] + visible[visible.length - 1] = active + overflow.unshift(displaced) + } + + return { visible, overflow } +} + +/** + * Dependency key for the registration effect. Handlers are excluded on purpose: + * a body that rebuilds `onSelect` every render would otherwise re-register in a + * loop. The layout reads live handlers through a ref instead, so excluding them + * here cannot go stale. + */ +export function railEntriesKey(entries: ToolSidebarRailEntry[]): string { + return entries.map((e) => `${e.id}${e.label}${e.count ?? ""}${e.active ? 1 : 0}`).join("") +} + +/** + * True when a keydown should toggle the tool sidebar. Matched on `code` rather + * than `key` because backslash sits behind different physical keys per layout. + */ +export function isSidebarShortcut(e: { + code: string + metaKey: boolean + ctrlKey: boolean + target: { tagName?: string; isContentEditable?: boolean } | null +}): boolean { + if (e.code !== "Backslash") return false + if (!e.metaKey && !e.ctrlKey) return false + const t = e.target + if (!t) return true + if (t.isContentEditable) return false + return t.tagName !== "INPUT" && t.tagName !== "TEXTAREA" && t.tagName !== "SELECT" +} + +export function clampSidebarWidth(px: number): number { + if (!Number.isFinite(px)) return DEFAULT_SIDEBAR_WIDTH + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, Math.round(px))) +} From d7f6b707ff339984d2e6340a6808d9147c2d53eb Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 17:56:16 +0530 Subject: [PATCH 02/13] feat(tool-sidebar): persist panel width per tool Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../__tests__/tool-sidebar-store.test.ts | 34 ++++++++++++++++++- .../src/store/tool-sidebar-store.ts | 20 +++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts b/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts index f4314ea2..c1631762 100644 --- a/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts +++ b/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts @@ -4,7 +4,7 @@ import { useToolSidebarStore } from "../tool-sidebar-store" beforeEach(() => { - useToolSidebarStore.setState({ collapsed: {} }) + useToolSidebarStore.setState({ collapsed: {}, width: {} }) }) describe("tool-sidebar-store", () => { @@ -33,4 +33,36 @@ describe("tool-sidebar-store", () => { setCollapsed("notes", true) expect(useToolSidebarStore.getState().collapsed["s3-drive"]).toBeUndefined() }) + + it("has no stored width until one is set", () => { + expect(useToolSidebarStore.getState().width["notes"]).toBeUndefined() + }) + + it("stores a width per tool", () => { + useToolSidebarStore.getState().setWidth("notes", 320) + expect(useToolSidebarStore.getState().width["notes"]).toBe(320) + expect(useToolSidebarStore.getState().width["bookmarks"]).toBeUndefined() + }) + + it("clamps a stored width to the allowed range", () => { + const { setWidth } = useToolSidebarStore.getState() + setWidth("notes", 50) + expect(useToolSidebarStore.getState().width["notes"]).toBe(200) + setWidth("notes", 5000) + expect(useToolSidebarStore.getState().width["notes"]).toBe(480) + }) + + it("resetWidth clears the key so the default applies again", () => { + useToolSidebarStore.getState().setWidth("notes", 320) + useToolSidebarStore.getState().resetWidth("notes") + expect(useToolSidebarStore.getState().width["notes"]).toBeUndefined() + }) + + it("width and collapse are independent", () => { + const { setWidth, setCollapsed } = useToolSidebarStore.getState() + setWidth("notes", 320) + setCollapsed("notes", true) + expect(useToolSidebarStore.getState().width["notes"]).toBe(320) + expect(useToolSidebarStore.getState().collapsed["notes"]).toBe(true) + }) }) diff --git a/apps/desktop-ui/src/store/tool-sidebar-store.ts b/apps/desktop-ui/src/store/tool-sidebar-store.ts index 5694945c..b2e7f0df 100644 --- a/apps/desktop-ui/src/store/tool-sidebar-store.ts +++ b/apps/desktop-ui/src/store/tool-sidebar-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { clampSidebarWidth } from '@/lib/tool-sidebar-rail' interface ToolSidebarState { /** @@ -7,19 +8,38 @@ interface ToolSidebarState { * default — so a tool that has never been collapsed stores nothing. */ collapsed: Record + /** + * Panel width in px keyed by tool id. A missing key means the 256px default, + * so a tool the user never dragged stores nothing. + */ + width: Record setCollapsed: (toolId: string, collapsed: boolean) => void toggle: (toolId: string) => void + setWidth: (toolId: string, px: number) => void + resetWidth: (toolId: string) => void } export const useToolSidebarStore = create()( persist( (set) => ({ collapsed: {}, + width: {}, setCollapsed: (toolId, collapsed) => set((s) => ({ collapsed: { ...s.collapsed, [toolId]: collapsed } })), toggle: (toolId) => set((s) => ({ collapsed: { ...s.collapsed, [toolId]: !s.collapsed[toolId] } })), + setWidth: (toolId, px) => + set((s) => ({ width: { ...s.width, [toolId]: clampSidebarWidth(px) } })), + resetWidth: (toolId) => + set((s) => { + const width = { ...s.width } + delete width[toolId] + return { width } + }), }), + // No version bump needed: `persist` shallow-merges the stored object over + // the initializer's, and payloads written before `width` existed simply have + // no such key, so the `{}` default survives. { name: 'tool-sidebar-storage' }, ), ) From 3d001392f1a7a189e0f9cea766e77ddcc6306aab Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 17:57:27 +0530 Subject: [PATCH 03/13] i18n(tool-sidebar): rail expand/collapse/resize keys in 27 locales Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- apps/desktop-ui/messages/af.json | 6 +++ apps/desktop-ui/messages/ar.json | 6 +++ apps/desktop-ui/messages/ca.json | 6 +++ apps/desktop-ui/messages/cs.json | 6 +++ apps/desktop-ui/messages/da.json | 6 +++ apps/desktop-ui/messages/de.json | 6 +++ apps/desktop-ui/messages/el.json | 6 +++ apps/desktop-ui/messages/en.json | 6 +++ apps/desktop-ui/messages/es.json | 6 +++ apps/desktop-ui/messages/fa.json | 6 +++ apps/desktop-ui/messages/fr.json | 6 +++ apps/desktop-ui/messages/id.json | 6 +++ apps/desktop-ui/messages/it.json | 6 +++ apps/desktop-ui/messages/ja.json | 6 +++ apps/desktop-ui/messages/ko.json | 6 +++ apps/desktop-ui/messages/ms.json | 6 +++ apps/desktop-ui/messages/nb.json | 6 +++ apps/desktop-ui/messages/nl.json | 6 +++ apps/desktop-ui/messages/pl.json | 6 +++ apps/desktop-ui/messages/pt-BR.json | 6 +++ apps/desktop-ui/messages/pt.json | 6 +++ apps/desktop-ui/messages/ru.json | 6 +++ apps/desktop-ui/messages/sv.json | 6 +++ apps/desktop-ui/messages/tr.json | 6 +++ apps/desktop-ui/messages/uk.json | 6 +++ apps/desktop-ui/messages/vi.json | 6 +++ apps/desktop-ui/messages/zh.json | 6 +++ .../scripts/add-tool-sidebar-i18n.mjs | 48 +++++++++++++++++++ 28 files changed, 210 insertions(+) create mode 100644 apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index cfdd5d36..aa61f875 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -5223,6 +5223,12 @@ "offline": "100% aflyn" }, "ToolSidebar": { + "expand": "Vou sybalk uit", + "collapse": "Vou sybalk in", + "resize": "Verstel sybalk se grootte", + "resetWidth": "Herstel sybalk se breedte", + "moreSections": "Meer afdelings", + "sections": "Sybalk-afdelings", "show": "Wys sybalk", "hide": "Versteek sybalk" }, diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 94522005..4e1ac6d2 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -5223,6 +5223,12 @@ "offline": "100% دون اتصال" }, "ToolSidebar": { + "expand": "توسيع الشريط الجانبي", + "collapse": "طي الشريط الجانبي", + "resize": "تغيير حجم الشريط الجانبي", + "resetWidth": "إعادة تعيين عرض الشريط الجانبي", + "moreSections": "المزيد من الأقسام", + "sections": "أقسام الشريط الجانبي", "show": "إظهار الشريط الجانبي", "hide": "إخفاء الشريط الجانبي" }, diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 5b980ce2..0bbbc2fb 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -5223,6 +5223,12 @@ "offline": "100% fora de línia" }, "ToolSidebar": { + "expand": "Amplia la barra lateral", + "collapse": "Redueix la barra lateral", + "resize": "Redimensiona la barra lateral", + "resetWidth": "Restableix l'amplada de la barra lateral", + "moreSections": "Més seccions", + "sections": "Seccions de la barra lateral", "show": "Mostra la barra lateral", "hide": "Amaga la barra lateral" }, diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index d2940606..ae84c5af 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Rozbalit postranní panel", + "collapse": "Sbalit postranní panel", + "resize": "Změnit šířku panelu", + "resetWidth": "Obnovit šířku panelu", + "moreSections": "Další sekce", + "sections": "Sekce postranního panelu", "show": "Zobrazit postranní panel", "hide": "Skrýt postranní panel" }, diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c92161b5..c348e15d 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Udvid sidepanel", + "collapse": "Skjul sidepanel", + "resize": "Tilpas sidepanelets bredde", + "resetWidth": "Nulstil sidepanelets bredde", + "moreSections": "Flere sektioner", + "sections": "Sidepanelsektioner", "show": "Vis sidepanel", "hide": "Skjul sidepanel" }, diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index ea508a90..b3af3829 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Seitenleiste ausklappen", + "collapse": "Seitenleiste einklappen", + "resize": "Seitenleiste anpassen", + "resetWidth": "Breite zurücksetzen", + "moreSections": "Weitere Abschnitte", + "sections": "Abschnitte der Seitenleiste", "show": "Seitenleiste einblenden", "hide": "Seitenleiste ausblenden" }, diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index a22c17ec..d441276c 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -5223,6 +5223,12 @@ "offline": "100% εκτός σύνδεσης" }, "ToolSidebar": { + "expand": "Ανάπτυξη πλαϊνής στήλης", + "collapse": "Σύμπτυξη πλαϊνής στήλης", + "resize": "Αλλαγή πλάτους", + "resetWidth": "Επαναφορά πλάτους", + "moreSections": "Περισσότερες ενότητες", + "sections": "Ενότητες πλαϊνής στήλης", "show": "Εμφάνιση πλαϊνής μπάρας", "hide": "Απόκρυψη πλαϊνής μπάρας" }, diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index f1f97a6d..a430419d 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -5310,6 +5310,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expand sidebar", + "collapse": "Collapse sidebar", + "resize": "Resize sidebar", + "resetWidth": "Reset sidebar width", + "moreSections": "More sections", + "sections": "Sidebar sections", "show": "Show sidebar", "hide": "Hide sidebar" }, diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index 194b0446..b5bd3aa9 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -5223,6 +5223,12 @@ "offline": "100% sin conexión" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Contraer barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Restablecer ancho", + "moreSections": "Más secciones", + "sections": "Secciones de la barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index e01d8972..10b2a386 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -5223,6 +5223,12 @@ "offline": "۱۰۰٪ آفلاین" }, "ToolSidebar": { + "expand": "گسترش نوار کناری", + "collapse": "جمع کردن نوار کناری", + "resize": "تغییر اندازه نوار کناری", + "resetWidth": "بازنشانی عرض نوار کناری", + "moreSections": "بخش‌های بیشتر", + "sections": "بخش‌های نوار کناری", "show": "نمایش نوار کناری", "hide": "پنهان کردن نوار کناری" }, diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 41af1a02..e4420437 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -5223,6 +5223,12 @@ "offline": "100 % hors ligne" }, "ToolSidebar": { + "expand": "Développer la barre latérale", + "collapse": "Réduire la barre latérale", + "resize": "Redimensionner la barre latérale", + "resetWidth": "Réinitialiser la largeur", + "moreSections": "Plus de sections", + "sections": "Sections de la barre latérale", "show": "Afficher la barre latérale", "hide": "Masquer la barre latérale" }, diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 13ca9cdd..ba78f61d 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Bentangkan bilah sisi", + "collapse": "Ciutkan bilah sisi", + "resize": "Ubah ukuran bilah sisi", + "resetWidth": "Atur ulang lebar bilah sisi", + "moreSections": "Bagian lainnya", + "sections": "Bagian bilah sisi", "show": "Tampilkan bilah sisi", "hide": "Sembunyikan bilah sisi" }, diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 21110000..5cf8185b 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Espandi barra laterale", + "collapse": "Comprimi barra laterale", + "resize": "Ridimensiona barra laterale", + "resetWidth": "Reimposta larghezza", + "moreSections": "Altre sezioni", + "sections": "Sezioni della barra laterale", "show": "Mostra barra laterale", "hide": "Nascondi barra laterale" }, diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 378cb30a..2fe81b64 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -5223,6 +5223,12 @@ "offline": "完全オフライン" }, "ToolSidebar": { + "expand": "サイドバーを展開", + "collapse": "サイドバーを折りたたむ", + "resize": "サイドバーの幅を変更", + "resetWidth": "サイドバーの幅をリセット", + "moreSections": "その他のセクション", + "sections": "サイドバーのセクション", "show": "サイドバーを表示", "hide": "サイドバーを非表示" }, diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 5e87a02c..69e705ba 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -5223,6 +5223,12 @@ "offline": "100% 오프라인" }, "ToolSidebar": { + "expand": "사이드바 펼치기", + "collapse": "사이드바 접기", + "resize": "사이드바 너비 조절", + "resetWidth": "사이드바 너비 초기화", + "moreSections": "섹션 더 보기", + "sections": "사이드바 섹션", "show": "사이드바 표시", "hide": "사이드바 숨기기" }, diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index de039994..86646f8c 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -5223,6 +5223,12 @@ "offline": "100% luar talian" }, "ToolSidebar": { + "expand": "Kembangkan bar sisi", + "collapse": "Runtuhkan bar sisi", + "resize": "Ubah saiz bar sisi", + "resetWidth": "Tetapkan semula lebar bar sisi", + "moreSections": "Lagi bahagian", + "sections": "Bahagian bar sisi", "show": "Tunjukkan bar sisi", "hide": "Sembunyikan bar sisi" }, diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index ded6f006..a958af60 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Utvid sidepanel", + "collapse": "Skjul sidepanel", + "resize": "Endre bredde på sidepanel", + "resetWidth": "Tilbakestill bredde", + "moreSections": "Flere seksjoner", + "sections": "Sidepanelseksjoner", "show": "Vis sidepanel", "hide": "Skjul sidepanel" }, diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 25c096d0..b93207a8 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Zijbalk uitklappen", + "collapse": "Zijbalk inklappen", + "resize": "Zijbalkbreedte aanpassen", + "resetWidth": "Breedte herstellen", + "moreSections": "Meer secties", + "sections": "Zijbalksecties", "show": "Zijbalk tonen", "hide": "Zijbalk verbergen" }, diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 86cc6ab4..bdec4d87 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Rozwiń panel boczny", + "collapse": "Zwiń panel boczny", + "resize": "Zmień szerokość panelu", + "resetWidth": "Przywróć szerokość panelu", + "moreSections": "Więcej sekcji", + "sections": "Sekcje panelu bocznego", "show": "Pokaż pasek boczny", "hide": "Ukryj pasek boczny" }, diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index c9957d75..d0afff29 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Recolher barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Redefinir largura", + "moreSections": "Mais seções", + "sections": "Seções da barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 64be03dd..58a83485 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Recolher barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Repor largura", + "moreSections": "Mais secções", + "sections": "Secções da barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index f87079b6..d7459120 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -5223,6 +5223,12 @@ "offline": "100% офлайн" }, "ToolSidebar": { + "expand": "Развернуть боковую панель", + "collapse": "Свернуть боковую панель", + "resize": "Изменить ширину панели", + "resetWidth": "Сбросить ширину панели", + "moreSections": "Ещё разделы", + "sections": "Разделы боковой панели", "show": "Показать боковую панель", "hide": "Скрыть боковую панель" }, diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index fa8bc36a..1753ab49 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Expandera sidopanelen", + "collapse": "Fäll ihop sidopanelen", + "resize": "Ändra sidopanelens bredd", + "resetWidth": "Återställ bredden", + "moreSections": "Fler sektioner", + "sections": "Sidopanelens sektioner", "show": "Visa sidopanel", "hide": "Dölj sidopanel" }, diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 858d5948..a4c0333b 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -5223,6 +5223,12 @@ "offline": "%100 çevrimdışı" }, "ToolSidebar": { + "expand": "Kenar çubuğunu genişlet", + "collapse": "Kenar çubuğunu daralt", + "resize": "Kenar çubuğunu yeniden boyutlandır", + "resetWidth": "Genişliği sıfırla", + "moreSections": "Daha fazla bölüm", + "sections": "Kenar çubuğu bölümleri", "show": "Kenar çubuğunu göster", "hide": "Kenar çubuğunu gizle" }, diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index e6ae9299..2820a96c 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -5223,6 +5223,12 @@ "offline": "100% офлайн" }, "ToolSidebar": { + "expand": "Розгорнути бічну панель", + "collapse": "Згорнути бічну панель", + "resize": "Змінити ширину панелі", + "resetWidth": "Скинути ширину панелі", + "moreSections": "Більше розділів", + "sections": "Розділи бічної панелі", "show": "Показати бічну панель", "hide": "Сховати бічну панель" }, diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index e2b80a14..344f364c 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -5223,6 +5223,12 @@ "offline": "100% ngoại tuyến" }, "ToolSidebar": { + "expand": "Mở rộng thanh bên", + "collapse": "Thu gọn thanh bên", + "resize": "Thay đổi kích thước thanh bên", + "resetWidth": "Đặt lại chiều rộng thanh bên", + "moreSections": "Thêm mục", + "sections": "Các mục thanh bên", "show": "Hiện thanh bên", "hide": "Ẩn thanh bên" }, diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 333bb79d..461e2643 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -5223,6 +5223,12 @@ "offline": "100% 离线" }, "ToolSidebar": { + "expand": "展开侧边栏", + "collapse": "折叠侧边栏", + "resize": "调整侧边栏宽度", + "resetWidth": "重置侧边栏宽度", + "moreSections": "更多分区", + "sections": "侧边栏分区", "show": "显示侧边栏", "hide": "隐藏侧边栏" }, diff --git a/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs b/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs new file mode 100644 index 00000000..24ed9cf2 --- /dev/null +++ b/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs @@ -0,0 +1,48 @@ +// Adds the collapse-to-icon rail keys to the ToolSidebar namespace in every +// locale. Idempotent: existing keys are left untouched. +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const DIR = new URL('../messages/', import.meta.url).pathname + +const T = { + en: { expand: 'Expand sidebar', collapse: 'Collapse sidebar', resize: 'Resize sidebar', resetWidth: 'Reset sidebar width', moreSections: 'More sections', sections: 'Sidebar sections' }, + af: { expand: 'Vou sybalk uit', collapse: 'Vou sybalk in', resize: 'Verstel sybalk se grootte', resetWidth: 'Herstel sybalk se breedte', moreSections: 'Meer afdelings', sections: 'Sybalk-afdelings' }, + ar: { expand: 'توسيع الشريط الجانبي', collapse: 'طي الشريط الجانبي', resize: 'تغيير حجم الشريط الجانبي', resetWidth: 'إعادة تعيين عرض الشريط الجانبي', moreSections: 'المزيد من الأقسام', sections: 'أقسام الشريط الجانبي' }, + ca: { expand: 'Amplia la barra lateral', collapse: 'Redueix la barra lateral', resize: 'Redimensiona la barra lateral', resetWidth: "Restableix l'amplada de la barra lateral", moreSections: 'Més seccions', sections: 'Seccions de la barra lateral' }, + cs: { expand: 'Rozbalit postranní panel', collapse: 'Sbalit postranní panel', resize: 'Změnit šířku panelu', resetWidth: 'Obnovit šířku panelu', moreSections: 'Další sekce', sections: 'Sekce postranního panelu' }, + da: { expand: 'Udvid sidepanel', collapse: 'Skjul sidepanel', resize: 'Tilpas sidepanelets bredde', resetWidth: 'Nulstil sidepanelets bredde', moreSections: 'Flere sektioner', sections: 'Sidepanelsektioner' }, + de: { expand: 'Seitenleiste ausklappen', collapse: 'Seitenleiste einklappen', resize: 'Seitenleiste anpassen', resetWidth: 'Breite zurücksetzen', moreSections: 'Weitere Abschnitte', sections: 'Abschnitte der Seitenleiste' }, + el: { expand: 'Ανάπτυξη πλαϊνής στήλης', collapse: 'Σύμπτυξη πλαϊνής στήλης', resize: 'Αλλαγή πλάτους', resetWidth: 'Επαναφορά πλάτους', moreSections: 'Περισσότερες ενότητες', sections: 'Ενότητες πλαϊνής στήλης' }, + es: { expand: 'Expandir barra lateral', collapse: 'Contraer barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Restablecer ancho', moreSections: 'Más secciones', sections: 'Secciones de la barra lateral' }, + fa: { expand: 'گسترش نوار کناری', collapse: 'جمع کردن نوار کناری', resize: 'تغییر اندازه نوار کناری', resetWidth: 'بازنشانی عرض نوار کناری', moreSections: 'بخش‌های بیشتر', sections: 'بخش‌های نوار کناری' }, + fr: { expand: 'Développer la barre latérale', collapse: 'Réduire la barre latérale', resize: 'Redimensionner la barre latérale', resetWidth: 'Réinitialiser la largeur', moreSections: 'Plus de sections', sections: 'Sections de la barre latérale' }, + id: { expand: 'Bentangkan bilah sisi', collapse: 'Ciutkan bilah sisi', resize: 'Ubah ukuran bilah sisi', resetWidth: 'Atur ulang lebar bilah sisi', moreSections: 'Bagian lainnya', sections: 'Bagian bilah sisi' }, + it: { expand: 'Espandi barra laterale', collapse: 'Comprimi barra laterale', resize: 'Ridimensiona barra laterale', resetWidth: 'Reimposta larghezza', moreSections: 'Altre sezioni', sections: 'Sezioni della barra laterale' }, + ja: { expand: 'サイドバーを展開', collapse: 'サイドバーを折りたたむ', resize: 'サイドバーの幅を変更', resetWidth: 'サイドバーの幅をリセット', moreSections: 'その他のセクション', sections: 'サイドバーのセクション' }, + ko: { expand: '사이드바 펼치기', collapse: '사이드바 접기', resize: '사이드바 너비 조절', resetWidth: '사이드바 너비 초기화', moreSections: '섹션 더 보기', sections: '사이드바 섹션' }, + ms: { expand: 'Kembangkan bar sisi', collapse: 'Runtuhkan bar sisi', resize: 'Ubah saiz bar sisi', resetWidth: 'Tetapkan semula lebar bar sisi', moreSections: 'Lagi bahagian', sections: 'Bahagian bar sisi' }, + nb: { expand: 'Utvid sidepanel', collapse: 'Skjul sidepanel', resize: 'Endre bredde på sidepanel', resetWidth: 'Tilbakestill bredde', moreSections: 'Flere seksjoner', sections: 'Sidepanelseksjoner' }, + nl: { expand: 'Zijbalk uitklappen', collapse: 'Zijbalk inklappen', resize: 'Zijbalkbreedte aanpassen', resetWidth: 'Breedte herstellen', moreSections: 'Meer secties', sections: 'Zijbalksecties' }, + pl: { expand: 'Rozwiń panel boczny', collapse: 'Zwiń panel boczny', resize: 'Zmień szerokość panelu', resetWidth: 'Przywróć szerokość panelu', moreSections: 'Więcej sekcji', sections: 'Sekcje panelu bocznego' }, + 'pt-BR': { expand: 'Expandir barra lateral', collapse: 'Recolher barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Redefinir largura', moreSections: 'Mais seções', sections: 'Seções da barra lateral' }, + pt: { expand: 'Expandir barra lateral', collapse: 'Recolher barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Repor largura', moreSections: 'Mais secções', sections: 'Secções da barra lateral' }, + ru: { expand: 'Развернуть боковую панель', collapse: 'Свернуть боковую панель', resize: 'Изменить ширину панели', resetWidth: 'Сбросить ширину панели', moreSections: 'Ещё разделы', sections: 'Разделы боковой панели' }, + sv: { expand: 'Expandera sidopanelen', collapse: 'Fäll ihop sidopanelen', resize: 'Ändra sidopanelens bredd', resetWidth: 'Återställ bredden', moreSections: 'Fler sektioner', sections: 'Sidopanelens sektioner' }, + tr: { expand: 'Kenar çubuğunu genişlet', collapse: 'Kenar çubuğunu daralt', resize: 'Kenar çubuğunu yeniden boyutlandır', resetWidth: 'Genişliği sıfırla', moreSections: 'Daha fazla bölüm', sections: 'Kenar çubuğu bölümleri' }, + uk: { expand: 'Розгорнути бічну панель', collapse: 'Згорнути бічну панель', resize: 'Змінити ширину панелі', resetWidth: 'Скинути ширину панелі', moreSections: 'Більше розділів', sections: 'Розділи бічної панелі' }, + vi: { expand: 'Mở rộng thanh bên', collapse: 'Thu gọn thanh bên', resize: 'Thay đổi kích thước thanh bên', resetWidth: 'Đặt lại chiều rộng thanh bên', moreSections: 'Thêm mục', sections: 'Các mục thanh bên' }, + zh: { expand: '展开侧边栏', collapse: '折叠侧边栏', resize: '调整侧边栏宽度', resetWidth: '重置侧边栏宽度', moreSections: '更多分区', sections: '侧边栏分区' }, +} + +let changed = 0 +for (const file of readdirSync(DIR).filter((f) => f.endsWith('.json'))) { + const locale = file.replace(/\.json$/, '') + const add = T[locale] ?? T.en + const path = join(DIR, file) + const json = JSON.parse(readFileSync(path, 'utf8')) + json.ToolSidebar = { ...add, ...(json.ToolSidebar ?? {}) } + writeFileSync(path, JSON.stringify(json, null, 2) + '\n') + changed++ +} +console.log(`updated ${changed} locale files`) From e17fd867fe57793c2dd10cc64f31382935ffbf78 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 17:58:59 +0530 Subject: [PATCH 04/13] feat(tool-sidebar): 48px icon rail with facets, overflow and roving tabindex Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../__tests__/tool-sidebar-rail.memo.test.tsx | 42 +++ .../components/tools/tool-sidebar-rail.tsx | 296 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx create mode 100644 apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx diff --git a/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx b/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx new file mode 100644 index 00000000..75d75300 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx @@ -0,0 +1,42 @@ +/** + * Metadata-level test: this project's Jest runs in the node environment with no + * DOM harness, so we assert the memo contract rather than rendered output — + * same approach as api-client/__tests__/collection-item.memo.test.tsx. + */ +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) + +describe("RailButton memo", () => { + it("is wrapped in React.memo with a custom comparator", () => { + const { RailButton } = require("../tool-sidebar-rail") + expect(RailButton.$$typeof).toBe(Symbol.for("react.memo")) + expect(RailButton.compare).toBeInstanceOf(Function) + }) + + it("skips re-render when the displayed props are unchanged", () => { + const { RailButton } = require("../tool-sidebar-rail") + const compare = RailButton.compare as (a: never, b: never) => boolean + const base = { + entry: { id: "tags", label: "Tags", count: 3, active: false }, + accent: { bg: "bg-blue-500/10", text: "text-blue-500" }, + onActivate: () => {}, + } + const same = { ...base, entry: { ...base.entry }, onActivate: () => {} } + expect(compare(base as never, same as never)).toBe(true) + }) + + it("re-renders when active or count changes", () => { + const { RailButton } = require("../tool-sidebar-rail") + const compare = RailButton.compare as (a: never, b: never) => boolean + const base = { + entry: { id: "tags", label: "Tags", count: 3, active: false }, + accent: { bg: "bg-blue-500/10", text: "text-blue-500" }, + onActivate: () => {}, + } + expect( + compare(base as never, { ...base, entry: { ...base.entry, active: true } } as never), + ).toBe(false) + expect(compare(base as never, { ...base, entry: { ...base.entry, count: 4 } } as never)).toBe( + false, + ) + }) +}) diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx new file mode 100644 index 00000000..487ef00c --- /dev/null +++ b/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx @@ -0,0 +1,296 @@ +'use client' + +import * as React from 'react' +import { MoreHorizontal, PanelLeft } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + RAIL_MAX_VISIBLE, + splitRailEntries, + type ToolSidebarRailEntry, +} from '@/lib/tool-sidebar-rail' +import { cn } from '@/lib/utils' + +/** + * The collapsed sidebar: a 48px column that keeps the tool's identity, its + * primary action and its facets reachable without expanding. Buttons are 40x40 + * inside 4px gutters, which clears the 44px pointer target. + * + * The rail owns no state. Entries arrive already flattened, and every click is + * routed back through `onActivate` so the layout can expand the panel first. + */ + +interface Accent { + bg: string + text: string +} + +interface RailButtonProps { + entry: ToolSidebarRailEntry + accent: Accent + onActivate: (entry: ToolSidebarRailEntry) => void +} + +function RailButtonImpl({ entry, accent, onActivate }: RailButtonProps) { + const Icon = entry.icon + return ( + + + + + + {entry.count !== undefined ? `${entry.label} (${entry.count})` : entry.label} + + + ) +} + +/** + * Memoized on displayed data only. `onActivate` is compared by identity but the + * layout passes a `useCallback`-stable handler, and entry handlers are resolved + * through a ref at click time, so a changing closure never forces a re-render. + */ +export const RailButton = React.memo(RailButtonImpl, (a, b) => { + return ( + a.entry.id === b.entry.id && + a.entry.label === b.entry.label && + a.entry.count === b.entry.count && + a.entry.active === b.entry.active && + a.entry.icon === b.entry.icon && + a.entry.groupStart === b.entry.groupStart && + a.accent.bg === b.accent.bg && + a.accent.text === b.accent.text + ) +}) +RailButton.displayName = 'RailButton' + +export interface ToolSidebarRailProps { + /** Tool identity chip at the top. Clicking it expands the panel. */ + icon: React.ElementType + title: string + accent: Accent + entries: ToolSidebarRailEntry[] + primaryAction?: { icon: React.ElementType; label: string; onClick: () => void } + /** Expand the panel (rail bottom button, tool chip, and before any entry). */ + onExpand: () => void + onActivate: (entry: ToolSidebarRailEntry) => void + /** Wired by the layout so `aria-controls` points at the panel. */ + panelId: string + /** So the layout can return focus here when the panel collapses. */ + expandRef?: React.RefObject +} + +export function ToolSidebarRail({ + icon: Icon, + title, + accent, + entries, + primaryAction, + onExpand, + onActivate, + panelId, + expandRef, +}: ToolSidebarRailProps) { + const t = useTranslations('ToolSidebar') + const listRef = React.useRef(null) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + + // Roving tabindex: the container is the single tab stop, arrows move focus + // between buttons. Without this a tool with eight facets adds eight stops to + // every Tab sweep of the app. + const focusAt = React.useCallback((delta: number | 'first' | 'last') => { + const nodes = Array.from( + listRef.current?.querySelectorAll('button[data-rail-entry]') ?? [], + ) + if (!nodes.length) return + const current = nodes.findIndex((n) => n === document.activeElement) + let next: number + if (delta === 'first') next = 0 + else if (delta === 'last') next = nodes.length - 1 + else next = current === -1 ? 0 : (current + delta + nodes.length) % nodes.length + nodes[next]?.focus() + }, []) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + focusAt(1) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + focusAt(-1) + } else if (e.key === 'Home') { + e.preventDefault() + focusAt('first') + } else if (e.key === 'End') { + e.preventDefault() + focusAt('last') + } + }, + [focusAt], + ) + + return ( + +
+ + + + + + {title} + + + + {primaryAction && ( + + + + + + {primaryAction.label} + + + )} + + {(visible.length > 0 || overflow.length > 0) && ( +
+ )} + +
+ {visible.map((entry) => ( + + {entry.groupStart && ( +
+ )} + + + ))} + + {overflow.length > 0 && ( + + + + + + {overflow.map((entry) => ( + onActivate(entry)}> + {entry.icon && } + {entry.label} + {entry.count !== undefined && ( + + {entry.count} + + )} + + ))} + + + )} +
+ + + + + + + {t('expand')} + + +
+ + ) +} From beff5c10b55b2436102dacefbf9cf6a380f5926c Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 18:02:13 +0530 Subject: [PATCH 05/13] feat(tool-sidebar): keep the panel mounted while collapsed and render the icon rail Collapsing used to unmount the panel, which discarded the body's search text, expanded tree groups and scroll position every time. It also made a rail impossible: entries are published by hooks inside the body, and an unmounted body cannot publish. Hide with display:none instead, mirroring how TabContent keeps inactive tool tabs alive. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../src/components/tools/tool-sidebar.tsx | 259 +++++++++++++----- 1 file changed, 194 insertions(+), 65 deletions(-) diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx index cb7d6e35..a7ccbf8a 100644 --- a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx +++ b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx @@ -13,8 +13,22 @@ import { useIsMobile } from '@/components/hooks/use-mobile' import { useToolSidebarStore } from '@/store/tool-sidebar-store' import { toolCategoryMap } from '@/lib/tool-categories' import { categoryAccent } from '@/components/dashboard/types' +import { ToolSidebarRail } from '@/components/tools/tool-sidebar-rail' +import { + DEFAULT_SIDEBAR_WIDTH, + emptyRailRegistry, + flattenRail, + railEntriesKey, + railHandlerKey, + registerRailGroup, + unregisterRailGroup, + type RailRegistry, + type ToolSidebarRailEntry, +} from '@/lib/tool-sidebar-rail' import { cn } from '@/lib/utils' +export type { ToolSidebarRailEntry } from '@/lib/tool-sidebar-rail' + /** * The one in-tool left panel. Every app-like tool (notes, bookmarks, api-client, * data-explorer, s3-drive, snippet-manager, password-manager, api-keys, @@ -33,6 +47,12 @@ interface ToolSidebarContextValue { * left hidden behind the panel. */ isOverlay: boolean + /** + * False while the panel is collapsed or the mobile sheet is shut. The body + * stays mounted in that state — gate anything expensive that assumes the user + * can see the panel on this rather than on mount. + */ + isVisible: boolean /** * Dismiss the panel. No-op on desktop when the panel is pinned open — call it * from list rows so picking an item on mobile closes the sheet instead of @@ -60,6 +80,50 @@ export function ToolSidebarActions({ children }: { children: React.ReactNode }) return slot ? createPortal(children, slot) : null } +interface RailRegistrationValue { + register: (groupId: string, entries: ToolSidebarRailEntry[]) => void + unregister: (groupId: string) => void + /** + * Live handlers keyed `${groupId}/${entryId}`. Handlers live in a ref rather + * than in registry state so a body that rebuilds its closures every render + * does not re-register in a loop, and the rail still calls the current one. + */ + handlers: React.RefObject void>> +} + +const RailRegistrationContext = React.createContext(null) + +/** + * Publishes rail entries from inside the panel body, so the collapsed 48px rail + * can offer the tool's facets/roots instead of only an expand button. Safe to + * call unconditionally: outside a ToolSidebarLayout it is a no-op. + * + * `entries` may be rebuilt every render — registration is keyed on the entries' + * displayed data, not on array identity. + */ +export function useToolSidebarRail(groupId: string, entries: ToolSidebarRailEntry[]) { + const reg = React.useContext(RailRegistrationContext) + const key = railEntriesKey(entries) + const latest = React.useRef(entries) + latest.current = entries + + // Refresh handlers on every commit so the rail always calls the current + // closure, without that identity churn triggering re-registration. + React.useEffect(() => { + if (!reg) return + const map = reg.handlers.current + for (const e of latest.current) { + if (e.onSelect) map.set(railHandlerKey(groupId, e.id), e.onSelect) + } + }) + + React.useEffect(() => { + if (!reg) return + reg.register(groupId, latest.current) + return () => reg.unregister(groupId) + }, [reg, groupId, key]) +} + export interface ToolSidebarFilterItem { id: string label: string @@ -138,6 +202,13 @@ interface ToolSidebarLayoutProps { title: string /** Header buttons (add, sort, filter…) rendered left of the collapse toggle. */ actions?: React.ReactNode + /** + * Rail entries the page owns directly. Bodies that own their own state should + * call `useToolSidebarRail` instead; these render after the registered ones. + */ + rail?: ToolSidebarRailEntry[] + /** The tool's one "create" affordance. Shown in the header and in the rail. */ + primaryAction?: { icon: React.ElementType; label: string; onClick: () => void } /** * Panel body: the tool's list, tree, or filter set. Laid out as a flex column * that does NOT scroll — the panel owns its own `overflow-y-auto` region so a @@ -153,6 +224,8 @@ export function ToolSidebarLayout({ icon: Icon, title, actions, + rail, + primaryAction, sidebar, children, className, @@ -161,27 +234,72 @@ export function ToolSidebarLayout({ const isMobile = useIsMobile() const collapsed = useToolSidebarStore((s) => !!s.collapsed[toolId]) const setCollapsed = useToolSidebarStore((s) => s.setCollapsed) + const width = useToolSidebarStore((s) => s.width[toolId] ?? DEFAULT_SIDEBAR_WIDTH) const [sheetOpen, setSheetOpen] = React.useState(false) const [actionsSlot, setActionsSlot] = React.useState(null) + const [registry, setRegistry] = React.useState(emptyRailRegistry) + const handlers = React.useRef void>>(new Map()) + const panelId = React.useId() + const railExpandRef = React.useRef(null) + const panelRef = React.useRef(null) // Same category tint the dashboard card for this tool uses, so the identity // colour is continuous from the grid to the tool. Falls back to primary. const accent = categoryAccent(toolCategoryMap[toolId] ?? '') // The panel floats only as the mobile sheet; on desktop it is a pinned column. const isOverlay = isMobile && sheetOpen + const hidden = isMobile || collapsed + + const railRegistration = React.useMemo( + () => ({ + register: (groupId, entries) => setRegistry((r) => registerRailGroup(r, groupId, entries)), + unregister: (groupId) => setRegistry((r) => unregisterRailGroup(r, groupId)), + handlers, + }), + [], + ) + + const railEntries = React.useMemo(() => { + const flat = flattenRail(registry) + if (!rail?.length) return flat + // Prop entries are their own group, so they get a separator too. + return [...flat, ...rail.map((e, i) => ({ ...e, groupStart: i === 0 && flat.length > 0 }))] + }, [registry, rail]) + + const expand = React.useCallback(() => { + if (isMobile) setSheetOpen(true) + else setCollapsed(toolId, false) + }, [isMobile, setCollapsed, toolId]) const close = React.useCallback(() => { if (sheetOpen) setSheetOpen(false) else if (!isMobile) setCollapsed(toolId, true) }, [isMobile, setCollapsed, toolId, sheetOpen]) + const activateRailEntry = React.useCallback( + (entry: ToolSidebarRailEntry) => { + expand() + // `flattenRail` namespaced the id to `groupId/entryId`, which is exactly + // the handler-map key. Entries that arrived via the `rail` prop are not in + // the map, so they fall back to their own closure. + ;(handlers.current.get(entry.id) ?? entry.onSelect)?.() + }, + [expand], + ) + const ctx = React.useMemo( - () => ({ isMobile, isOverlay, close }), - [isMobile, isOverlay, close], + () => ({ isMobile, isOverlay, isVisible: !hidden, close }), + [isMobile, isOverlay, hidden, close], ) const panel = ( -
+
{title}
+ {primaryAction && ( + + )} {actions}
{!isMobile && ( @@ -203,9 +333,15 @@ export function ToolSidebarLayout({ variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" - onClick={() => setCollapsed(toolId, true)} - aria-label={t('hide')} - title={t('hide')} + onClick={() => { + setCollapsed(toolId, true) + // Focus would otherwise land on when this button hides. + requestAnimationFrame(() => railExpandRef.current?.focus()) + }} + aria-label={t('collapse')} + title={t('collapse')} + aria-expanded={true} + aria-controls={panelId} > @@ -218,72 +354,65 @@ export function ToolSidebarLayout({
) - const hidden = isMobile || collapsed - return ( -
- {!isMobile && !collapsed &&
{panel}
} - - {isMobile && ( - - - - {title} - + +
+ {/* Mounted even while collapsed. Two reasons: rail entries are published + by hooks inside the body and an unmounted body cannot publish, and + unmounting threw away the body's search text, expanded tree groups + and scroll position on every collapse. */} + {!isMobile && ( +
{panel} - - - )} +
+ )} - {/* Re-open lives in a 40px rail, not a floating overlay: every tool's - main pane already puts a toolbar or header at the top-left, and an - absolutely-positioned button would sit on top of it. + {isMobile && ( + + + + {title} + + {panel} + + + )} - One control, one job. The rail carries the tool's accent icon so the - collapsed strip still says which tool you are in, and swaps it for - the expand arrow on hover/focus — showing both at once read as two - competing buttons for the same action. */} - {hidden && ( -
- -
- )} + {/* Collapsed state is a 48px rail, not a floating overlay: every tool's + main pane already puts a toolbar or header at the top-left, and an + absolutely-positioned button would sit on top of it. The rail keeps + the tool's identity, its primary action and its facets reachable. */} + {hidden && ( + + )} - {/* Same surface the 71 single-pane tools paint (dashboard-grid-bg + - dash-ambient), so moving between a converter and a workspace tool - doesn't change the background under you. */} -
-
- {children} + {/* Same surface the 71 single-pane tools paint (dashboard-grid-bg + + dash-ambient), so moving between a converter and a workspace tool + doesn't change the background under you. */} +
+
+ {children} +
-
+ ) } From f26768f1f5fdab5de9ecd448454e35a734e5e432 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 18:03:40 +0530 Subject: [PATCH 06/13] feat(tool-sidebar): filter lists publish their facets to the collapsed rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit password-manager, api-keys, to-do and environment-manager gain a populated rail with no edits of their own — the facets are already declared as filter items. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../password-manager/password-list.tsx | 2 ++ .../src/components/tools/tool-sidebar.tsx | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/desktop-ui/src/components/password-manager/password-list.tsx b/apps/desktop-ui/src/components/password-manager/password-list.tsx index 2fe076b5..78933c87 100644 --- a/apps/desktop-ui/src/components/password-manager/password-list.tsx +++ b/apps/desktop-ui/src/components/password-manager/password-list.tsx @@ -367,6 +367,7 @@ export function PasswordList() { value={quickFilter} onChange={(id) => setQuickFilter(id as typeof quickFilter)} heading={t("filters.heading")} + railGroupId="security" /> {tagFilters.length > 0 && ( )}
diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx index a7ccbf8a..a3db7b87 100644 --- a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx +++ b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx @@ -143,6 +143,7 @@ export function ToolSidebarFilterList({ onChange, heading, className, + railGroupId, }: { items: ToolSidebarFilterItem[] value: string @@ -150,8 +151,33 @@ export function ToolSidebarFilterList({ /** Optional uppercase section label above the rows. */ heading?: string className?: string + /** + * Group id for the collapsed rail. Defaults to the heading, so a panel with + * two filter lists (password-manager: security + tags) produces two rail + * groups. Pass explicitly when there is no heading. + */ + railGroupId?: string }) { const panel = useToolSidebarPanel() + const groupId = railGroupId ?? heading ?? 'filters' + + // The facets this list already renders are exactly what the collapsed rail + // should offer, so publish them rather than making each tool restate them. + const railEntries = React.useMemo( + () => + items.map((item) => ({ + id: item.id, + label: item.label, + icon: item.icon, + count: item.count, + active: item.id === value, + onSelect: () => onChange(item.id), + })), + [items, value, onChange], + ) + + useToolSidebarRail(groupId, railEntries) + return (
{heading && ( From d795f045159ab3de871b5cfc6ba5bc74d1a0cf32 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 18:09:31 +0530 Subject: [PATCH 07/13] feat(tool-sidebar): tree sidebars publish their top-level entries to the rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notes and snippet-manager publish pinned items, api-client its collections, data-explorer and s3-drive their connections, bookmarks and secure-files their root folders. Also fixes secure-files' folder tree calling panel.close() unconditionally on select, which collapsed the whole sidebar on every folder pick on desktop — every other sidebar guards that with isOverlay. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../collections/collections-sidebar.tsx | 29 +++++++++++++++-- .../src/components/bookmarks/folder-tree.tsx | 31 ++++++++++++++++++- .../data-explorer/unified-sidebar.tsx | 28 ++++++++++++++++- .../src/components/notes/NotesSidebar.tsx | 21 +++++++++++-- .../components/s3-drive/bucket-sidebar.tsx | 21 +++++++++++-- .../components/secure-files/folder-tree.tsx | 23 ++++++++++++-- .../src/components/sidebar/client-layout.tsx | 14 ++------- .../snippet-manager/snippet-manager-tool.tsx | 23 +++++++++++++- 8 files changed, 168 insertions(+), 22 deletions(-) diff --git a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx index 52cddd36..00d6ae6d 100644 --- a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx @@ -49,7 +49,7 @@ import { useHistoryState, useHistoryActions } from "../context/history-context" import { CollectionsSidebarSkeleton, HistoryListSkeleton } from "../skeletons" import { useDebouncedValue } from "@/lib/use-debounced-value" import { isDesktop } from "@/lib/desktop/is-desktop" -import { ToolSidebarActions, useToolSidebarPanel } from "@/components/tools/tool-sidebar" +import { ToolSidebarActions, useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar" interface CollectionsSidebarProps { onLoadRequest: (request: CollectionRequest) => void @@ -144,6 +144,31 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi return filteredCollections.filter((c) => c.workspace === activeWorkspaceId) }, [filteredCollections, activeWorkspaceId]) + // Collections stay reachable from the collapsed rail. A collection has no + // open/closed state of its own — only the folders inside it toggle — so + // picking one expands the panel and scrolls that collection into view. + useToolSidebarRail( + "collections", + React.useMemo( + () => + collectionsForActiveWs.slice(0, 8).map((c) => ({ + id: c.id, + label: c.name, + icon: FolderGit2, + count: c.items.length, + onSelect: () => { + // The panel is expanding in the same tick; wait for layout. + requestAnimationFrame(() => + document + .querySelector(`[data-collection-id="${CSS.escape(c.id)}"]`) + ?.scrollIntoView({ block: "nearest" }), + ) + }, + })), + [collectionsForActiveWs], + ), + ) + const activeWorkspaceName = workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "All" const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) @@ -364,7 +389,7 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi
) : ( collectionsForActiveWs.map((collection) => ( -
+
diff --git a/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx b/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx index 4858c11e..5b0fe0c2 100644 --- a/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx +++ b/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import { motion, AnimatePresence } from "framer-motion" import { IconFolder, @@ -43,6 +43,7 @@ import { import EditFolderDialog from "./edit-folder-dialog" import AddFolderDialog from "./add-folder-dialog" import { useTranslations } from "next-intl" +import { useToolSidebarRail } from "@/components/tools/tool-sidebar" interface FolderTreeProps { onSelectFolder: (id: string | null) => void @@ -51,9 +52,37 @@ interface FolderTreeProps { export default function FolderTree({ onSelectFolder }: FolderTreeProps) { const t = useTranslations("Bookmarks.folderTree") const { selectedFolderId, bookmarks } = useBookmarkStore() + // Memoized selector (bookmark-store.ts:506), so calling it here as well as + // in RootFolders costs nothing. + const rootFolders = useChildFolders(null) const totalBookmarks = bookmarks.length + // "All bookmarks" plus the root folders, for the collapsed rail. + useToolSidebarRail( + "folders", + useMemo( + () => [ + { + id: "all", + label: t("allBookmarks"), + icon: IconBookmarks, + count: totalBookmarks, + active: selectedFolderId === null, + onSelect: () => onSelectFolder(null), + }, + ...rootFolders.slice(0, 7).map(f => ({ + id: f.id, + label: f.name, + icon: IconFolder, + active: selectedFolderId === f.id, + onSelect: () => onSelectFolder(f.id), + })), + ], + [rootFolders, totalBookmarks, selectedFolderId, onSelectFolder, t], + ), + ) + return (
{/* All Bookmarks */} diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx index 24b9b083..bff91586 100644 --- a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -7,6 +7,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { IconAlertTriangle, IconChevronRight, + IconDatabase, IconDots, IconDownload, IconEdit, @@ -35,7 +36,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; -import { useToolSidebarPanel } from "@/components/tools/tool-sidebar"; +import { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar"; import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; import { deleteConnection } from "./connection-service"; import type { OpenTabRequest, SourceId, UnifiedConnection } from "./types"; @@ -208,6 +209,31 @@ export function UnifiedSidebar({ [onOpenTab, panel] ); + // Connections stay reachable from the collapsed rail; picking one expands + // its tree, which is what the row click does too. + useToolSidebarRail( + "connections", + useMemo( + () => + connections + .filter((c): c is UnifiedConnection => !!c && typeof c.id === "string") + .slice(0, 8) + .map((c) => ({ + id: c.id, + label: c.name, + icon: IconDatabase, + active: expandedIds.has(c.id), + onSelect: () => + setExpandedIds((prev) => { + const next = new Set(prev); + next.add(c.id); + return next; + }), + })), + [connections, expandedIds] + ) + ); + const filtered = useMemo(() => { const q = query.trim().toLowerCase(); const safe = connections.filter( diff --git a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx index 25fc902f..87983a3b 100644 --- a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx +++ b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx @@ -55,7 +55,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Note } from "@/app/app/notes/types/Note"; -import { ToolSidebarActions, useToolSidebarPanel } from "@/components/tools/tool-sidebar"; +import { ToolSidebarActions, useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar"; import { useTranslations } from "next-intl"; import { extractSnippet } from "@/app/app/notes/utils/noteContentUtils"; import { type ChildrenMap, type SortDir, type SortKey, buildChildrenMap, getCachedPlainText } from "./notes-helpers"; @@ -273,7 +273,7 @@ NoteItem.displayName = "NoteItem"; export default function NotesSidebar() { const t = useTranslations("Notes.sidebar"); const { notes, noteById, isLoading, searchIndexReady } = useNotesData(); - const { activeNoteId } = useNotesUI(); + const { activeNoteId, setActiveNoteId } = useNotesUI(); const { createNote, deleteNote, moveNote, warmSearchIndex } = useNotesActions(); const [noteToDelete, setNoteToDelete] = useState(null); const [noteToMove, setNoteToMove] = useState(null); @@ -309,6 +309,23 @@ export default function NotesSidebar() { const pinnedNotes = useMemo(() => rootNotes.filter(n => n.pinned), [rootNotes]); const unpinnedNotes = useMemo(() => rootNotes.filter(n => !n.pinned), [rootNotes]); + // Pinned notes are the sidebar's own shortlist, so they are what the + // collapsed rail offers. + useToolSidebarRail( + "pinned", + useMemo( + () => + pinnedNotes.slice(0, 8).map(n => ({ + id: n.id, + label: n.title || t("untitled"), + icon: Pin, + active: n.id === activeNoteId, + onSelect: () => setActiveNoteId(n.id), + })), + [pinnedNotes, activeNoteId, setActiveNoteId, t], + ), + ); + const searchResults = useMemo(() => { const trimmed = debouncedQuery.trim(); if (!trimmed) return null; diff --git a/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx b/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx index 11c78fa7..34d2fdd2 100644 --- a/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx +++ b/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import { toast } from "sonner" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -23,7 +23,7 @@ import { import { IconBucket, IconPlus, IconDots, IconPencil, IconTrash, IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react" import { deleteConnection } from "@/lib/s3-drive-api" import { useS3DriveStore, type DecryptedConnection } from "@/store/s3-drive-store" -import { useToolSidebarPanel } from "@/components/tools/tool-sidebar" +import { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar" import { AddBucketDialog } from "./add-bucket-dialog" type Props = { @@ -61,6 +61,23 @@ function connSubtitle(conn: DecryptedConnection): string { export function BucketSidebar({ encryptionKey }: Props) { const { connections, activeConnectionId, setActiveConnection, removeConnection } = useS3DriveStore() const panel = useToolSidebarPanel() + + // Connections stay switchable from the collapsed rail. + useToolSidebarRail( + "connections", + useMemo( + () => + connections.slice(0, 8).map((c) => ({ + id: c.id, + label: c.name, + icon: IconBucket, + active: c.id === activeConnectionId, + onSelect: () => setActiveConnection(c.id), + })), + [connections, activeConnectionId, setActiveConnection], + ), + ) + const [addOpen, setAddOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) diff --git a/apps/desktop-ui/src/components/secure-files/folder-tree.tsx b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx index 6cb9b185..63a42b2f 100644 --- a/apps/desktop-ui/src/components/secure-files/folder-tree.tsx +++ b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx @@ -11,7 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { useToolSidebarPanel } from "@/components/tools/tool-sidebar" +import { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar" import { cn } from "@/lib/utils" import type { FolderNode } from "@/lib/secure-files" @@ -55,11 +55,30 @@ export function FolderTree({ root, currentDir, ...actions }: { root: FolderNode; const select = React.useCallback( (path: string) => { actions.onSelect(path) - panel?.close() + // Only when the panel floats over the result. On desktop it is a pinned + // column, and closing it collapsed the sidebar on every folder pick. + if (panel?.isOverlay) panel.close() }, [actions, panel], ) + // Top-level folders stay reachable from the collapsed rail. + useToolSidebarRail( + "folders", + React.useMemo( + () => + root.children.slice(0, 8).map((child) => ({ + id: child.path, + label: child.name, + icon: IconFolder, + count: child.files.length, + active: currentDir === child.path, + onSelect: () => select(child.path), + })), + [root.children, currentDir, select], + ), + ) + return (
- +
) : (

{t("chooseFolderBody")}

diff --git a/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts index 08757bce..a5e023b0 100644 --- a/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts @@ -90,3 +90,13 @@ describe("looksLikeText", () => { expect(looksLikeText(new Uint8Array())).toBe(true) }) }) + +describe("NO_FOLDER", () => { + it("is a value no real folder path can equal", () => { + const { NO_FOLDER } = require("../secure-files") + expect(typeof NO_FOLDER).toBe("string") + // "" is the root folder and must stay distinct from "no selection". + expect(NO_FOLDER).not.toBe("") + expect(NO_FOLDER).not.toMatch(/^[\w./-]+$/) + }) +}) diff --git a/apps/desktop-ui/src/lib/secure-files.ts b/apps/desktop-ui/src/lib/secure-files.ts index 24a0f399..b8578b4c 100644 --- a/apps/desktop-ui/src/lib/secure-files.ts +++ b/apps/desktop-ui/src/lib/secure-files.ts @@ -34,6 +34,15 @@ export function baseName(dir: string): string { return dir.slice(dir.lastIndexOf("/") + 1) } +/** + * `currentDir` value meaning "the overview is showing, no folder is selected". + * `""` is the root folder and would highlight it (folder-tree.tsx:70), so the + * sentinel has to be a string no path can equal. Written as an escape, not a + * raw byte — a literal NUL in the source makes the whole file binary to grep, + * ripgrep and diff, which then skip it silently. + */ +export const NO_FOLDER = "\u0000" + /** Build the folder tree from file dirs plus any empty (not yet populated) dirs. */ export function buildFolderTree(files: SecureFileEntry[], extraDirs: Iterable = []): FolderNode { const root: FolderNode = { name: "", path: "", children: [], files: [] } From 15b405868656cd6349f834035e57b840071bbce8 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 18:14:04 +0530 Subject: [PATCH 11/13] feat(tool-sidebar): shared search, section, row and empty-state primitives Shipped unused. They give the next per-tool sidebar migration somewhere to land without that migration also having to design the pieces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../tools/tool-sidebar-primitives.tsx | 196 ++++++++++++++++++ .../src/components/tools/tool-sidebar.tsx | 11 + 2 files changed, 207 insertions(+) create mode 100644 apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx new file mode 100644 index 00000000..a34eafc0 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx @@ -0,0 +1,196 @@ +'use client' + +import * as React from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { IconChevronRight } from '@tabler/icons-react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useToolSidebarPanel } from '@/components/tools/tool-sidebar' +import { cn } from '@/lib/utils' + +/** + * Body building blocks shared by tool sidebars. Adopting them is per-tool + * follow-up work — nothing was rewritten onto them in the branch that added + * them, so they exist here unused until a tool migrates. + */ + +/** The filter field five sidebars each rebuild. */ +export function ToolSidebarSearch({ + value, + onChange, + placeholder, + className, +}: { + value: string + onChange: (next: string) => void + placeholder: string + className?: string +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="h-8 pl-8 pr-8 text-sm" + /> + {value && ( + + )} +
+ ) +} + +/** + * Collapsible group following the project convention: chevron rotates 90deg + * when open, framer-motion height/opacity at ~0.2s, nothing rendered when empty. + */ +export function ToolSidebarSection({ + title, + count, + defaultOpen = true, + actions, + children, +}: { + title: string + count?: number + defaultOpen?: boolean + actions?: React.ReactNode + children: React.ReactNode +}) { + const [open, setOpen] = React.useState(defaultOpen) + if (count === 0) return null + + return ( +
+
+ + {actions} +
+ + {open && ( + + {children} + + )} + +
+ ) +} + +interface ToolSidebarRowProps { + label: string + icon?: React.ElementType + count?: number + active?: boolean + /** Indent depth for tree rows. */ + level?: number + actions?: React.ReactNode + onSelect: () => void +} + +function ToolSidebarRowImpl({ + label, + icon: Icon, + count, + active, + level = 0, + actions, + onSelect, +}: ToolSidebarRowProps) { + const panel = useToolSidebarPanel() + return ( +
+ + {actions && ( +
+ {actions} +
+ )} +
+ ) +} + +export const ToolSidebarRow = React.memo(ToolSidebarRowImpl, (a, b) => { + return ( + a.label === b.label && + a.icon === b.icon && + a.count === b.count && + a.active === b.active && + a.level === b.level && + a.actions === b.actions && + a.onSelect === b.onSelect + ) +}) +ToolSidebarRow.displayName = 'ToolSidebarRow' + +export function ToolSidebarEmpty({ + icon: Icon, + message, + action, +}: { + icon: React.ElementType + message: string + action?: React.ReactNode +}) { + return ( +
+ +

{message}

+ {action} +
+ ) +} diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx index e3744891..d1f91db8 100644 --- a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx +++ b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx @@ -32,6 +32,17 @@ import { cn } from '@/lib/utils' export type { ToolSidebarRailEntry } from '@/lib/tool-sidebar-rail' +// Body primitives live in their own file (this one is the layout), but consumers +// import everything sidebar-shaped from here. `ToolSidebarFilterList` stays +// below: the primitives file imports `useToolSidebarPanel` from here, so moving +// it would close an import cycle. +export { + ToolSidebarSearch, + ToolSidebarSection, + ToolSidebarRow, + ToolSidebarEmpty, +} from '@/components/tools/tool-sidebar-primitives' + /** * The one in-tool left panel. Every app-like tool (notes, bookmarks, api-client, * data-explorer, s3-drive, snippet-manager, password-manager, api-keys, From 74f607199e91cc5c2c4d5bf4d09e29b76e6f9ba5 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 20:25:21 +0530 Subject: [PATCH 12/13] fix(dashboard): greet the user by their profile name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero greeted off `useAuth().user.displayName`, which is a hardcoded null — that identity object is deliberately name-free, and the editable profile name lives in local preferences behind `useAppUser` (as top-bar and mobile-nav already read it). So the greeting never named anyone, however the profile was filled in. Reads the profile name instead, via a `greetingFirstName` helper that is unit tested. The now-unused `user` prop is dropped from DashboardHero. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- apps/desktop-ui/src/app/dashboard/page.tsx | 2 -- .../__tests__/dashboard-greeting.test.ts | 27 +++++++++++++++++++ .../components/dashboard/dashboard-hero.tsx | 17 ++++++------ .../src/components/dashboard/types.ts | 10 +++++++ 4 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 apps/desktop-ui/src/components/dashboard/__tests__/dashboard-greeting.test.ts diff --git a/apps/desktop-ui/src/app/dashboard/page.tsx b/apps/desktop-ui/src/app/dashboard/page.tsx index 6df8dc43..ceec1f1b 100644 --- a/apps/desktop-ui/src/app/dashboard/page.tsx +++ b/apps/desktop-ui/src/app/dashboard/page.tsx @@ -262,7 +262,6 @@ const DashboardPage: React.FC = () => {
{/* ── Mobile Sticky Header (outside padded container for full-bleed) ── */} { {/* ── Desktop Hero (inside padded container for alignment) ── */} { + it("uses the first word of a full name", () => { + expect(greetingFirstName("Akhil Edathadan")).toBe("Akhil") + }) + + it("passes a single-word name through", () => { + expect(greetingFirstName("Akhil")).toBe("Akhil") + }) + + it("ignores surrounding and repeated whitespace", () => { + expect(greetingFirstName(" Akhil Edathadan ")).toBe("Akhil") + }) + + it("returns null for an unset name so the greeting falls back", () => { + expect(greetingFirstName("")).toBeNull() + expect(greetingFirstName(" ")).toBeNull() + expect(greetingFirstName(undefined)).toBeNull() + expect(greetingFirstName(null)).toBeNull() + }) +}) diff --git a/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx b/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx index befde774..ed2b3884 100644 --- a/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx +++ b/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx @@ -4,12 +4,12 @@ import React from 'react' import Link from 'next/link' import { Layers, Zap, Pin, Clock, History } from 'lucide-react' import { useTranslations } from 'next-intl' -import { dashboardGreeting } from './types' +import { dashboardGreeting, greetingFirstName } from './types' +import { useAppUser } from '@/hooks/use-app-user' import { useCountUp } from '@/hooks/use-count-up' import { cn } from '@/lib/utils' interface DashboardHeroProps { - user: { displayName?: string | null } | null totalTools: number pinnedCount: number recentCount: number @@ -67,7 +67,6 @@ function Stat({ } export function DashboardHero({ - user, totalTools, pinnedCount, recentCount, @@ -75,6 +74,10 @@ export function DashboardHero({ desktopOnly, }: DashboardHeroProps) { const t = useTranslations('Dashboard') + // The editable profile name lives in local preferences. `useAuth` is identity + // only — its displayName is a hardcoded null, so greeting off it never named + // anyone. + const firstName = greetingFirstName(useAppUser().name) return ( <> @@ -92,9 +95,7 @@ export function DashboardHero({

{dashboardGreeting(t)} - {user?.displayName - ? t('commaName', { name: user.displayName.split(' ')[0] }) - : ''} + {firstName ? t('commaName', { name: firstName }) : ''}

@@ -124,9 +125,7 @@ export function DashboardHero({ {dashboardGreeting(t)}

- {user?.displayName - ? t('welcomeBackNamed', { name: user.displayName.split(' ')[0] }) - : t('welcomeBack')} + {firstName ? t('welcomeBackNamed', { name: firstName }) : t('welcomeBack')}

{t('tagline')}

diff --git a/apps/desktop-ui/src/components/dashboard/types.ts b/apps/desktop-ui/src/components/dashboard/types.ts index ca746129..368debef 100644 --- a/apps/desktop-ui/src/components/dashboard/types.ts +++ b/apps/desktop-ui/src/components/dashboard/types.ts @@ -103,6 +103,16 @@ export function groupDisplayTitle(title: string, t: (key: string) => string): st return title } +/** + * First name for the dashboard greeting, or null when the profile has no name + * set. The name comes from local preferences (`useAppUser`) — `useAuth` is + * identity only and its displayName is always null. + */ +export function greetingFirstName(name: string | null | undefined): string | null { + const first = name?.trim().split(/\s+/)[0] + return first || null +} + /** Time-of-day greeting key. */ export function dashboardGreeting(t: (key: string) => string): string { const hour = new Date().getHours() From 91c88682785a277b7e33f1361582f5cdf5ff5e56 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 24 Aug 2026 20:37:17 +0530 Subject: [PATCH 13/13] fix(notes): hide the sidebar until the vault is unlocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault gate sat in page.tsx, which is this layout's children — so a locked vault swapped the editor for the placeholder while the sidebar column kept rendering a note tree, a search field and a New note button, all backed by a vault with no key loaded. Gate in the layout instead, above NotesProvider so the provider does not mount and fail to load notes it cannot decrypt. password-manager, api-keys, environment-manager, secure-files and snippet-manager already gate before the component that owns their ToolSidebarLayout; notes differed only because its layout is a separate route-level wrapper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H3GuKxASDXLwFCLA7o3GGH --- .../src/app/app/notes/notes-client-layout.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx b/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx index 94cc182a..cfb8220b 100644 --- a/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx +++ b/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx @@ -5,6 +5,9 @@ import NotesSidebar from "@/components/notes/NotesSidebar"; import { ToolSidebarLayout } from "@/components/tools/tool-sidebar"; import { NotebookPen } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useVaultGuard } from "@/hooks/use-vault-guard"; +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; function NotesLayout({ children }: { children: React.ReactNode }) { const tSidebar = useTranslations("Notes.sidebar"); @@ -33,6 +36,20 @@ function NotesLayout({ children }: { children: React.ReactNode }) { } export default function NotesClientLayout({ children }: { children: React.ReactNode }) { + const { isUnlocked, isRestoring } = useVaultGuard(); + + // Gate here, not in page.tsx. The page is this layout's *children*, so a gate + // there swaps the editor for the placeholder while the sidebar column keeps + // rendering — a note tree, a search field and a New note button, all backed + // by a vault that cannot be read. password-manager, api-keys and + // environment-manager gate before their ToolSidebarLayout for the same + // reason; notes only differed because its layout is a separate wrapper. + // + // Above NotesProvider, so a locked vault does not mount the provider and + // have it fail to load notes it has no key for. + if (isRestoring) return ; + if (!isUnlocked) return ; + return ( {children}