Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dc4233a
feat(app): session detail action row with Pin, Copy command, Resume
graydawnc Apr 29, 2026
b6d6f02
chore(app): delete dead HomeView component
graydawnc Apr 29, 2026
57bea17
test(app): e2e for session detail action buttons + pin persistence
graydawnc Apr 29, 2026
5f53a16
fix(app): copy command includes cd <cwd> prefix; e2e covers Copy comm…
graydawnc Apr 29, 2026
71e206a
feat(app): post-stack polish — sidebar shell, global library feed, da…
graydawnc Apr 29, 2026
38ae092
feat(app): scope-aware search, collapsible sections, overlay polish
graydawnc Apr 29, 2026
70d4306
fix(app): doSearch ignored scope when committing from Cmd+K overlay
graydawnc Apr 29, 2026
b9caf63
feat(app): SessionDetail header redesigned as single row
graydawnc Apr 29, 2026
0ad10c3
style(app): UX polish — sort menu, kbd hint, folder icon, alignment
graydawnc Apr 29, 2026
0e8eb61
style(app): unify top padding to pt-3 across panes
graydawnc Apr 29, 2026
4e74801
fix(app): refresh sidebar groups on sync; rework SessionDetail meta +…
graydawnc Apr 29, 2026
9da9e5f
style(app): cleaner pin icon, icon scale, status text, source filter …
graydawnc Apr 29, 2026
4104d5e
feat(app): sidebar density toggles + bottom-row layout
graydawnc Apr 29, 2026
43714dd
style(app): hide sidebar scrollbar; align settings gear with project …
graydawnc Apr 29, 2026
8a30af2
feat(app): Cmd+K overlay polish — recents in empty state, stable heig…
graydawnc Apr 29, 2026
7463b74
style(app): align SessionDetail back arrow with title first line
graydawnc Apr 29, 2026
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
35 changes: 18 additions & 17 deletions packages/app/e2e/agent-search.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { test, expect } from '@playwright/test'
import { launchApp, waitForSync, search, type AppContext } from './helpers/launch'
import { launchApp, waitForSync, type AppContext } from './helpers/launch'
import type { Page } from '@playwright/test'

async function searchInAgentMode(window: Page, query: string) {
const overlay = window.locator('[data-testid="search-overlay"]')
if (!(await overlay.isVisible().catch(() => false))) {
await window.locator('[data-testid="search-trigger"]').first().click()
}
const input = window.locator('[data-testid="search-overlay-input"]')
await expect(input).toBeVisible({ timeout: 3000 })
await window.locator('[data-testid="mode-agent"]').click()
await input.fill(query)
await input.press('Enter')
await expect(overlay).toBeHidden({ timeout: 2000 })
}

test.describe('Agent mode — success', () => {
let ctx: AppContext
Expand All @@ -16,9 +30,7 @@ test.describe('Agent mode — success', () => {
const { window } = ctx

await waitForSync(window)
await search(window, 'XYLOPHONE_CANARY_42')
await window.locator('[data-testid="mode-agent"]').click()
await window.locator('[data-testid="search-input"]').press('Enter')
await searchInAgentMode(window, 'XYLOPHONE_CANARY_42')

const card = window.locator('[data-testid="ai-answer-card"]')
await expect(card).toBeVisible({ timeout: 15000 })
Expand All @@ -37,18 +49,9 @@ test.describe('Agent mode — success', () => {
await expect(window.locator('text=Sources used')).toBeVisible({ timeout: 3000 })
})

test('mode toggle preserves query text', async () => {
const { window } = ctx
await expect(window.locator('[data-testid="search-input"]')).toHaveValue('XYLOPHONE_CANARY_42')
})

test('second query replaces previous answer', async () => {
const { window } = ctx

const compactInput = window.locator('[data-testid="search-input"]')
await compactInput.fill('another question')
await compactInput.press('Enter')

await searchInAgentMode(window, 'another question')
const answerText = window.locator('[data-testid="ai-answer-text"]')
await expect(answerText).toContainText('MOCK_ACP_RESPONSE_42', { timeout: 10000 })
})
Expand All @@ -69,9 +72,7 @@ test.describe('Agent mode — error', () => {
const { window } = ctx

await waitForSync(window)
await search(window, 'test query')
await window.locator('[data-testid="mode-agent"]').click()
await window.locator('[data-testid="search-input"]').press('Enter')
await searchInAgentMode(window, 'test query')

const card = window.locator('[data-testid="ai-answer-card"]')
await expect(card).toBeVisible({ timeout: 15000 })
Expand Down
6 changes: 2 additions & 4 deletions packages/app/e2e/fast-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test('home view shows title and session counts after sync', async () => {

await expect(window.locator('h1')).toContainText('AI Session Library')
await waitForSync(window)
await expect(window.locator('[data-testid="library-project-card"]').first()).toBeVisible()
await expect(window.locator('[data-testid="session-row"]').first()).toBeVisible({ timeout: 5000 })
})

test('search finds fixture content by canary keyword', async () => {
Expand Down Expand Up @@ -121,9 +121,7 @@ test('session page can submit a new search without returning home first', async
await window.locator('[data-testid="fragment-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible()

const input = window.locator('[data-testid="search-input"]')
await input.fill('TROMBONE_CANARY_99')
await input.press('Enter')
await search(window, 'TROMBONE_CANARY_99')

await expect(window.locator('[data-testid="session-detail"]')).toHaveCount(0)
await expect(window.locator('[data-testid="fragment-row"]').first()).toContainText('TROMBONE_CANARY_99')
Expand Down
6 changes: 4 additions & 2 deletions packages/app/e2e/helpers/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export async function waitForSync(window: Page) {
}

export async function search(window: Page, query: string) {
// Open overlay via ⌘K / Ctrl+K depending on platform
await window.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k')
const overlay = window.locator('[data-testid="search-overlay"]')
if (!(await overlay.isVisible().catch(() => false))) {
await window.locator('[data-testid="search-trigger"]').first().click()
}
const input = window.locator('[data-testid="search-overlay-input"]')
await expect(input).toBeVisible({ timeout: 3000 })
await input.fill(query)
Expand Down
5 changes: 4 additions & 1 deletion packages/app/e2e/home-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ test.afterAll(async () => {
})

async function openOverlayAndType(ctx: AppContext, query: string) {
await ctx.window.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k')
const overlay = ctx.window.locator('[data-testid="search-overlay"]')
if (!(await overlay.isVisible().catch(() => false))) {
await ctx.window.locator('[data-testid="search-trigger"]').first().click()
}
const input = ctx.window.locator('[data-testid="search-overlay-input"]')
await expect(input).toBeVisible({ timeout: 3000 })
await input.fill(query)
Expand Down
3 changes: 2 additions & 1 deletion packages/app/e2e/project-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ test('changing sort order reloads sessions', async () => {

const recentFirst = await window.locator('[data-testid="session-row"]').first().getAttribute('data-session-uuid')

await window.locator('[data-testid="project-sort"]').selectOption('oldest')
await window.locator('[data-testid="project-sort"]').click()
await window.getByRole('menuitem', { name: 'Oldest' }).click()
await expect(window.locator('[data-testid="session-row"]').first()).toBeVisible({ timeout: 5000 })

const oldestFirst = await window.locator('[data-testid="session-row"]').first().getAttribute('data-session-uuid')
Expand Down
42 changes: 42 additions & 0 deletions packages/app/e2e/session-detail.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test'
import { launchApp, waitForSync, type AppContext } from './helpers/launch'

let ctx: AppContext

test.beforeAll(async () => {
ctx = await launchApp()
})

test.afterAll(async () => {
await ctx?.cleanup()
})

test('session detail shows Pin, action menu (Copy ID + Copy command), Resume', async () => {
const { window } = ctx
await waitForSync(window)

await window.locator('[data-testid="sidebar-project-row"]').first().click()
await window.locator('[data-testid="session-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible({ timeout: 5000 })

await expect(window.locator('[data-testid="pin-button"]')).toBeVisible()
await expect(window.locator('[data-testid="detail-resume"]')).toBeVisible()

await window.locator('[data-testid="detail-actions-menu"] button').first().click()
await expect(window.getByRole('menuitem', { name: 'Copy session ID' })).toBeVisible()
await expect(window.getByRole('menuitem', { name: /Copy resume command/ })).toBeVisible()
})

test('pinning from session detail persists', async () => {
const { window } = ctx
await waitForSync(window)

await window.locator('[data-testid="sidebar-project-row"]').first().click()
await window.locator('[data-testid="session-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible({ timeout: 5000 })

const pinButton = window.locator('[data-testid="session-detail"] [data-testid="pin-button"]')
const initialState = await pinButton.getAttribute('data-pinned')
await pinButton.click()
await expect(pinButton).toHaveAttribute('data-pinned', initialState === '1' ? '0' : '1', { timeout: 2000 })
})
4 changes: 4 additions & 0 deletions packages/app/src/main/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export interface AgentsConfig {
defaultSearchSort?: 'relevance' | 'newest' | 'oldest'
/** Preferred terminal app for session resume (e.g. "iTerm2", "Warp"). Auto-detected if unset. */
terminal?: string
/** Show colored source dots in sidebar project rows (default: true) */
sidebarShowSourceDots?: boolean
/** Show session count in sidebar project rows (default: true) */
sidebarShowSessionCount?: boolean
/** Built-in Open Agent SDK configuration */
sdkAgent?: SdkAgentConfig
/** Custom agent definitions (extend beyond builtins) */
Expand Down
17 changes: 11 additions & 6 deletions packages/app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Worker } from 'node:worker_threads'
import {
getDB, wasNewDb, getInitialUserVersion, Syncer, SpoolWatcher,
searchFragments, searchSessionPreview, listRecentSessions, getSessionWithMessages, getStatus,
pinSession, unpinSession, getPinnedUuids,
pinSession, unpinSession, getPinnedUuids, listPinnedSessions,
listProjectGroups, listSessionsByIdentity, listPinnedSessionsByIdentity,
} from '@spool-lab/core'
import type { FragmentResult, SessionSource, ListSessionsByIdentityOptions } from '@spool-lab/core'
Expand Down Expand Up @@ -86,10 +86,10 @@ const searchCache = new SearchCache()
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
title: isDevMode ? 'Spool DEV' : 'Spool',
width: 960,
height: 620,
width: 1180,
height: 780,
minWidth: 800,
minHeight: 480,
minHeight: 520,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#141410' : '#FAFAF8',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
Expand Down Expand Up @@ -234,8 +234,8 @@ app.on('window-all-closed', () => {

// ── IPC Handlers ──────────────────────────────────────────────────────────────

ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyPinned }: { query: string; limit?: number; source?: string; onlyPinned?: boolean }) => {
const cacheKey = `${source ?? 'all'}|${limit}|${onlyPinned ? 'pinned' : 'full'}|${query}`
ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyPinned, identityKey }: { query: string; limit?: number; source?: string; onlyPinned?: boolean; identityKey?: string }) => {
const cacheKey = `${source ?? 'all'}|${identityKey ?? 'any'}|${limit}|${onlyPinned ? 'pinned' : 'full'}|${query}`
if (!isSyncActive) {
const cached = searchCache.get(cacheKey)
if (cached) return cached
Expand All @@ -248,6 +248,7 @@ ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyPinned }: {
limit,
...(sessionSource ? { source: sessionSource } : {}),
...(onlyPinned ? { onlyPinned: true } : {}),
...(identityKey ? { identityKey } : {}),
}).map(f => ({ ...f, kind: 'fragment' as const }))

if (!isSyncActive) {
Expand Down Expand Up @@ -309,6 +310,10 @@ ipcMain.handle('spool:get-pinned-uuids', () => {
return getPinnedUuids(db)
})

ipcMain.handle('spool:list-pinned-sessions', () => {
return listPinnedSessions(db)
})

ipcMain.handle('spool:list-pinned-sessions-by-identity', (_e, { identityKey }: { identityKey: string }) => {
return listPinnedSessionsByIdentity(db, identityKey)
})
Expand Down
9 changes: 7 additions & 2 deletions packages/app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface AgentsConfig {
defaultAgent?: string
defaultSearchSort?: SearchSortOrder
terminal?: string
sidebarShowSourceDots?: boolean
sidebarShowSessionCount?: boolean
sdkAgent?: SdkAgentConfig
customAgents?: Record<string, {
name?: string
Expand All @@ -41,8 +43,8 @@ export interface AgentsConfig {
export type SpoolAPI = typeof api

const api = {
search: (query: string, limit?: number, source?: string, onlyPinned?: boolean): Promise<SearchResult[]> =>
ipcRenderer.invoke('spool:search', { query, limit, source, onlyPinned }),
search: (query: string, limit?: number, source?: string, onlyPinned?: boolean, identityKey?: string): Promise<SearchResult[]> =>
ipcRenderer.invoke('spool:search', { query, limit, source, onlyPinned, identityKey }),

searchPreview: (query: string, limit?: number, source?: string): Promise<SearchResult[]> =>
ipcRenderer.invoke('spool:search-preview', { query, limit, source }),
Expand Down Expand Up @@ -71,6 +73,9 @@ const api = {
getPinnedUuids: (): Promise<string[]> =>
ipcRenderer.invoke('spool:get-pinned-uuids'),

listPinnedSessions: (): Promise<Session[]> =>
ipcRenderer.invoke('spool:list-pinned-sessions'),

listPinnedSessionsByIdentity: (identityKey: string): Promise<Session[]> =>
ipcRenderer.invoke('spool:list-pinned-sessions-by-identity', { identityKey }),

Expand Down
Loading