diff --git a/DESIGN.md b/DESIGN.md index 21b1602f..62191fe0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -42,14 +42,14 @@ The public web must show the artifact before explaining the product. Real Sessio ### Desktop app - **Core principle:** The desktop app is the author’s private preparation surface. Projects and Sessions are the home; search and publishing are actions within that context. -- **Shell:** Persistent left sidebar (240px) + main pane. Sidebar lists projects derived from `project_groups_v` and remains visible across every main-pane state. +- **Shell:** Persistent resizable left sidebar (240px default, 200–360px) + main pane. Sidebar lists projects derived from `project_groups_v` and remains visible across every main-pane state. - **Sidebar:** Warm surface background, soft right border. Top-left wordmark `Spool.`, then a `PROJECTS` section label with a sort menu, then project rows. A divider separates derived projects from the always-last `Loose` entry. - **Project row:** Display name on the left, faint source-color dots in the middle, monospace count on the right. Active and hover states use `surface2`. - **Home:** Pinned Sessions above a recent feed bucketed by date. Entry to search is ⌘K or the top-right trigger. - **Project view:** Recent feed of one project with sort and source filters. A `PINNED` segment surfaces project-pinned Sessions on top. - **Session detail:** Opens as a main-pane state, not a modal. Share and Publish actions belong in the detail header. - **Search overlay (⌘K):** Floats above the current main pane on a dimmed backdrop, scoped to `All` or the current project. Fast and AI modes share the same surface. -- **Width:** Window width ~960px; main-pane content stays near 720px; sidebar stays fixed at 240px. +- **Width:** Window width ~960px; main-pane content stays near 720px; the sidebar remembers widths from 200–360px and defaults to 240px. ### Shape diff --git a/apps/app/e2e/session-tree-sidebar-resize.spec.ts b/apps/app/e2e/session-tree-sidebar-resize.spec.ts new file mode 100644 index 00000000..1a2e6630 --- /dev/null +++ b/apps/app/e2e/session-tree-sidebar-resize.spec.ts @@ -0,0 +1,106 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + +const ROOT_UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const CHILD_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +let ctx: AppContext + +test.beforeEach(async () => { + ctx = await launchApp({ + extraFixtures: ({ codexDir }) => { + const dayDir = join(codexDir, '2026', '07', '19') + mkdirSync(dayDir, { recursive: true }) + writeCodexSession(dayDir, ROOT_UUID, '12-00-00', '2026-07-19T12:00:00Z', null) + writeCodexSession(dayDir, CHILD_UUID, '12-01-00', '2026-07-19T12:01:00Z', ROOT_UUID) + }, + }) +}) + +test.afterEach(async () => { + await ctx?.cleanup() +}) + +test('library renders Codex child sessions as a collapsed tree', async () => { + const { window } = ctx + await waitForSync(window) + await window.locator('[data-testid="sidebar-library"]').click() + + const root = window.locator(`[data-testid="session-row"][data-session-uuid="${ROOT_UUID}"]`) + const child = window.locator(`[data-testid="session-row"][data-session-uuid="${CHILD_UUID}"]`) + await expect(root).toBeVisible() + await expect(child).toBeHidden() + + const toggle = root.locator('[data-testid="session-tree-toggle"]') + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await toggle.focus() + await toggle.press('Enter') + + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(child).toBeVisible() + await expect(child).toHaveAttribute('data-tree-depth', '1') + await expect(window.locator('[data-testid="session-detail"]')).toHaveCount(0) +}) + +test('sidebar separator supports keyboard, drag, reset, and persistence', async () => { + const { window } = ctx + const sidebar = window.locator('[data-testid="sidebar"]') + const handle = window.locator('[data-testid="sidebar-resize-handle"]') + await expect(handle).toBeVisible() + + await handle.focus() + await handle.press('ArrowRight') + await expect(handle).toHaveAttribute('aria-valuenow', '248') + expect(Math.round((await sidebar.boundingBox())!.width)).toBe(248) + + const box = await handle.boundingBox() + if (!box) throw new Error('sidebar resize handle has no bounding box') + await window.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await window.mouse.down() + await window.mouse.move(box.x + box.width / 2 + 32, box.y + box.height / 2) + await window.mouse.up() + await expect(handle).toHaveAttribute('aria-valuenow', '280') + + expect(await window.evaluate(() => localStorage.getItem('spool:sidebar-width'))).toBe('280') + await handle.dblclick() + await expect(handle).toHaveAttribute('aria-valuenow', '240') + expect(await window.evaluate(() => localStorage.getItem('spool:sidebar-width'))).toBe('240') +}) + +function writeCodexSession( + dir: string, + uuid: string, + fileTime: string, + timestamp: string, + parentSessionUuid: string | null, +): void { + const sessionMeta = { + timestamp, + type: 'session_meta', + payload: { + id: uuid, + cwd: '/tmp/tree-project', + ...(parentSessionUuid + ? { + parent_thread_id: parentSessionUuid, + thread_source: 'subagent', + source: { subagent: 'review' }, + } + : { source: 'cli' }), + }, + } + const userMessage = { + timestamp, + type: 'event_msg', + payload: { + type: 'user_message', + message: parentSessionUuid ? 'Review the parent session.' : 'Implement the parent feature.', + }, + } + const filePath = join(dir, `rollout-2026-07-19T${fileTime}-${uuid}.jsonl`) + writeFileSync(filePath, `${JSON.stringify(sessionMeta)}\n${JSON.stringify(userMessage)}\n`) +} diff --git a/apps/app/src/renderer/App.tsx b/apps/app/src/renderer/App.tsx index b210fea9..4c8890a6 100644 --- a/apps/app/src/renderer/App.tsx +++ b/apps/app/src/renderer/App.tsx @@ -47,7 +47,8 @@ import ShareEditorPage from './components/ShareEditorPage.js' import ShareSessionDialog from './components/ShareSessionDialog.js' import SharesPage from './components/SharesPage.js' import Sidebar from './components/Sidebar.js' -import SidebarRail from './components/SidebarRail.js' +import SidebarRail, { DEFAULT_SIDEBAR_WIDTH } from './components/SidebarRail.js' +import SidebarResizeHandle, { clampSidebarWidth } from './components/SidebarResizeHandle.js' import { useHotkeys } from './hooks/useHotkeys.js' import { useLanguageBootstrap } from './i18n/useLanguageBootstrap.js' import { composeFromSession, sessionDraftId } from './lib/compose-from-session.js' @@ -58,6 +59,7 @@ import { loadThemeEditorState, saveThemeEditorState } from './theme/persist.js' type View = 'search' | 'session' | 'shares' | 'share-editor' | 'security' type SettingsTab = 'general' | 'appearance' | 'shortcuts' | 'sources' | 'agent' | 'security' +const SIDEBAR_WIDTH_STORAGE_KEY = 'spool:sidebar-width' type FragmentSearchResult = FragmentResult & { kind: 'fragment' } @@ -145,6 +147,8 @@ export default function App() { const [searchOverlayOpen, setSearchOverlayOpen] = useState(false) const [searchScopeProject, setSearchScopeProject] = useState(null) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth) + const [sidebarResizing, setSidebarResizing] = useState(false) const [sharePanelOpen, setSharePanelOpen] = useState(true) const trafficLightInset = typeof window !== 'undefined' && window.spool?.platform === 'darwin' /** Active share-editor session: conversation, the user's last @@ -1040,6 +1044,7 @@ export default function App() { } : {})} chromeOnly={!trafficLightInset && sidebarCollapsed} + width={sidebarWidth} onSettingsClick={() => { setSettingsTab('general') setShowSettings(true) @@ -1084,6 +1089,14 @@ export default function App() { onTogglePanel={() => setSharePanelOpen((v) => !v)} sidebar={sidebarElement} sidebarCollapsed={sidebarCollapsed} + sidebarWidth={sidebarWidth} + sidebarResizing={sidebarResizing} + onSidebarWidthChange={setSidebarWidth} + onSidebarResizeStart={() => setSidebarResizing(true)} + onSidebarResizeEnd={(width) => { + setSidebarResizing(false) + persistSidebarWidth(width) + }} onToggleSidebar={toggleSidebar} trafficLightInset={trafficLightInset} /> @@ -1146,14 +1159,29 @@ export default function App() { sidebarCollapsed={sidebarCollapsed} onToggleSidebar={toggleSidebar} trafficLightInset={trafficLightInset} + sidebarWidth={sidebarWidth} + sidebarResizing={sidebarResizing} />
{sidebarElement} + {!sidebarCollapsed && ( + setSidebarResizing(true)} + onResizeEnd={(width) => { + setSidebarResizing(false) + persistSidebarWidth(width) + }} + /> + )}
{isSharesView ? ( @@ -1394,6 +1422,23 @@ export default function App() { ) } +function loadSidebarWidth(): number { + try { + const stored = Number.parseInt(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) ?? '', 10) + return Number.isFinite(stored) ? clampSidebarWidth(stored) : DEFAULT_SIDEBAR_WIDTH + } catch { + return DEFAULT_SIDEBAR_WIDTH + } +} + +function persistSidebarWidth(width: number): void { + try { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(width))) + } catch { + // Storage may be unavailable in hardened or test renderers. + } +} + const AgentSelector = memo(function AgentSelector({ agents, activeAgent, diff --git a/apps/app/src/renderer/components/AppTopBar.tsx b/apps/app/src/renderer/components/AppTopBar.tsx index 2bf349ae..9ec77e35 100644 --- a/apps/app/src/renderer/components/AppTopBar.tsx +++ b/apps/app/src/renderer/components/AppTopBar.tsx @@ -2,10 +2,14 @@ import { PanelLeft } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { DEFAULT_SIDEBAR_WIDTH } from './SidebarRail.js' + type Props = { sidebarCollapsed: boolean onToggleSidebar: () => void trafficLightInset?: boolean + sidebarWidth?: number + sidebarResizing?: boolean /** Page-level chrome (page title, primary action). Rendered into a * flex slot to the right of the sidebar fold toggle. */ children?: ReactNode @@ -22,6 +26,8 @@ export default function AppTopBar({ sidebarCollapsed, onToggleSidebar, trafficLightInset = true, + sidebarWidth = DEFAULT_SIDEBAR_WIDTH, + sidebarResizing = false, children, }: Props) { const { t } = useTranslation() @@ -30,6 +36,8 @@ export default function AppTopBar({ const sidebarTitle = sidebarCollapsed ? `${t('sidebar.expand')} (⌘B)` : `${t('sidebar.collapse')} (⌘B)` + const collapsedWidth = trafficLightInset ? 0 : 48 + const widthTransition = sidebarResizing ? '' : 'transition-[width] duration-[280ms] ease-out' if (!trafficLightInset && !children) { return null @@ -48,10 +56,8 @@ export default function AppTopBar({ with the content pane below. */}