Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
106 changes: 106 additions & 0 deletions apps/app/e2e/session-tree-sidebar-resize.spec.ts
Original file line number Diff line number Diff line change
@@ -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`)
}
47 changes: 46 additions & 1 deletion apps/app/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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' }

Expand Down Expand Up @@ -145,6 +147,8 @@ export default function App() {
const [searchOverlayOpen, setSearchOverlayOpen] = useState(false)
const [searchScopeProject, setSearchScopeProject] = useState<ScopeValue | null>(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
Expand Down Expand Up @@ -1040,6 +1044,7 @@ export default function App() {
}
: {})}
chromeOnly={!trafficLightInset && sidebarCollapsed}
width={sidebarWidth}
onSettingsClick={() => {
setSettingsTab('general')
setShowSettings(true)
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -1146,14 +1159,29 @@ export default function App() {
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
trafficLightInset={trafficLightInset}
sidebarWidth={sidebarWidth}
sidebarResizing={sidebarResizing}
/>
<div className="flex min-h-0 flex-1">
<SidebarRail
collapsed={sidebarCollapsed}
collapsedWidth={!trafficLightInset ? 'chrome' : 'none'}
width={sidebarWidth}
resizing={sidebarResizing}
>
{sidebarElement}
</SidebarRail>
{!sidebarCollapsed && (
<SidebarResizeHandle
width={sidebarWidth}
onWidthChange={setSidebarWidth}
onResizeStart={() => setSidebarResizing(true)}
onResizeEnd={(width) => {
setSidebarResizing(false)
persistSidebarWidth(width)
}}
/>
)}
<div className="relative flex min-w-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
{isSharesView ? (
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 14 additions & 12 deletions apps/app/src/renderer/components/AppTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +26,8 @@ export default function AppTopBar({
sidebarCollapsed,
onToggleSidebar,
trafficLightInset = true,
sidebarWidth = DEFAULT_SIDEBAR_WIDTH,
sidebarResizing = false,
children,
}: Props) {
const { t } = useTranslation()
Expand All @@ -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
Expand All @@ -48,10 +56,8 @@ export default function AppTopBar({
with the content pane below. */}
<div className="pointer-events-none absolute inset-0 flex" aria-hidden="true">
<div
className={[
'flex-none transition-[width] duration-[280ms] ease-out bg-warm-surface dark:bg-dark-surface',
sidebarCollapsed ? (trafficLightInset ? 'w-0' : 'w-12') : 'w-60',
].join(' ')}
className={`flex-none ${widthTransition} bg-warm-surface dark:bg-dark-surface`}
style={{ width: sidebarCollapsed ? collapsedWidth : sidebarWidth }}
/>
<div className="bg-warm-bg dark:bg-dark-bg flex-1" />
</div>
Expand All @@ -74,19 +80,15 @@ export default function AppTopBar({
/>
</div>
<div
className={[
'flex-none transition-[width] duration-[280ms] ease-out',
sidebarCollapsed ? 'w-0' : 'w-[134px]',
].join(' ')}
className={`flex-none ${widthTransition}`}
style={{ width: sidebarCollapsed ? 0 : Math.max(0, sidebarWidth - 106) }}
aria-hidden="true"
/>
</>
) : (
<div
className={[
'flex-none transition-[width] duration-[280ms] ease-out',
sidebarCollapsed ? 'w-12' : 'w-60',
].join(' ')}
className={`flex-none ${widthTransition}`}
style={{ width: sidebarCollapsed ? 48 : sidebarWidth }}
aria-hidden="true"
/>
)}
Expand Down
58 changes: 47 additions & 11 deletions apps/app/src/renderer/components/LibraryLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { useTranslation } from 'react-i18next'

import { insertSessionSorted } from '../../shared/sessionSort.js'
import { buildSessionForest, type SessionTreeNode } from '../lib/sessionTree.js'
import { FeaturedEmptyState } from './EmptyState.js'
import VirtualSessionList, { type SessionListRow } from './VirtualSessionList.js'

Expand Down Expand Up @@ -184,10 +185,24 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare

// i18n.language is a stable per-locale key; depending on `t` (which
// changes identity on most renders) would rebuild rows constantly.
const sessionForest = useMemo(() => buildSessionForest(recentSessions ?? []), [recentSessions])
const treeNodes = useMemo(() => {
const nodes = new Map<string, SessionTreeNode>()
const visit = (node: SessionTreeNode): void => {
nodes.set(node.session.sessionUuid, node)
for (const child of node.children) visit(child)
}
for (const root of sessionForest) visit(root)
return nodes
}, [sessionForest])
const buckets = useMemo(
() => (recentSessions ? bucketByDate(recentSessions, looseTranslator(t)) : []),
() =>
bucketByDate(
sessionForest.map((node) => node.session),
looseTranslator(t),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[recentSessions, i18n.language],
[sessionForest, i18n.language],
)
const totalSessions = pinnedSessions.length + (recentSessions?.length ?? 0)
const pinnedLabel = useMemo(
Expand Down Expand Up @@ -225,14 +240,8 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare
dataAttr: { 'data-bucket': bucket.key },
})
for (const s of bucket.sessions) {
out.push({
kind: 'session',
id: s.sessionUuid,
session: s,
showProject: true,
bucket: bucket.key,
headerId: `bucket-${bucket.key}`,
})
const node = treeNodes.get(s.sessionUuid)
if (node) appendTreeRows(out, node, bucket.key, `bucket-${bucket.key}`)
}
}
out.push({
Expand All @@ -243,7 +252,7 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare
total: totalSessions,
})
return out
}, [pinnedSessions, pinnedLabel, buckets, loadingMore, exhausted, totalSessions])
}, [pinnedSessions, pinnedLabel, buckets, treeNodes, loadingMore, exhausted, totalSessions])

return (
<div data-testid="library-landing" className="flex h-full flex-col overflow-hidden">
Expand All @@ -270,6 +279,33 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare
)
}

function appendTreeRows(
rows: SessionListRow[],
node: SessionTreeNode,
bucket: BucketKey,
headerId: string,
depth = 0,
ancestorIds: string[] = [],
): void {
rows.push({
kind: 'session',
id: node.session.sessionUuid,
session: node.session,
showProject: true,
bucket,
headerId,
treeDepth: depth,
treeAncestorIds: ancestorIds,
treeChildCount: node.children.length,
})
for (const child of node.children) {
appendTreeRows(rows, child, bucket, headerId, depth + 1, [
...ancestorIds,
node.session.sessionUuid,
])
}
}

function looseTranslator(t: ReturnType<typeof useTranslation>['t']): TranslateFn {
return (key) => (t as unknown as (k: string) => string)(key)
}
Expand Down
Loading