diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1b90c95cb0..2e54bbf8cc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,12 +7,12 @@ "main": "dist/main/main.js", "scripts": { "start": "electron .", - "dev": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/runtime run build && npm --workspace @maka/ui run build && npm run build && electron .", + "dev": "node scripts/dev.mjs", "dev:hmr": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/runtime run build && npm --workspace @maka/ui run build && npm run build:main && npm run build:preload && node scripts/dev-hmr.mjs", "storybook": "storybook dev -p 6006 -c .storybook", "build-storybook": "storybook build -c .storybook --output-dir storybook-static", "build": "npm run build:main && npm run build:preload && npm run build:renderer", - "clean:main": "rm -rf dist/main tsconfig.main.tsbuildinfo", + "clean:main": "node ../../scripts/clean-paths.mjs dist/main tsconfig.main.tsbuildinfo", "build:main": "tsc -p tsconfig.main.json", "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:renderer": "vite build", diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs new file mode 100644 index 0000000000..4a66e43550 --- /dev/null +++ b/apps/desktop/scripts/dev.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * Dev launcher with PARALLEL + INCREMENTAL builds. + * + * Uses `tsc --build` for library packages so the compiler skips + * unchanged sub-projects via .tsbuildinfo (incremental). + * + * Dependency graph (→ compiles after): + * core ─┬→ storage + * ├→ runtime + * └→ ui + * + * libs (tsc --build tsconfig.lib.json) ─── covers core+storage+runtime+ui + * preload (esbuild) ─── parallel, no tsc dependency + * main (esbuild) ─── fast app bundle for Electron + * Vite dev server + Electron ─── fork + */ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); +const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); +const ON_WINDOWS = process.platform === 'win32'; +const TSC_CLI = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); +const ESBUILD_CLI = join(REPO_ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild'); + +// ── helpers ────────────────────────────────────────────────────────────────── + +function log(label, msg) { + const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false }); + console.log(`[${ts}][${label}] ${msg}`); +} + +function runNodeTool(dir, script, args) { + return new Promise((resolve_, reject_) => { + const child = spawn(process.execPath, [script, ...args], { + cwd: dir, + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.on('exit', (code) => { + if (code === 0) resolve_(); + else reject_(new Error(`"${script} ${args.join(' ')}" exited with code ${code}`)); + }); + child.on('error', reject_); + }); +} + +function resolveElectronBin() { + for (let dir = DESKTOP_DIR; ; dir = dirname(dir)) { + const exe = ON_WINDOWS + ? join(dir, 'node_modules', 'electron', 'dist', 'electron.exe') + : join(dir, 'node_modules', '.bin', 'electron'); + if (existsSync(exe)) return exe; + if (dirname(dir) === dir) return 'electron'; + } +} + +// ── build phases ───────────────────────────────────────────────────────────── + +const TIMER_START = Date.now(); + +// Phase 1: all library packages via `tsc --build` (single process, shared +// .tsbuildinfo, sub-project incremental detection). Also runs preload +// (esbuild is fast) in parallel since it has no tsc dependency. +log('build', 'libraries — starting (tsc --build + preload)'); +await Promise.all([ + runNodeTool(REPO_ROOT, TSC_CLI, ['--build', 'tsconfig.lib.json']).then( + () => log('build', 'libraries (all) — done'), + (e) => { log('build', `libraries — FAILED: ${e.message}`); throw e; }, + ), + runNodeTool(DESKTOP_DIR, ESBUILD_CLI, ['src/preload/preload.ts', '--bundle', '--platform=node', '--format=cjs', '--outfile=dist/preload/preload.cjs', '--external:electron']).then( + () => log('build', 'preload — done'), + (e) => { log('build', `preload — FAILED: ${e.message}`); throw e; }, + ), +]); + +// Phase 2: main — esbuild bundle for dev startup. The full +// tsconfig.main.json still compiles tests for `npm test` and typechecks +// main-process code in verification commands. +log('build', 'main — starting'); +await runNodeTool(DESKTOP_DIR, ESBUILD_CLI, ['src/main/main.ts', '--bundle', '--platform=node', '--format=esm', '--packages=external', '--outfile=dist/main/main.js', '--external:electron']); +log('build', 'main — done'); + +const BUILD_MS = Date.now() - TIMER_START; +log('build', `all builds finished in ${(BUILD_MS / 1000).toFixed(1)}s`); + +// ── Vite dev server + Electron ─────────────────────────────────────────────── + +process.chdir(DESKTOP_DIR); +log('vite', 'starting dev server...'); +const server = await createServer(); +await server.listen(); +server.printUrls(); + +const devUrl = server.resolvedUrls?.local?.[0]?.replace(/\/$/, ''); +if (!devUrl) { + console.error('[dev] vite did not report a local URL; aborting.'); + await server.close(); + process.exit(1); +} + +log('electron', `launching against ${devUrl} (renderer HMR live)`); +const electron = spawn(resolveElectronBin(), ['.', ...process.argv.slice(2)], { + cwd: DESKTOP_DIR, + stdio: 'inherit', + env: { ...process.env, VITE_DEV_SERVER_URL: devUrl }, +}); + +let shuttingDown = false; +async function shutdown(code, options = {}) { + if (shuttingDown) return; + shuttingDown = true; + if (options.killElectron !== false) { + await terminateProcessTree(electron); + } + await server.close().catch(() => {}); + process.exit(code); +} + +function terminateProcessTree(child) { + if (child.exitCode !== null || child.killed) return Promise.resolve(); + if (ON_WINDOWS && child.pid) { + return new Promise((resolve_) => { + const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + killer.on('exit', () => resolve_()); + killer.on('error', () => resolve_()); + }); + } + child.kill('SIGTERM'); + return Promise.resolve(); +} + +electron.on('exit', (code) => shutdown(code ?? 0, { killElectron: false })); +electron.on('error', (err) => { + console.error(`[dev] failed to start Electron: ${err.message}`); + shutdown(1); +}); +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); diff --git a/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts b/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts new file mode 100644 index 0000000000..b699d62156 --- /dev/null +++ b/apps/desktop/src/main/__tests__/dev-startup-contract.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +test('dev launcher bundles app main sources without compiling main-process tests', async () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const devScript = await readFile(join(desktopRoot, 'scripts', 'dev.mjs'), 'utf8'); + assert.match(devScript, /esbuild/); + assert.match(devScript, /src\/main\/main\.ts/); + assert.match(devScript, /--bundle/); + assert.match(devScript, /--packages=external/); + assert.doesNotMatch(devScript, /\['tsc', '-p'/); + assert.doesNotMatch(devScript, /tsconfig\.main\.app\.json/); +}); + +test('dev launcher tears down the Electron process tree on Windows', async () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const devScript = await readFile(join(desktopRoot, 'scripts', 'dev.mjs'), 'utf8'); + + assert.match(devScript, /taskkill/); + assert.match(devScript, /\/T/); + assert.match(devScript, /\/F/); +}); diff --git a/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts b/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts new file mode 100644 index 0000000000..6abff4e443 --- /dev/null +++ b/apps/desktop/src/main/__tests__/startup-loading-shell-contract.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +test('startup onboarding loading slot paints visible skeleton chrome', () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const css = readFileSync(join(desktopRoot, 'src', 'renderer', 'styles', 'onboarding.css'), 'utf8'); + const block = css.match(/\.maka-onboarding-loading\s*\{[\s\S]*?\n\}/)?.[0] ?? ''; + + assert.match(block, /background:\s*var\(--foreground-5\)/); + assert.match(block, /border:\s*1px solid var\(--border-strong\)/); + assert.match(block, /box-shadow:/); + assert.match(css, /var\(--foreground-8\)/); + assert.match(css, /\.maka-onboarding-loading::before\s*\{/); + assert.match(css, /\.maka-onboarding-loading::after\s*\{/); +}); + +test('index.html paints an inline preload skeleton before React mounts', () => { + const cwd = process.cwd(); + const desktopRoot = cwd.endsWith(join('apps', 'desktop')) ? cwd : join(cwd, 'apps', 'desktop'); + const html = readFileSync(join(desktopRoot, 'src', 'renderer', 'index.html'), 'utf8'); + + // #root ships a non-empty, accessible skeleton so there is no blank window + // during the CSS + JS loading gap; createRoot() replaces it on mount. + assert.match(html, /
\s*
{ + const element = document.querySelector(selector); + if (!element) return { selector, present: false }; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + selector, + present: true, + textLength: (element.textContent ?? '').trim().length, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + color: style.color, + backgroundColor: style.backgroundColor, + }; + }), + centerElement: (() => { + const element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2); + if (!element) return null; + const style = getComputedStyle(element); + return { + tagName: element.tagName, + className: typeof element.className === 'string' ? element.className : '', + text: (element.textContent ?? '').trim().slice(0, 120), + color: style.color, + backgroundColor: style.backgroundColor, + }; + })(), activeElementInSearchModal: Boolean(document.activeElement && document.activeElement.closest && document.activeElement.closest('.maka-search-modal')), activeElement: document.activeElement ? { tagName: document.activeElement.tagName, diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 61a48eae87..5e5f9cc843 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -8,9 +8,78 @@ content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" /> Maka + -
+
+
+
diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 3e78594f71..2089be98a3 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -615,7 +615,45 @@ default EmptyChatHero so the user doesn't see prompt suggestions flash before the state-routed hero mounts. */ .maka-onboarding-loading { - min-height: 120px; + position: relative; + width: min(620px, calc(100% - 48px)); + min-height: 132px; + margin: auto; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: var(--radius-modal); + background: var(--foreground-5); + box-shadow: + 0 0 0 1px oklch(from var(--foreground) l c h / 0.03), + 0 10px 24px oklch(from var(--foreground) l c h / 0.06); +} + +.maka-onboarding-loading::before, +.maka-onboarding-loading::after { + content: ""; + position: absolute; + left: 24px; + right: 24px; + border-radius: var(--radius-pill); + background: linear-gradient( + 90deg, + var(--foreground-8), + oklch(from var(--foreground) l c h / 0.16), + var(--foreground-8) + ); +} + +.maka-onboarding-loading::before { + top: 34px; + width: min(280px, calc(100% - 48px)); + height: 16px; +} + +.maka-onboarding-loading::after { + top: 66px; + width: min(460px, calc(100% - 48px)); + height: 12px; + opacity: 0.7; } .maka-list-row-name { diff --git a/apps/desktop/tsconfig.main.json b/apps/desktop/tsconfig.main.json index 3b07a0c2cc..f99e63b7da 100644 --- a/apps/desktop/tsconfig.main.json +++ b/apps/desktop/tsconfig.main.json @@ -5,7 +5,8 @@ "outDir": "dist", "module": "ESNext", "moduleResolution": "Bundler", - "types": ["node", "electron"] + "types": ["node", "electron"], + "incremental": true }, "include": ["src/main"] } diff --git a/packages/core/package.json b/packages/core/package.json index 3045b22f43..3df1eb75ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,7 @@ "./usage-stats/pricing": "./dist/usage-stats/pricing.js" }, "scripts": { - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && node --test \"dist/**/*.test.js\"" diff --git a/packages/headless/package.json b/packages/headless/package.json index 7528618f8a..e96cb080a9 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -38,7 +38,7 @@ "maka-headless": "./dist/cli.js" }, "scripts": { - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && node ../../scripts/run-headless-tests.mjs" diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 46c2d3a476..34db6bbbca 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -42,7 +42,7 @@ "./ai-sdk-flow": "./dist/ai-sdk-flow.js" }, "scripts": { - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && node --test \"dist/**/*.test.js\"" diff --git a/packages/storage/package.json b/packages/storage/package.json index 3e0111caa3..77c974c5a7 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -7,7 +7,7 @@ "types": "./dist/index.d.ts", "exports": "./dist/index.js", "scripts": { - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && node --test \"dist/**/*.test.js\"" diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index 1ac5d13ca5..1c62f046b6 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -266,14 +266,23 @@ describe('FileArtifactStore', () => { } }); - test('path guard rejects symlink escapes from artifact root', async () => { + test('path guard rejects symlink escapes from artifact root', async (t) => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-store-')); const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-outside-')); try { const artifactRoot = join(workspaceRoot, 'artifacts'); await mkdir(artifactRoot, { recursive: true }); await writeFile(join(outsideRoot, 'secret.txt'), 'secret', 'utf8'); - await symlink(outsideRoot, join(artifactRoot, 'session-1')); + try { + await symlink(outsideRoot, join(artifactRoot, 'session-1')); + } catch (error) { + const code = (error as { code?: unknown }).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + t.skip('Windows symlink creation requires elevated privileges or Developer Mode'); + return; + } + throw error; + } assert.deepEqual( await resolveArtifactPath({ artifactRoot, relativePath: 'session-1/secret.txt' }), diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index b4478b0b6d..910bd07131 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -511,6 +511,67 @@ describe('FileSessionStore CRUD', () => { }); }); + test('list derives previews for sessions outside the first three without full detail reads', async () => { + await withStore(async (store, workspaceRoot) => { + for (let index = 0; index < 5; index += 1) { + const sessionId = `preview-tail-${index}`; + await mkdir(join(workspaceRoot, 'sessions', sessionId), { recursive: true }); + await writeFile( + join(workspaceRoot, 'sessions', sessionId, 'session.jsonl'), + [ + JSON.stringify(makeRawHeader({ + id: sessionId, + workspaceRoot, + name: `Preview tail ${index}`, + lastMessageAt: 100 - index, + })), + JSON.stringify({ type: 'assistant', id: `a-${index}`, turnId: `t-${index}`, ts: 100 - index, text: `tail preview ${index}`, modelId: 'fake' }), + '', + ].join('\n'), + 'utf8', + ); + } + + const summaries = await store.list(); + + assert.equal(summaries.length, 5); + assert.deepEqual(summaries.map((summary) => summary.lastMessagePreview), [ + 'tail preview 0', + 'tail preview 1', + 'tail preview 2', + 'tail preview 3', + 'tail preview 4', + ]); + }); + }); + + test('list accepts unusually large but valid session headers', async () => { + await withStore(async (store, workspaceRoot) => { + const sessionId = 'large-valid-header'; + await mkdir(join(workspaceRoot, 'sessions', sessionId), { recursive: true }); + await writeFile( + join(workspaceRoot, 'sessions', sessionId, 'session.jsonl'), + [ + JSON.stringify(makeRawHeader({ + id: sessionId, + workspaceRoot, + name: 'Large header', + labels: Array.from({ length: 700 }, (_, index) => `label-${index}`), + lastMessageAt: 10, + })), + JSON.stringify({ type: 'assistant', id: 'a1', turnId: 't1', ts: 10, text: 'large header survives', modelId: 'fake' }), + '', + ].join('\n'), + 'utf8', + ); + + const [summary] = await store.list(); + + assert.equal(summary?.id, sessionId); + assert.equal(summary?.lastMessagePreview, 'large header survives'); + }); + }); + test('summary lastMessageAt does not move backwards when copying older visible messages', async () => { await withStore(async (store, workspaceRoot) => { const sessionId = 'newer-header-with-old-copy'; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 15b2154f82..6a0e575546 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { deriveTurnRecords, isPermissionMode, isSessionBlockedReason, isSessionStatus, normalizeUserSessionName } from '@maka/core'; @@ -36,6 +36,9 @@ export function createSessionStore(workspaceRoot: string): SessionStore { } class FileSessionStore implements SessionStore { + private static readonly HEADER_BUDGET = 8192; + private static readonly MAX_HEADER_BYTES = 1024 * 1024; + private static readonly TAIL_PREVIEW_BUDGET = 64 * 1024; private readonly sessionsRoot: string; private readonly writeQueues = new Map>(); @@ -99,31 +102,60 @@ class FileSessionStore implements SessionStore { async list(filter?: SessionListFilter): Promise { await mkdir(this.sessionsRoot, { recursive: true }); const entries = await import('node:fs/promises').then((fs) => fs.readdir(this.sessionsRoot, { withFileTypes: true })); - const summaries: SessionSummary[] = []; + + // Phase 1: read each header plus a bounded tail preview. That keeps + // list() proportional to the number of sessions rather than full + // transcript size, while preserving sidebar previews and timestamp + // fallback for sessions outside the top few. + const withHeaders: Array<{ id: string; header: SessionHeader; previewMessages: StoredMessage[] }> = []; for (const entry of entries) { if (!entry.isDirectory()) continue; if (!isSafeSessionId(entry.name)) continue; try { - const { header, messages } = await this.readFileParts(entry.name); + const header = await this.readHeaderOnly(entry.name); if (filter?.isArchived !== undefined && header.isArchived !== filter.isArchived) continue; if (filter?.isFlagged !== undefined && header.isFlagged !== filter.isFlagged) continue; if (filter?.labelSlug && !header.labels.includes(filter.labelSlug)) continue; - summaries.push(toSummary(header, messages)); + const previewMessages = await this.readTailPreviewMessages(entry.name).catch(() => []); + withHeaders.push({ id: entry.name, header, previewMessages }); } catch { // Ignore malformed session folders in the sidebar. } } - // Secondary key on `id` (lexicographic) so sessions with identical - // lastMessageAt always sort in the same order — fixtures with + + // Secondary key on id (lexicographic) so sessions with identical + // lastMessageAt always sort in the same order - fixtures with // multiple sessions seeded at the same frozen timestamp would // otherwise drift across runs based on filesystem readdir order // (PR108k-yj per @kenji visual-smoke determinism). Negligible cost // for real users; identical lastMessageAt is rare in production. - return summaries.sort((a, b) => { - const tsDelta = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); + withHeaders.sort((a, b) => { + const aLastMessageAt = maxTimestamp(a.header.lastMessageAt, latestVisibleMessageAt(a.previewMessages)); + const bLastMessageAt = maxTimestamp(b.header.lastMessageAt, latestVisibleMessageAt(b.previewMessages)); + const tsDelta = (bLastMessageAt ?? 0) - (aLastMessageAt ?? 0); if (tsDelta !== 0) return tsDelta; - return a.id.localeCompare(b.id); + return a.header.id.localeCompare(b.header.id); }); + + // Phase 2: full detail read only for the most recent 3 sessions. + // For those, keep only the last 10 messages as preview. Remaining + // sessions use the bounded tail preview from phase 1. + const TOP_N = 3; + const summaries: SessionSummary[] = []; + for (let i = 0; i < withHeaders.length; i++) { + const { header, previewMessages } = withHeaders[i]; + let messages: StoredMessage[] = previewMessages.slice(-10); + if (i < TOP_N) { + try { + const result = await this.readFilePartsUnlocked(header.id); + messages = result.messages.slice(-10); + } catch { + // Fall through to the bounded tail preview from phase 1. + } + } + summaries.push(toSummary(header, messages)); + } + return summaries; } async readHeader(sessionId: string): Promise { @@ -235,6 +267,66 @@ class FileSessionStore implements SessionStore { return join(this.sessionDir(sessionId), 'session.jsonl'); } + private async readHeaderOnly(sessionId: string): Promise { + // Fast path: read only the first JSON line (the header) without + // parsing any message payload. Used by list() to quickly scan + // all sessions before deciding which ones need detail reads. + const path = this.sessionPath(sessionId); + const handle = await open(path, 'r'); + try { + const chunks: Buffer[] = []; + let offset = 0; + while (offset < FileSessionStore.MAX_HEADER_BYTES) { + const buf = Buffer.alloc(Math.min( + FileSessionStore.HEADER_BUDGET, + FileSessionStore.MAX_HEADER_BYTES - offset, + )); + const { bytesRead } = await handle.read(buf, 0, buf.length, offset); + if (bytesRead === 0) break; + chunks.push(buf.subarray(0, bytesRead)); + const region = Buffer.concat(chunks).toString('utf8'); + const firstNl = region.indexOf('\n'); + if (firstNl !== -1) { + return migrateHeader(JSON.parse(region.slice(0, firstNl)) as StoredSessionHeader, sessionId); + } + offset += bytesRead; + } + throw new Error(`Session ${sessionId}: cannot find header line`); + } finally { + await handle.close(); + } + } + + private async readTailPreviewMessages(sessionId: string): Promise { + const path = this.sessionPath(sessionId); + const handle = await open(path, 'r'); + try { + const { size } = await handle.stat(); + const start = Math.max(0, size - FileSessionStore.TAIL_PREVIEW_BUDGET); + const length = size - start; + if (length <= 0) return []; + const buf = Buffer.alloc(length); + const { bytesRead } = await handle.read(buf, 0, length, start); + const text = buf.toString('utf8', 0, bytesRead); + const rawLines = text.split('\n'); + // The first tail line is either the header (start === 0) or a partial JSONL line. + const lines = rawLines.slice(1); + const completeLines = text.endsWith('\n') ? lines : lines.slice(0, -1); + const messages: StoredMessage[] = []; + for (const line of completeLines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line) as StoredMessage); + } catch { + // Tail previews are best-effort; full reads still surface durable corruption notes. + } + } + return messages; + } finally { + await handle.close(); + } + } + private async readFileParts(sessionId: string): Promise<{ header: SessionHeader; messages: StoredMessage[] }> { return this.readFilePartsUnlocked(sessionId); } diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json index b2d80aa402..dccdd421a0 100644 --- a/packages/storage/tsconfig.json +++ b/packages/storage/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "dist", "declaration": true, - "declarationMap": true + "declarationMap": true, + "composite": true }, - "include": ["src"] + "include": ["src"], + "references": [{ "path": "../core" }] } diff --git a/packages/ui/package.json b/packages/ui/package.json index ca2658b295..c3e1ffccf5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,7 @@ "./smooth-stream": "./dist/smooth-stream.js" }, "scripts": { - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && node --test \"dist/**/*.test.js\"" diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 41c736352c..114f810951 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,8 +5,10 @@ "outDir": "dist", "declaration": true, "declarationMap": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "composite": true }, "include": ["src"], - "exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"] + "exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"], + "references": [{ "path": "../core" }] } diff --git a/scripts/clean-paths.mjs b/scripts/clean-paths.mjs new file mode 100644 index 0000000000..9098724bd2 --- /dev/null +++ b/scripts/clean-paths.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { rm } from 'node:fs/promises'; + +const targets = process.argv.slice(2); +if (targets.length === 0) { + console.error('Usage: node scripts/clean-paths.mjs [path...]'); + process.exit(1); +} + +for (const target of targets) { + await rm(target, { recursive: true, force: true }); +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000000..b857af4a88 --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "files": [], + "references": [ + { "path": "packages/core" }, + { "path": "packages/storage" }, + { "path": "packages/runtime" }, + { "path": "packages/ui" } + ] +}