diff --git a/README.md b/README.md index c13a9159c..205c0c327 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Local browser UI for the [pi coding agent](https://github.com/earendil-works/pi) ## Features -- **Session workspace**: browse, resume, rename, export, and delete conversations grouped by project, with running state, context usage, cost, and compaction details. +- **Session workspace**: browse, resume, rename, pin, manually order pinned conversations, export, and delete conversations grouped by project, with running state, context usage, cost, and compaction details. - **Two ways to branch**: **New session** creates an independent session file from an earlier message; **Edit from here** creates a branch inside the current session. - **Project file tools**: browse and upload files, inspect Git diffs, and preview source, Markdown, images, audio, PDFs, and DOCX files with automatic refresh. - **Git worktrees**: switch checkouts from the sidebar while keeping sessions from the same repository grouped together. diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index a573dfef0..cba1700fd 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -60,12 +60,16 @@ export async function POST( return NextResponse.json({ success: true, data: result }); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const promptBusy = commandType === "prompt" + && !promptAccepted + && getRpcSession(id)?.isRunning() === true; return NextResponse.json({ - error: error instanceof Error ? error.message : String(error), + error: message, ...(commandType === "prompt" && !promptAccepted - ? { code: "prompt_rejected", accepted: false } + ? { code: promptBusy ? "prompt_busy" : "prompt_rejected", accepted: false } : {}), - }, { status: 500 }); + }, { status: promptBusy ? 409 : 500 }); } } diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index 0a9184adc..38274b1fb 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -65,7 +65,7 @@ export async function POST(req: Request) { // in sync so the new cwd is immediately readable via /api/files. Without this, // a file request under a brand-new cwd would 403 for up to the cache TTL. allowFileRoot(cwd); - invalidateSessionListCache(); + invalidateSessionListCache(session.sessionFile ? [session.sessionFile] : undefined); const state = await session.send({ type: "get_state" }) as { model?: { id: string; provider: string }; diff --git a/app/api/agent/running/route.ts b/app/api/agent/running/route.ts index 5581294b2..40fde366e 100644 --- a/app/api/agent/running/route.ts +++ b/app/api/agent/running/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getSessionListVersion } from "@/lib/session-reader"; +import { refreshSessionIndexInBackground } from "@/lib/session-index"; import { getCompletionNotificationSuppressedRpcSessionIds, getRunningRpcSessionIds, @@ -9,6 +10,7 @@ export const dynamic = "force-dynamic"; // GET /api/agent/running - Lightweight snapshot for visible-tab polling. export async function GET() { + refreshSessionIndexInBackground(); return NextResponse.json( { sessionListVersion: getSessionListVersion(), diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 4dd4874b8..167836286 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -27,6 +27,18 @@ import { } from "@/lib/file-upload"; import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; import { filePathFromApiSegments, samePath } from "@/lib/paths"; +import { createServerTiming } from "@/lib/server-timing"; +import { + createFileVersion, + fileVersionHeaders, + matchesIfModifiedSince, + matchesIfNoneMatch, + type FileVersion, +} from "@/lib/file-version"; +import { + documentPreviewCacheKey, + getOrCreateDocumentPreview, +} from "@/lib/document-preview-cache"; const IGNORED_NAMES = new Set([ "node_modules", ".git", ".next", "dist", "build", "__pycache__", @@ -43,6 +55,7 @@ const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024; const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; // Multipart boundaries and headers are not file bytes, but must be bounded too. const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024; +const MAX_DIRECTORY_VERSION_PATHS = 128; const EXT_TO_LANGUAGE: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", @@ -115,6 +128,10 @@ function parseUploadFileNames(value: unknown): string[] | null { return value; } +function directoryVersion(stat: fs.Stats): string { + return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -130,6 +147,34 @@ export async function POST( const { directory } = uploadDirectory; const type = request.nextUrl.searchParams.get("type") ?? "upload"; + if (type === "directory-versions") { + const body = await request.json().catch(() => null) as { paths?: unknown } | null; + const paths = parseUploadFileNames(body?.paths); + if (!paths || paths.length > MAX_DIRECTORY_VERSION_PATHS) { + return NextResponse.json( + { error: `paths must be an array of at most ${MAX_DIRECTORY_VERSION_PATHS} strings` }, + { status: 400 }, + ); + } + const root = new Set([directory]); + const versions: Record = {}; + for (const candidate of paths) { + if (!isFilePathAllowed(candidate, root)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + try { + if (!isExistingFilePathAllowed(candidate, root)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + const stat = fs.statSync(candidate); + versions[candidate] = stat.isDirectory() ? directoryVersion(stat) : null; + } catch { + versions[candidate] = null; + } + } + return NextResponse.json({ versions }); + } + if (type === "upload-check") { const body = await request.json().catch(() => null) as { fileNames?: unknown } | null; const fileNames = parseUploadFileNames(body?.fileNames); @@ -288,10 +333,26 @@ function getContentDisposition(filePath: string, asDownload = false): string { return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`; } -function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response { +function notModifiedResponse(request: Request, version: FileVersion): Response | null { + const ifNoneMatch = request.headers.get("if-none-match"); + const unchanged = ifNoneMatch + ? matchesIfNoneMatch(ifNoneMatch, version.etag) + : matchesIfModifiedSince(request.headers.get("if-modified-since"), version.lastModified); + if (!unchanged) return null; + return new Response(null, { status: 304, headers: fileVersionHeaders(version) }); +} + +function streamFile( + filePath: string, + stat: fs.Stats, + version: FileVersion, + contentType: string, + rangeHeader: string | null, + asDownload = false, +): Response { const headers: Record = { + ...Object.fromEntries(fileVersionHeaders(version)), "Content-Type": contentType, - "Cache-Control": "no-cache", "Accept-Ranges": "bytes", "Content-Disposition": getContentDisposition(filePath, asDownload), "X-Content-Type-Options": "nosniff", @@ -419,7 +480,9 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { - try { + const timing = createServerTiming(); + const response = await timing.time("file-handler", async () => { + try { const { path: segments } = await params; const filePath = filePathFromApiSegments(segments); const rawType = request.nextUrl.searchParams.get("type") ?? "list"; @@ -429,7 +492,7 @@ export async function GET( } const sessionId = request.nextUrl.searchParams.get("sessionId"); - const allowedRoots = await getAllowedFileRoots(); + const allowedRoots = await timing.time("auth", () => getAllowedFileRoots()); const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); const allowedBySessionReference = !allowedByRoot && @@ -441,7 +504,7 @@ export async function GET( let stat: fs.Stats | undefined; try { - stat = fs.statSync(filePath); + stat = timing.timeSync("stat", () => fs.statSync(filePath)); } catch { if (type !== "watch") { return NextResponse.json({ error: "Not found" }, { status: 404 }); @@ -456,6 +519,8 @@ export async function GET( return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + const version = createFileVersion(stat); + if (type === "read") { if (!stat?.isFile()) { return NextResponse.json({ error: "Not a file" }, { status: 400 }); @@ -465,40 +530,57 @@ export async function GET( if (stat.size > IMAGE_PREVIEW_MAX_BYTES) { return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 }); } - return streamFile(filePath, stat, imageMime, request.headers.get("range")); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + return streamFile(filePath, stat, version, imageMime, request.headers.get("range")); } const audioMime = getAudioMime(filePath); if (audioMime) { - return streamFile(filePath, stat, audioMime, request.headers.get("range")); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + return streamFile(filePath, stat, version, audioMime, request.headers.get("range")); } const videoMime = getVideoMime(filePath); if (videoMime) { - return streamFile(filePath, stat, videoMime, request.headers.get("range")); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + return streamFile(filePath, stat, version, videoMime, request.headers.get("range")); } const documentMime = getDocumentMime(filePath); if (documentMime) { - return streamFile(filePath, stat, documentMime, request.headers.get("range")); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + return streamFile(filePath, stat, version, documentMime, request.headers.get("range")); } if (stat.size > TEXT_PREVIEW_MAX_BYTES) { return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 }); } - const content = fs.readFileSync(filePath, "utf-8"); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + const content = timing.timeSync("file-read", () => fs.readFileSync(filePath, "utf-8")); const language = getLanguage(filePath); - return NextResponse.json({ content, language, size: stat.size }); + return timing.timeSync("serialize", () => NextResponse.json( + { content, language, size: stat.size, version }, + { headers: fileVersionHeaders(version) }, + )); } if (type === "download") { if (!stat?.isFile()) { return NextResponse.json({ error: "Not a file" }, { status: 400 }); } + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; const mime = getImageMime(filePath) || getAudioMime(filePath) || getVideoMime(filePath) || getDocumentMime(filePath) || "application/octet-stream"; - return streamFile(filePath, stat, mime, request.headers.get("range"), true); + return streamFile(filePath, stat, version, mime, request.headers.get("range"), true); } if (type === "meta") { if (!stat?.isFile()) { return NextResponse.json({ error: "Not a file" }, { status: 400 }); } + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; const imageMime = getImageMime(filePath); const audioMime = getAudioMime(filePath); const videoMime = getVideoMime(filePath); @@ -508,7 +590,8 @@ export async function GET( language: getLanguage(filePath), mime: imageMime || audioMime || videoMime || documentMime || "text/plain", previewKind: documentPreviewKind(filePath), - }); + version, + }, { headers: fileVersionHeaders(version) }); } if (type === "preview") { @@ -522,19 +605,27 @@ export async function GET( return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 }); } - const mammoth = await import("mammoth"); - const result = await mammoth.convertToHtml( - { path: filePath }, - { - externalFileAccess: false, - convertImage: mammoth.images.dataUri, - } - ); - const html = wrapDocxPreviewHtml(result.value, path.basename(filePath)); + const notModified = notModifiedResponse(request, version); + if (notModified) return notModified; + const cacheKey = documentPreviewCacheKey(filePath, version.etag); + const html = await timing.time("preview", () => getOrCreateDocumentPreview( + cacheKey, + async () => { + const mammoth = await import("mammoth"); + const result = await mammoth.convertToHtml( + { path: filePath }, + { + externalFileAccess: false, + convertImage: mammoth.images.dataUri, + }, + ); + return wrapDocxPreviewHtml(result.value, path.basename(filePath)); + }, + )); return new Response(html, { headers: { + ...Object.fromEntries(fileVersionHeaders(version)), "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache", "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", @@ -547,11 +638,7 @@ export async function GET( return NextResponse.json({ error: "Not a file" }, { status: 400 }); } let watcher: fs.FSWatcher | null = null; - let lastMtimeMs = stat?.mtimeMs ?? 0; - let lastCtimeMs = stat?.ctimeMs ?? 0; - let lastIno = stat?.ino ?? 0; - let lastSize = stat?.size ?? 0; - let lastExists = stat !== undefined; + let lastVersion = version; const stream = new ReadableStream({ start(controller) { const send = (eventName: string, data: Record) => { @@ -569,37 +656,40 @@ export async function GET( changedName != null && !samePath(path.join(watchedDirectory, changedName.toString()), filePath) ) return; + let nextVersion: FileVersion; try { - const s = fs.statSync(filePath); - // Some platforms emit watch events for file reads/attribute - // access. Ignore those or the client's refresh read loops. - if ( - lastExists - && s.mtimeMs === lastMtimeMs - && s.ctimeMs === lastCtimeMs - && s.ino === lastIno - && s.size === lastSize - ) return; - lastExists = true; - lastMtimeMs = s.mtimeMs; - lastCtimeMs = s.ctimeMs; - lastIno = s.ino; - lastSize = s.size; - send("change", { mtime: s.mtime.toISOString(), size: s.size }); + nextVersion = createFileVersion(fs.statSync(filePath)); } catch { - if (!lastExists) return; - lastExists = false; - send("change", { mtime: new Date().toISOString(), size: 0 }); + nextVersion = createFileVersion(); } + // Some platforms emit watch events for file reads/attribute + // access. Ignore those or the client's refresh read loops. + if (nextVersion.etag === lastVersion.etag) return; + lastVersion = nextVersion; + send("change", { + version: nextVersion, + size: nextVersion.size, + mtime: nextVersion.lastModified, + }); }); watcher.on("error", () => { try { watcher?.close(); } catch { /* ignore */ } watcher = null; try { controller.close(); } catch { /* ignore */ } }); - // The client snapshots only after this event, so emit it after the - // watcher exists to avoid dropping changes between those steps. - send("connected", { filePath }); + // Re-stat only after fs.watch exists. This closes the pre-watch + // snapshot window and gives the client one authoritative version + // from which to start its initial read. + try { + lastVersion = createFileVersion(fs.statSync(filePath)); + } catch { + lastVersion = createFileVersion(); + } + send("connected", { + version: lastVersion, + size: lastVersion.size, + mtime: lastVersion.lastModified, + }); } catch { send("error", { message: "Failed to watch file" }); controller.close(); @@ -626,7 +716,7 @@ export async function GET( // Avoid per-entry stat calls for normal files and directories. Symlinks and // filesystems without directory type information use the stat fallback. - const dirents = fs.readdirSync(filePath, { withFileTypes: true }); + const dirents = timing.timeSync("enumerate", () => fs.readdirSync(filePath, { withFileTypes: true })); const entries = dirents .filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) .flatMap((d) => { @@ -641,8 +731,14 @@ export async function GET( return a.name.localeCompare(b.name); }); - return NextResponse.json({ entries, path: filePath }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } + return timing.timeSync("serialize", () => NextResponse.json({ + entries, + path: filePath, + directoryVersion: directoryVersion(stat), + })); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } + }); + return timing.finish(response); } diff --git a/app/api/files/directory-versions-route.test.mjs b/app/api/files/directory-versions-route.test.mjs new file mode 100644 index 000000000..ae6c1072a --- /dev/null +++ b/app/api/files/directory-versions-route.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; +import { NextRequest } from "next/server.js"; + +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { GET, POST } = await jiti.import("./[...path]/route.ts"); + +function routeContext(filePath) { + return { params: Promise.resolve({ path: filePath.replace(/^\/+/, "").split("/") }) }; +} + +function urlFor(filePath, type) { + const encoded = filePath.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/"); + return `http://localhost/api/files/${encoded}?type=${type}`; +} + +function postVersions(root, paths) { + return new NextRequest(urlFor(root, "directory-versions"), { + method: "POST", + headers: { + host: "localhost", + origin: "http://localhost", + "Content-Type": "application/json", + }, + body: JSON.stringify({ paths }), + }); +} + +test("directory listings and bounded batch validation expose change-only versions", async (t) => { + const root = mkdtempSync(join(tmpdir(), "pi-web-directory-versions-")); + const nested = join(root, "nested"); + const outside = join(dirname(root), `${root.split("/").pop()}-outside`); + mkdirSync(nested); + mkdirSync(outside); + const previousAllowedRootsCache = globalThis.__piAllowedRootsCache; + globalThis.__piAllowedRootsCache = { + roots: new Set([root]), + expiresAt: Date.now() + 60_000, + }; + t.after(() => { + globalThis.__piAllowedRootsCache = previousAllowedRootsCache; + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + const listing = await GET( + new NextRequest(urlFor(nested, "list"), { headers: { host: "localhost" } }), + routeContext(nested), + ); + assert.equal(listing.status, 200); + assert.equal(typeof (await listing.json()).directoryVersion, "string"); + + const first = await POST(postVersions(root, [nested]), routeContext(root)); + assert.equal(first.status, 200); + const firstVersion = (await first.json()).versions[nested]; + assert.equal(typeof firstVersion, "string"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + writeFileSync(join(nested, "new.txt"), "new\n"); + const changed = await POST(postVersions(root, [nested]), routeContext(root)); + assert.equal(changed.status, 200); + assert.notEqual((await changed.json()).versions[nested], firstVersion); + + const denied = await POST(postVersions(root, [outside]), routeContext(root)); + assert.equal(denied.status, 403); + + const oversized = await POST( + postVersions(root, Array.from({ length: 129 }, () => nested)), + routeContext(root), + ); + assert.equal(oversized.status, 400); +}); diff --git a/app/api/files/file-version-route.test.mjs b/app/api/files/file-version-route.test.mjs new file mode 100644 index 000000000..abcdc0e11 --- /dev/null +++ b/app/api/files/file-version-route.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; +import { NextRequest } from "next/server.js"; +import JSZip from "jszip"; + +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { GET } = await jiti.import("./[...path]/route.ts"); + +function routeContext(filePath) { + return { params: Promise.resolve({ path: filePath.replace(/^\/+/, "").split("/") }) }; +} + +function request(filePath, type, headers = {}) { + const encoded = filePath.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/"); + return new NextRequest(`http://localhost/api/files/${encoded}?type=${type}`, { headers }); +} + +async function createDocx(text) { + const zip = new JSZip(); + zip.file("[Content_Types].xml", ` + + + + + `); + zip.file("_rels/.rels", ` + + + `); + zip.file("word/document.xml", ` + + ${text} + `); + return zip.generateAsync({ type: "nodebuffer" }); +} + +test("authorized file reads expose versions and honor ETag only after authorization", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "pi-web-file-version-route-")); + const filePath = join(directory, "sample.txt"); + const outsidePath = join(dirname(directory), `${directory.split("/").pop()}-outside.txt`); + writeFileSync(filePath, "first version"); + writeFileSync(outsidePath, "outside"); + + const previousAllowedRootsCache = globalThis.__piAllowedRootsCache; + globalThis.__piAllowedRootsCache = { + roots: new Set([directory]), + expiresAt: Date.now() + 60_000, + }; + t.after(() => { + globalThis.__piAllowedRootsCache = previousAllowedRootsCache; + rmSync(directory, { recursive: true, force: true }); + rmSync(outsidePath, { force: true }); + }); + + const first = await GET(request(filePath, "read"), routeContext(filePath)); + assert.equal(first.status, 200); + const firstBody = await first.json(); + const etag = first.headers.get("etag"); + assert.equal(firstBody.content, "first version"); + assert.equal(firstBody.version.exists, true); + assert.equal(firstBody.version.etag, etag); + assert.equal(first.headers.get("cache-control"), "private, no-cache"); + assert.ok(first.headers.get("last-modified")); + + const unchanged = await GET( + request(filePath, "read", { "If-None-Match": etag }), + routeContext(filePath), + ); + assert.equal(unchanged.status, 304); + assert.equal(await unchanged.text(), ""); + + const unchangedByDate = await GET( + request(filePath, "read", { "If-Modified-Since": first.headers.get("last-modified") }), + routeContext(filePath), + ); + assert.equal(unchangedByDate.status, 304); + + const etagTakesPrecedence = await GET( + request(filePath, "read", { + "If-None-Match": '"different"', + "If-Modified-Since": "Tue, 19 Jan 2038 03:14:07 GMT", + }), + routeContext(filePath), + ); + assert.equal(etagTakesPrecedence.status, 200); + + const denied = await GET( + request(outsidePath, "read", { "If-None-Match": etag }), + routeContext(outsidePath), + ); + assert.equal(denied.status, 403); + assert.equal(denied.headers.get("etag"), null); + + writeFileSync(filePath, "second version with a different size"); + const changed = await GET( + request(filePath, "read", { "If-None-Match": etag }), + routeContext(filePath), + ); + assert.equal(changed.status, 200); + const changedBody = await changed.json(); + assert.notEqual(changedBody.version.etag, etag); + assert.equal(changedBody.content, "second version with a different size"); + + const meta = await GET(request(filePath, "meta"), routeContext(filePath)); + const metaBody = await meta.json(); + assert.equal(meta.headers.get("etag"), changedBody.version.etag); + assert.equal(metaBody.version.etag, changedBody.version.etag); +}); + +test("DOCX preview cache remains versioned and cannot bypass revoked authorization", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "pi-web-docx-version-route-")); + const filePath = join(directory, "sample.docx"); + writeFileSync(filePath, await createDocx("cached preview")); + + const previousAllowedRootsCache = globalThis.__piAllowedRootsCache; + globalThis.__piAllowedRootsCache = { + roots: new Set([directory]), + expiresAt: Date.now() + 60_000, + }; + t.after(() => { + globalThis.__piAllowedRootsCache = previousAllowedRootsCache; + rmSync(directory, { recursive: true, force: true }); + }); + + const first = await GET(request(filePath, "preview"), routeContext(filePath)); + assert.equal(first.status, 200); + assert.match(await first.text(), /cached preview/); + const etag = first.headers.get("etag"); + assert.ok(etag); + + const cached = await GET(request(filePath, "preview"), routeContext(filePath)); + assert.equal(cached.status, 200); + assert.match(await cached.text(), /cached preview/); + assert.equal(cached.headers.get("etag"), etag); + + const unchanged = await GET( + request(filePath, "preview", { "If-None-Match": etag }), + routeContext(filePath), + ); + assert.equal(unchanged.status, 304); + + globalThis.__piAllowedRootsCache = { + roots: new Set(), + expiresAt: Date.now() + 60_000, + }; + const denied = await GET( + request(filePath, "preview", { "If-None-Match": etag }), + routeContext(filePath), + ); + assert.equal(denied.status, 403); + assert.equal(denied.headers.get("etag"), null); +}); diff --git a/app/api/files/watch-route.test.mjs b/app/api/files/watch-route.test.mjs index 01ec46596..2b41b2fad 100644 --- a/app/api/files/watch-route.test.mjs +++ b/app/api/files/watch-route.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; +import { mkdtempSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { createJiti } from "jiti"; +import { NextRequest } from "next/server.js"; const source = await readFile(new URL("./[...path]/route.ts", import.meta.url), "utf8"); const start = source.indexOf('if (type === "watch")'); @@ -8,19 +13,70 @@ const end = source.indexOf("// type === \"list\"", start); assert.notEqual(start, -1, "watch route not found"); assert.notEqual(end, -1, "watch route end not found"); const watchBlock = source.slice(start, end); +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { GET } = await jiti.import("./[...path]/route.ts"); + +function createSseCollector(body) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const events = []; + let buffer = ""; + let stopped = false; + + const pump = (async () => { + while (!stopped) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const event = block.match(/^event: (.+)$/m)?.[1]; + const data = block.match(/^data: (.+)$/m)?.[1]; + if (event && data) events.push({ event, data: JSON.parse(data) }); + } + } + })(); + + return { + async waitFor(predicate, timeoutMs = 5_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const match = events.find(predicate); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for SSE event; received ${JSON.stringify(events)}`); + }, + checkpoint() { + return events.length; + }, + async close() { + stopped = true; + await reader.cancel(); + await pump.catch(() => {}); + }, + events, + }; +} test("file watching survives same-path replacement", () => { assert.match(watchBlock, /watcher = fs\.watch\(watchedDirectory/); assert.match(watchBlock, /samePath\(path\.join\(watchedDirectory, changedName\.toString\(\)\), filePath\)/); - assert.match(watchBlock, /s\.ctimeMs === lastCtimeMs/); - assert.match(watchBlock, /s\.ino === lastIno/); - assert.match(watchBlock, /lastExists/); + assert.match(watchBlock, /nextVersion = createFileVersion\(fs\.statSync\(filePath\)\)/); + assert.match(watchBlock, /nextVersion\.etag === lastVersion\.etag/); + assert.match(watchBlock, /version: nextVersion/); }); test("a missing target can be watched after its parent is authorized", () => { assert.match(source, /if \(type !== "watch"\)[\s\S]*error: "Not found"/); assert.match(source, /const existingAuthorizationPath = stat \? filePath : path\.dirname\(filePath\)/); - assert.match(watchBlock, /lastExists = stat !== undefined/); + assert.match(watchBlock, /lastVersion = version/); }); test("connected is emitted only after the watcher exists", () => { @@ -29,3 +85,103 @@ test("connected is emitted only after the watcher exists", () => { assert.ok(watcher >= 0, "watcher creation missing"); assert.ok(connected > watcher, "connected emitted before watcher creation"); }); + +test("runtime watcher observes writes, atomic replacement, deletion, and recreation", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "pi-web-watch-route-")); + const filePath = join(directory, "watched.txt"); + const replacementPath = join(directory, "replacement.tmp"); + writeFileSync(filePath, "initial"); + + const previousAllowedRootsCache = globalThis.__piAllowedRootsCache; + globalThis.__piAllowedRootsCache = { + roots: new Set([directory]), + expiresAt: Date.now() + 60_000, + }; + t.after(() => { + globalThis.__piAllowedRootsCache = previousAllowedRootsCache; + rmSync(directory, { recursive: true, force: true }); + }); + + const pathSegments = filePath.replace(/^\/+/, "").split("/"); + const response = await GET( + new NextRequest(`http://localhost/api/files/${pathSegments.map(encodeURIComponent).join("/")}?type=watch`), + { params: Promise.resolve({ path: pathSegments }) }, + ); + assert.equal(response.status, 200); + assert.match(response.headers.get("Server-Timing") ?? "", /auth;dur=\d+\.\d/); + assert.ok(response.body); + + const collector = createSseCollector(response.body); + t.after(() => collector.close()); + const connected = await collector.waitFor((event) => event.event === "connected"); + assert.equal(connected.data.version.exists, true); + assert.equal(connected.data.version.size, 7); + assert.match(connected.data.version.etag, /^"fv1-[A-Za-z0-9_-]+"$/); + + let checkpoint = collector.checkpoint(); + writeFileSync(filePath, "ordinary-write"); + const ordinary = await collector.waitFor((event, index) => ( + index >= checkpoint && event.event === "change" && event.data.size === 14 + )); + assert.equal(ordinary.data.version.exists, true); + assert.equal(ordinary.data.version.size, 14); + + checkpoint = collector.checkpoint(); + writeFileSync(replacementPath, "atomic-replacement"); + renameSync(replacementPath, filePath); + const replacement = await collector.waitFor((event, index) => ( + index >= checkpoint && event.event === "change" && event.data.size === 18 + )); + assert.equal(replacement.data.version.exists, true); + assert.notEqual(replacement.data.version.etag, ordinary.data.version.etag); + + checkpoint = collector.checkpoint(); + unlinkSync(filePath); + const deleted = await collector.waitFor((event, index) => ( + index >= checkpoint && event.event === "change" && event.data.size === 0 + )); + assert.equal(deleted.data.version.exists, false); + + checkpoint = collector.checkpoint(); + writeFileSync(filePath, "recreated-file"); + const recreated = await collector.waitFor((event, index) => ( + index >= checkpoint && event.event === "change" && event.data.size === 14 + )); + assert.equal(recreated.data.version.exists, true); + assert.notEqual(recreated.data.version.etag, deleted.data.version.etag); +}); + +test("runtime watcher handshakes a missing file before its creation", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "pi-web-watch-missing-")); + const filePath = join(directory, "later.txt"); + const previousAllowedRootsCache = globalThis.__piAllowedRootsCache; + globalThis.__piAllowedRootsCache = { + roots: new Set([directory]), + expiresAt: Date.now() + 60_000, + }; + t.after(() => { + globalThis.__piAllowedRootsCache = previousAllowedRootsCache; + rmSync(directory, { recursive: true, force: true }); + }); + + const pathSegments = filePath.replace(/^\/+/, "").split("/"); + const response = await GET( + new NextRequest(`http://localhost/api/files/${pathSegments.map(encodeURIComponent).join("/")}?type=watch`), + { params: Promise.resolve({ path: pathSegments }) }, + ); + assert.equal(response.status, 200); + assert.ok(response.body); + + const collector = createSseCollector(response.body); + t.after(() => collector.close()); + const connected = await collector.waitFor((event) => event.event === "connected"); + assert.equal(connected.data.version.exists, false); + + const checkpoint = collector.checkpoint(); + writeFileSync(filePath, "created later"); + const created = await collector.waitFor((event, index) => ( + index >= checkpoint && event.event === "change" && event.data.version?.exists === true + )); + assert.equal(created.data.version.size, 13); + assert.notEqual(created.data.version.etag, connected.data.version.etag); +}); diff --git a/app/api/git/diff/route.ts b/app/api/git/diff/route.ts index e896d0d8c..dc10773dd 100644 --- a/app/api/git/diff/route.ts +++ b/app/api/git/diff/route.ts @@ -1,31 +1,39 @@ import { NextRequest, NextResponse } from "next/server"; import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access"; import { getGitFileDiff } from "@/lib/git-changes"; +import { createServerTiming } from "@/lib/server-timing"; export async function GET(request: NextRequest) { + const timing = createServerTiming(); try { const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? ""; + const includePatch = request.nextUrl.searchParams.get("probe") !== "1"; if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { - return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); + return timing.finish(NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 })); } if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) { - return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 }); + return timing.finish(NextResponse.json({ error: "path must be an absolute path" }, { status: 400 })); } - const allowedRoots = await getAllowedFileRoots(); + const allowedRoots = await timing.time("auth", () => getAllowedFileRoots()); if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); + return timing.finish(NextResponse.json({ error: "Access denied" }, { status: 403 })); } // The cwd must resolve inside an allowed root. The file itself may no // longer exist when Git reports it as deleted; getGitFileDiff verifies // that the requested path belongs to this repository and its status. if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); + return timing.finish(NextResponse.json({ error: "Access denied" }, { status: 403 })); } - return NextResponse.json(await getGitFileDiff(cwd, filePath)); + const result = await timing.time("git", () => getGitFileDiff(cwd, filePath, { includePatch })); + const response = timing.timeSync("serialize", () => NextResponse.json(result)); + return timing.finish(response); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return timing.finish(NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + )); } } diff --git a/app/api/git/status/route.ts b/app/api/git/status/route.ts index ead1792f7..77734b06b 100644 --- a/app/api/git/status/route.ts +++ b/app/api/git/status/route.ts @@ -2,34 +2,42 @@ import fs from "fs"; import { NextRequest, NextResponse } from "next/server"; import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access"; import { getGitStatus } from "@/lib/git-changes"; +import { createServerTiming } from "@/lib/server-timing"; export async function GET(request: NextRequest) { + const timing = createServerTiming(); try { const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; + const force = request.nextUrl.searchParams.get("force") === "1"; if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { - return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); + return timing.finish(NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 })); } - const allowedRoots = await getAllowedFileRoots(); + const allowedRoots = await timing.time("auth", () => getAllowedFileRoots()); if (!isFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); + return timing.finish(NextResponse.json({ error: "Access denied" }, { status: 403 })); } let stat: fs.Stats; try { - stat = fs.statSync(cwd); + stat = timing.timeSync("stat", () => fs.statSync(cwd)); } catch { - return NextResponse.json({ error: "Directory not found" }, { status: 404 }); + return timing.finish(NextResponse.json({ error: "Directory not found" }, { status: 404 })); } if (!stat.isDirectory()) { - return NextResponse.json({ error: "Not a directory" }, { status: 400 }); + return timing.finish(NextResponse.json({ error: "Not a directory" }, { status: 400 })); } if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); + return timing.finish(NextResponse.json({ error: "Access denied" }, { status: 403 })); } - return NextResponse.json(await getGitStatus(cwd)); + const result = await timing.time("git", () => getGitStatus(cwd, { force })); + const response = timing.timeSync("serialize", () => NextResponse.json(result)); + return timing.finish(response); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return timing.finish(NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + )); } } diff --git a/app/api/models/route.ts b/app/api/models/route.ts index e17f4cae6..8fb1f0da5 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -1,6 +1,6 @@ import { stat } from "fs/promises"; import { resolve } from "path"; -import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent"; +import { getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { loadModelsWithCache, @@ -11,6 +11,7 @@ import { import { resolveVisibleModels, selectInitialModelScope } from "@/lib/model-scope"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; import { projectTrustReloadOptions } from "@/lib/project-trust"; +import { createPiWebAgentSessionServices } from "@/lib/agent-session-services"; export const dynamic = "force-dynamic"; @@ -37,11 +38,11 @@ async function loadModels(cwd: string): Promise { // runs a repository's .pi/extensions factories, so honor project trust here // too (see lib/project-trust.ts, #236). const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir); - const services = await createAgentSessionServices({ + const services = await createPiWebAgentSessionServices({ cwd, agentDir, ...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}), - }); + }, { transientExtensions: true }); const modelError = services.modelRuntime.getError(); const settings: SettingsManager = services.settingsManager; // `enabledModels` supports globs and fuzzy patterns, so resolve it the same diff --git a/app/api/performance-timing-routes.test.mjs b/app/api/performance-timing-routes.test.mjs new file mode 100644 index 000000000..093d4970d --- /dev/null +++ b/app/api/performance-timing-routes.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const routeSources = new Map(await Promise.all([ + ["files", new URL("./files/[...path]/route.ts", import.meta.url)], + ["git-status", new URL("./git/status/route.ts", import.meta.url)], + ["git-diff", new URL("./git/diff/route.ts", import.meta.url)], + ["worktrees", new URL("./worktrees/route.ts", import.meta.url)], +].map(async ([name, url]) => [name, await readFile(url, "utf8")]))); + +test("critical file, Git, and worktree GET routes emit request-local Server-Timing", () => { + for (const [name, source] of routeSources) { + assert.match(source, /createServerTiming\(\)/, `${name} does not create request timing`); + assert.match(source, /timing\.finish\(/, `${name} does not attach request timing`); + } +}); + +test("file routes distinguish authorization and expensive file operations", () => { + const source = routeSources.get("files"); + assert.match(source, /timing\.time\("auth"/); + assert.match(source, /timing\.timeSync\("file-read"/); + assert.match(source, /timing\.timeSync\("enumerate"/); + assert.match(source, /timing\.time\("preview"/); +}); + +test("Git and worktree routes distinguish authorization, project, Git, and serialization", () => { + const status = routeSources.get("git-status"); + const diff = routeSources.get("git-diff"); + const worktrees = routeSources.get("worktrees"); + + for (const source of [status, diff]) { + assert.match(source, /timing\.time\("auth"/); + assert.match(source, /timing\.time\("git"/); + assert.match(source, /timing\.timeSync\("serialize"/); + } + assert.match(worktrees, /timing\.time\("auth"/); + assert.match(worktrees, /timing\.time\("project"/); + assert.match(worktrees, /timing\.time\("git"/); + assert.match(worktrees, /timing\.timeSync\("serialize"/); +}); diff --git a/app/api/project-trust/route.ts b/app/api/project-trust/route.ts index 81c033a5d..3c7c0957a 100644 --- a/app/api/project-trust/route.ts +++ b/app/api/project-trust/route.ts @@ -22,7 +22,12 @@ async function validateCwd(value: unknown): Promise< return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) }; } } catch { - return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) }; + return { + response: NextResponse.json( + { error: "Directory does not exist", code: "cwd_not_found" }, + { status: 400 }, + ), + }; } const allowedRoots = await getAllowedFileRoots(); diff --git a/app/api/session-order/route.ts b/app/api/session-order/route.ts new file mode 100644 index 000000000..be97ad144 --- /dev/null +++ b/app/api/session-order/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { MAX_PINNED_SESSIONS_PER_PROJECT } from "@/lib/session-order"; +import { + readSessionOrderPreferences, + writeProjectSessionOrder, +} from "@/lib/session-order-store"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + return NextResponse.json(readSessionOrderPreferences(getAgentDir()), { + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function PUT(req: Request) { + try { + const body = await req.json() as { projectKey?: unknown; pinnedSessionIds?: unknown }; + if (typeof body.projectKey !== "string" || body.projectKey.length === 0 || body.projectKey.length > 2048) { + return NextResponse.json({ error: "projectKey is required" }, { status: 400 }); + } + if (!Array.isArray(body.pinnedSessionIds) + || body.pinnedSessionIds.length > MAX_PINNED_SESSIONS_PER_PROJECT + || body.pinnedSessionIds.some((id) => typeof id !== "string" || id.length === 0 || id.length > 256)) { + return NextResponse.json({ error: "pinnedSessionIds must be a valid string array" }, { status: 400 }); + } + + const preferences = writeProjectSessionOrder( + getAgentDir(), + body.projectKey, + body.pinnedSessionIds as string[], + ); + return NextResponse.json(preferences, { + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + const status = error instanceof SyntaxError ? 400 : 500; + return NextResponse.json({ error: String(error) }, { status }); + } +} diff --git a/app/api/sessions/[id]/auto-name/route.ts b/app/api/sessions/[id]/auto-name/route.ts index 84b14adaf..c16e0feec 100644 --- a/app/api/sessions/[id]/auto-name/route.ts +++ b/app/api/sessions/[id]/auto-name/route.ts @@ -3,6 +3,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent"; import { generateSessionTitle } from "@/lib/session-title"; import { getRpcSession, startRpcSession } from "@/lib/rpc-manager"; import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader"; +import { invalidateParsedSession } from "@/lib/session-detail-cache"; export async function POST( _req: Request, @@ -34,7 +35,8 @@ export async function POST( } session.inner.setSessionName(result.title); - invalidateSessionListCache(); + invalidateParsedSession(filePath); + invalidateSessionListCache([filePath]); return NextResponse.json({ title: result.title, usage: result.usage ?? null }); } catch (error) { return NextResponse.json( diff --git a/app/api/sessions/[id]/context/route.ts b/app/api/sessions/[id]/context/route.ts index 1f048e269..aecde455b 100644 --- a/app/api/sessions/[id]/context/route.ts +++ b/app/api/sessions/[id]/context/route.ts @@ -1,45 +1,80 @@ import { NextResponse } from "next/server"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader"; import { getRpcSession } from "@/lib/rpc-manager"; +import { createServerTiming } from "@/lib/server-timing"; +import { + getParsedSessionSnapshot, + getSessionContextFromSnapshot, +} from "@/lib/session-detail-cache"; +import { + computeSessionContextStats, + computeSessionInputHistory, + paginateSessionContext, + parseSessionContextPageRequest, + SessionContextPageRequestError, +} from "@/lib/session-context-page"; export async function GET( req: Request, { params }: { params: Promise<{ id: string }> }, ) { + const timing = createServerTiming(); const { id } = await params; const url = new URL(req.url); - const leafId = url.searchParams.get("leafId") ?? undefined; + // An explicit empty leaf selects the empty branch; absence uses the active leaf. + const requestedLeafId = url.searchParams.has("leafId") ? url.searchParams.get("leafId") || null : undefined; const deferThinking = url.searchParams.has("deferThinking"); const deferToolResultImages = url.searchParams.has("deferMedia"); - // `tail` caps the ancestor chain returned (default 50); `before` rewinds the - // walk start to an older entry so the client can page upward without - // re-fetching the whole active branch. - const rawTail = Number(url.searchParams.get("tail")); - const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50; - const before = url.searchParams.get("before") ?? undefined; try { + const pageRequest = parseSessionContextPageRequest(url.searchParams); const rpc = getRpcSession(id); const liveRpc = rpc?.isAlive() ? rpc : undefined; - const filePath = liveRpc ? null : await resolveSessionPath(id); + const filePath = liveRpc + ? null + : await timing.time("resolve", () => resolveSessionPath(id)); if (!liveRpc && !filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); + return timing.finish(NextResponse.json({ error: "Session not found" }, { status: 404 })); } - const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(filePath!); - // `before` is the oldest entry already on the client; fetch its ancestors - // only (excludeLeaf) so prepending the page does not duplicate `before`. - const context = buildSessionContext(sm.getEntries() as never, before ?? leafId, { - deferThinking, - deferToolResultImages, - tail, - excludeLeaf: Boolean(before), - sessionId: id, - }); + const diskSnapshot = liveRpc + ? null + : await timing.time("parse", () => getParsedSessionSnapshot(filePath!)); + const manager = liveRpc?.inner.sessionManager; + const entries = manager?.getEntries() ?? diskSnapshot!.entries; + const leafId = requestedLeafId !== undefined ? requestedLeafId : (manager ? manager.getLeafId() : diskSnapshot!.leafId); + const contextOptions = { deferThinking, deferToolResultImages, sessionId: id }; + const fullContext = timing.timeSync("context", () => diskSnapshot + ? getSessionContextFromSnapshot( + diskSnapshot, + leafId, + contextOptions, + () => buildSessionContext(entries as never, leafId, contextOptions), + ) + : buildSessionContext(entries as never, leafId, contextOptions)); + const contextStats = computeSessionContextStats(fullContext); + const inputHistory = computeSessionInputHistory(fullContext); + const { context, page } = pageRequest + ? paginateSessionContext(fullContext, pageRequest) + : { + context: fullContext, + page: { + startIndex: 0, + endIndex: fullContext.messages.length, + totalMessages: fullContext.messages.length, + hasEarlier: false, + }, + }; - return NextResponse.json({ context, tail, before: before ?? null }); + const response = timing.timeSync("serialize", () => NextResponse.json({ + context, + page, + contextStats, + inputHistory, + })); + return timing.finish(response); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + const status = error instanceof SessionContextPageRequestError ? 400 : 500; + return timing.finish(NextResponse.json({ error: String(error) }, { status })); } } diff --git a/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts b/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts index 4d7db57f9..67c7a5c11 100644 --- a/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts +++ b/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader"; +import { resolveSessionPath } from "@/lib/session-reader"; +import { getParsedSessionSnapshot } from "@/lib/session-detail-cache"; export async function GET( req: Request, @@ -16,8 +17,10 @@ export async function GET( const filePath = await resolveSessionPath(id); if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 }); - // SessionManager-backed parsing preserves the SDK's malformed-line tolerance. - const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId); + // The shared SessionManager-backed snapshot preserves SDK malformed-line tolerance. + const entry = (await getParsedSessionSnapshot(filePath)).entries.find( + (candidate) => candidate.id === entryId, + ); if (!entry || entry.type !== "message" || entry.message.role !== "assistant") { return NextResponse.json({ error: "Assistant message not found" }, { status: 404 }); } diff --git a/app/api/sessions/[id]/meta/route.ts b/app/api/sessions/[id]/meta/route.ts new file mode 100644 index 000000000..b5e267104 --- /dev/null +++ b/app/api/sessions/[id]/meta/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { attachSessionProjectInfo, getIndexedSessionInfoById } from "@/lib/session-reader"; +import { getRpcSessionInfos } from "@/lib/rpc-manager"; +import { createServerTiming } from "@/lib/server-timing"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const timing = createServerTiming(); + const { id } = await params; + try { + const runtime = getRpcSessionInfos().find((session) => session.id === id); + const session = runtime + ? (await timing.time("runtime-project", () => attachSessionProjectInfo([runtime])))[0] ?? null + : await timing.time("session-meta", () => getIndexedSessionInfoById(id)); + if (!session) { + return timing.finish(NextResponse.json( + { error: "Session not found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + )); + } + return timing.finish(NextResponse.json( + { session }, + { headers: { "Cache-Control": "no-store" } }, + )); + } catch (error) { + return timing.finish(NextResponse.json( + { error: String(error) }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + )); + } +} diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index e913e4376..461453afb 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import { attachSessionProjectInfo, resolveSessionPath, @@ -15,6 +15,19 @@ import { sessionPathKey } from "@/lib/session-path"; import { getRpcSession } from "@/lib/rpc-manager"; import { projectTreeForResponse } from "@/lib/project-tree"; import { computeSessionTotalActiveMs } from "@/lib/session-timing"; +import { createServerTiming } from "@/lib/server-timing"; +import { + getParsedSessionSnapshot, + getSessionContextFromSnapshot, + invalidateParsedSession, +} from "@/lib/session-detail-cache"; +import { + computeSessionContextStats, + computeSessionInputHistory, + paginateSessionContext, + parseSessionContextPageRequest, + SessionContextPageRequestError, +} from "@/lib/session-context-page"; import { computeSessionStats } from "@/lib/session-stats"; import type { SessionEntry } from "@/lib/types"; import { readSubagentRun, readSubagentSessionResources, SUBAGENT_META_TYPE } from "@/lib/subagents"; @@ -24,87 +37,113 @@ export async function GET( req: Request, { params }: { params: Promise<{ id: string }> } ) { + const timing = createServerTiming(); const { id } = await params; try { const rpc = getRpcSession(id); const liveRpc = rpc?.isAlive() ? rpc : undefined; - const resolvedPath = liveRpc ? null : await resolveSessionPath(id); + const resolvedPath = liveRpc + ? null + : await timing.time("resolve", () => resolveSessionPath(id)); if (!liveRpc && !resolvedPath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); + return timing.finish(NextResponse.json({ error: "Session not found" }, { status: 404 })); } - const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(resolvedPath!); - const filePath = liveRpc?.sessionFile || sm.getSessionFile() || resolvedPath || ""; - const entries = sm.getEntries(); - const leafId = sm.getLeafId(); - const tree = projectTreeForResponse(sm.getTree()); + const diskSnapshot = liveRpc + ? null + : await timing.time("parse", () => getParsedSessionSnapshot(resolvedPath!)); + const sm = liveRpc?.inner.sessionManager; + const { filePath, entries, leafId, tree } = timing.timeSync("session-read", () => ({ + filePath: liveRpc?.sessionFile || diskSnapshot?.filePath || resolvedPath || "", + entries: sm?.getEntries() ?? diskSnapshot!.entries, + leafId: sm ? sm.getLeafId() : diskSnapshot!.leafId, + tree: sm ? projectTreeForResponse(sm.getTree()) : diskSnapshot!.tree, + })); const searchParams = new URL(req.url).searchParams; const deferThinking = searchParams.has("deferThinking"); const deferToolResultImages = searchParams.has("deferMedia"); - const rawTail = Number(searchParams.get("tail")); - const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50; - const context = buildSessionContext(entries as never, leafId, { - deferThinking, - deferToolResultImages, - tail, - sessionId: id, // local: lazy URLs for historical tool-result images - }); - const totalActiveMs = computeSessionTotalActiveMs(entries); - // Cumulative usage over ALL entries, including history compacted away — - // the same aggregation the SDK's getSessionStats() uses. Lets the client - // keep monotonic token/cost counters across compaction and page reloads. - const stats = computeSessionStats(entries as unknown as SessionEntry[]); - const sessionName = sm.getSessionName(); - const firstUserEntry = entries.find((entry) => entry.type === "message" && entry.message.role === "user"); - const firstUserMessage = firstUserEntry?.type === "message" ? firstUserEntry.message : undefined; + const pageRequest = parseSessionContextPageRequest(searchParams); + const contextOptions = { deferThinking, deferToolResultImages, sessionId: id }; + const { fullContext, totalActiveMs } = timing.timeSync("context", () => ({ + fullContext: diskSnapshot + ? getSessionContextFromSnapshot( + diskSnapshot, + leafId, + contextOptions, + () => buildSessionContext(entries as never, leafId, contextOptions), + ) + : buildSessionContext(entries as never, leafId, contextOptions), + totalActiveMs: computeSessionTotalActiveMs(entries), + })); + const { context, page: contextPage } = pageRequest + ? paginateSessionContext(fullContext, pageRequest) + : { + context: fullContext, + page: { + startIndex: 0, + endIndex: fullContext.messages.length, + totalMessages: fullContext.messages.length, + hasEarlier: false, + }, + }; + const contextStats = computeSessionContextStats(fullContext); + const inputHistory = computeSessionInputHistory(fullContext); - const header = sm.getHeader(); - let modified = header?.timestamp ?? new Date().toISOString(); - try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ } - const parentSessionId = header?.parentSession - ? await resolveSessionIdByPath(header.parentSession) - : undefined; - const subagent = header - ? readSubagentRun(entries as never, header.id, filePath) - : null; + const stats = diskSnapshot?.stats ?? computeSessionStats(entries as unknown as SessionEntry[]); const toolNames = readSubagentSessionResources(entries as never)?.tools ?? readSessionToolSelection(entries as never); - const info = header ? (await attachSessionProjectInfo([{ - path: filePath, - id: header.id, - cwd: header.cwd ?? "", - name: sessionName, - created: header.timestamp, - modified, - messageCount: stats.totalMessages, - firstMessage: firstUserMessage - ? (() => { - const c = (firstUserMessage as { content: unknown }).content; - return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)"; - })() - : "(no messages)", - parentSessionId, - ...(subagent - ? { relation: { kind: "subagent" as const, parentSessionId: subagent.parentSessionId, profile: subagent.profile, description: subagent.description, status: liveRpc?.isRunning() ? "running" as const : subagent.status } } - : header.parentSession - ? { relation: { kind: "fork" as const, ...(parentSessionId ? { originSessionId: parentSessionId } : {}) } } - : {}), - transient: !filePath || !existsSync(filePath), - }]))[0] : null; + const info = await timing.time("metadata", async () => { + const header = sm?.getHeader() ?? diskSnapshot?.header ?? null; + if (!header) return null; + let modified = header.timestamp; + try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ } + const subagent = readSubagentRun(entries as never, header.id, filePath); + const originSessionId = header.parentSession + ? await resolveSessionIdByPath(header.parentSession) + : undefined; + const firstEntry = entries.find((entry) => entry.type === "message" && entry.message.role === "user"); + const content = firstEntry?.type === "message" && firstEntry.message.role === "user" + ? firstEntry.message.content + : undefined; + const firstMessage = typeof content === "string" ? content + : Array.isArray(content) ? content.filter((block) => block.type === "text").map((block) => block.text).join(" ") : ""; + return (await attachSessionProjectInfo([{ + path: filePath, + id: header.id, + cwd: header.cwd ?? "", + name: sm?.getSessionName() ?? diskSnapshot?.sessionName, + created: header.timestamp, + modified, + messageCount: stats.totalMessages, + firstMessage: firstMessage || "(no messages)", + parentSessionId: subagent?.parentSessionId ?? originSessionId, + ...(subagent + ? { relation: { kind: "subagent" as const, parentSessionId: subagent.parentSessionId, profile: subagent.profile, description: subagent.description, status: liveRpc?.isRunning() ? "running" as const : subagent.status } } + : header.parentSession + ? { relation: { kind: "fork" as const, ...(originSessionId ? { originSessionId } : {}) } } + : {}), + transient: !filePath || !existsSync(filePath), + }]))[0]; + }); - return NextResponse.json({ + const response = timing.timeSync("serialize", () => NextResponse.json({ sessionId: id, filePath, info, leafId, tree, context, + contextPage, + contextStats, + inputHistory, stats, totalActiveMs, ...(toolNames !== undefined ? { toolNames } : {}), - }); + })); + return timing.finish(response); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + const status = error instanceof SessionContextPageRequestError ? 400 : 500; + return timing.finish(NextResponse.json({ error: String(error) }, { status })); } } @@ -123,9 +162,10 @@ export async function PATCH( if (!filePath) { return NextResponse.json({ error: "Session not found" }, { status: 404 }); } - const sm = SessionManager.open(filePath); - sm.appendSessionInfo(name.trim()); - invalidateSessionListCache(); + const manager = SessionManager.open(filePath); + manager.appendSessionInfo(name.trim()); + invalidateParsedSession(filePath); + invalidateSessionListCache([filePath]); return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); @@ -159,6 +199,7 @@ export async function DELETE( // Re-attach all direct children to this session's parent (cascade re-parent) // Scan sibling files in the same directory const targetPathKey = sessionPathKey(filePath); + const reparentedPaths: string[] = []; const dir = dirname(filePath); try { const files = readdirSync(dir).filter( @@ -203,6 +244,8 @@ export async function DELETE( } } writeFileSync(childPath, lines.join("\n")); + invalidateParsedSession(childPath); + reparentedPaths.push(childPath); } } catch { /* skip malformed */ } } @@ -210,8 +253,9 @@ export async function DELETE( await getRpcSession(id)?.shutdown(); unlinkSync(filePath); + invalidateParsedSession(filePath); invalidateSessionPathCache(id); - invalidateSessionListCache(); + invalidateSessionListCache([filePath, ...reparentedPaths]); return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); diff --git a/app/api/sessions/context-route.test.mjs b/app/api/sessions/context-route.test.mjs index c637b422d..00a1a0848 100644 --- a/app/api/sessions/context-route.test.mjs +++ b/app/api/sessions/context-route.test.mjs @@ -1,46 +1,41 @@ -// Static + behavior coverage for the context pagination API (the #555 transfer fix): -// ?tail bounds the returned chain, ?before rewinds the walk and excludes its own -// boundary so prepending the page never duplicates it. Data behavior is covered -// end-to-end in lib/session-reader.pagination.test.mjs; here we assert the route wires -// the params through to buildSessionContext (excludeLeaf on ?before). import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { createJiti } from "jiti"; -const routeSrc = await readFileSync(new URL("./[id]/context/route.ts", import.meta.url), "utf8"); -const jiti = createJiti(import.meta.url, { - alias: { "@": process.cwd() }, - interopDefault: true, - moduleCache: false, -}); +const routeSrc = readFileSync(new URL("./[id]/context/route.ts", import.meta.url), "utf8"); +const jiti = createJiti(import.meta.url, { alias: { "@": process.cwd() } }); const { buildSessionContext } = await jiti.import("@/lib/session-reader"); +const { paginateSessionContext, parseSessionContextPageRequest } = await jiti.import("@/lib/session-context-page"); -test("context route parses ?tail and ?before, excluding the boundary on paging", () => { - assert.match(routeSrc, /const tail = Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); - assert.match(routeSrc, /const before = url\.searchParams\.get\("before"\)/); - assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, before \?\? leafId, \{[^}]*excludeLeaf: Boolean\(before\)/); +test("context 路由复用完整分支缓存后分页,图片 URL 带会话身份", () => { + assert.match(routeSrc, /parseSessionContextPageRequest\(url.searchParams\)/); + assert.match(routeSrc, /getSessionContextFromSnapshot/); + assert.match(routeSrc, /paginateSessionContext\(fullContext, pageRequest\)/); + assert.match(routeSrc, /sessionId: id/); + assert.doesNotMatch(routeSrc, /before \?\? leafId|excludeLeaf: Boolean\(before\)/); }); -test("context route: ?before pages upward without duplicating the boundary", () => { - const entries = []; - for (let i = 0; i < 100; i++) { - entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } }); - } - const page1 = buildSessionContext(entries, "e99", { tail: 5 }).entryIds; - assert.deepEqual(page1, ["e95", "e96", "e97", "e98", "e99"]); - const oldest = page1[0]; // e95 - const page2 = buildSessionContext(entries, oldest, { tail: 5, excludeLeaf: true }).entryIds; - assert.equal(page2[page2.length - 1], "e94"); - assert.ok(!page2.includes(oldest), "boundary `before` must not be duplicated"); - assert.ok(page1.every((id) => !page2.includes(id)), "adjacent pages share no entry"); +test("数字 before 作为排他边界,上翻不重复已返回消息", () => { + const entries = Array.from({ length: 100 }, (_, i) => ({ + id: `e${i}`, parentId: i ? `e${i - 1}` : null, type: "message", + timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` }, + })); + const full = buildSessionContext(entries, "e99"); + const first = paginateSessionContext(full, parseSessionContextPageRequest(new URLSearchParams("tail=5"))); + assert.deepEqual(first.context.entryIds, ["e95", "e96", "e97", "e98", "e99"]); + const second = paginateSessionContext(full, parseSessionContextPageRequest(new URLSearchParams(`before=${first.page.startIndex}&limit=5`))); + assert.deepEqual(second.context.entryIds, ["e90", "e91", "e92", "e93", "e94"]); + assert.equal(first.context.entryIds.some(id => second.context.entryIds.includes(id)), false); + assert.equal(second.context.oldestEntryId, "e90"); + assert.equal(second.context.hasMore, second.page.hasEarlier); }); -test("context route data reports when pagination reaches the root", () => { - const entries = [ - { id: "e0", parentId: null, type: "message", timestamp: new Date(1000).toISOString(), message: { role: "user", content: "root" } }, - ]; - const page = buildSessionContext(entries, "e0", { tail: 50, excludeLeaf: true }); - assert.deepEqual(page.entryIds, []); - assert.equal(page.hasMore, false); +test("到达根节点后返回空窗口,不保留完整分支的旧游标", () => { + const full = buildSessionContext([{ id: "e0", parentId: null, type: "message", timestamp: new Date(1000).toISOString(), message: { role: "user", content: "root" } }]); + const page = paginateSessionContext(full, { before: 0, limit: 120 }); + assert.deepEqual(page.context.entryIds, []); + assert.equal(page.context.oldestEntryId, null); + assert.equal(page.context.hasMore, false); + assert.equal(page.page.hasEarlier, false); }); diff --git a/app/api/sessions/detail-route.test.mjs b/app/api/sessions/detail-route.test.mjs index 2507e6eab..a6d85e85e 100644 --- a/app/api/sessions/detail-route.test.mjs +++ b/app/api/sessions/detail-route.test.mjs @@ -1,54 +1,150 @@ -// Static + behavior coverage for the session detail API's tail bound (the #509/#555 -// transfer fix). Mirrors runtime-route.test.mjs: source assertions confirm the route -// parses ?tail (default 50, NaN-safe, capped at 1000) and feeds only the sliced chain -// to buildSessionContext. The data-slicing behavior itself is covered end-to-end in -// lib/session-reader.pagination.test.mjs (sliceActiveBranch + buildSessionContext). import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; import { createJiti } from "jiti"; -const routeSrc = await readFileSync(new URL("./[id]/route.ts", import.meta.url), "utf8"); +// Use real JSONL parsing and route handlers, but never construct an AgentSession. +const directory = mkdtempSync(join(tmpdir(), "pi-web-detail-route-")); +const rpcStub = join(directory, "rpc.mjs"); +writeFileSync(rpcStub, "export const getRpcSession = (id) => globalThis.__piDetailRouteRpcs?.get(id);\n"); const jiti = createJiti(import.meta.url, { - alias: { "@": process.cwd() }, - interopDefault: true, + alias: { "@/lib/rpc-manager": rpcStub, "@": process.cwd() }, moduleCache: false, }); -const { buildSessionContext } = await jiti.import("@/lib/session-reader"); - -test("detail route parses ?tail: default 50, NaN-safe, capped at 1000", () => { - assert.match(routeSrc, /const rawTail = Number\(searchParams\.get\("tail"\)\)/); - assert.match(routeSrc, /Math\.min\(rawTail, 1000\)/); - assert.match(routeSrc, /Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); - assert.match(routeSrc, /buildSessionContext\(entries as never, leafId, \{[^}]*tail,[^}]*sessionId: id[^}]*\}\)/); - assert.match(routeSrc, /computeSessionStats\(entries as unknown as SessionEntry\[\]\)/); - assert.match(routeSrc, /messageCount: stats\.totalMessages/); - assert.match(routeSrc, /stats,/); -}); - -test("detail route bounds history to the tail window (default 50 over 5000 entries)", () => { - const entries = []; - for (let i = 0; i < 5000; i++) { - entries.push({ - id: `e${i}`, - parentId: i === 0 ? null : `e${i - 1}`, - type: "message", - timestamp: new Date(1000 + i * 1000).toISOString(), - message: { role: i % 2 === 0 ? "user" : "assistant", content: `m${i}` }, - }); +const { GET: detail } = await jiti.import("./[id]/route.ts"); +const { GET: context } = await jiti.import("./[id]/context/route.ts"); +const { cacheSessionPath, invalidateSessionPathCache } = await jiti.import("../../../lib/session-reader.ts"); +const { invalidateParsedSession } = await jiti.import("../../../lib/session-detail-cache.ts"); +const id = "history-fixture"; +const filePath = join(directory, "history.jsonl"); +const timestamp = "2026-01-01T00:00:00.000Z"; +const usage = (input, output, cost) => ({ input, output, cacheRead: 0, cacheWrite: 0, cost: { input: cost, output: 0, cacheRead: 0, cacheWrite: 0, total: cost } }); +const entries = [ + { type: "session", version: 3, id, timestamp, cwd: directory }, + { type: "custom", id: "tools", parentId: null, timestamp, customType: "pi-web:tool-selection", data: { version: 1, tools: [] } }, +]; +for (let i = 0; i < 300; i++) { + entries.push({ type: "message", id: `e${i}`, parentId: i ? `e${i - 1}` : "tools", timestamp, + message: i % 2 + ? { role: "assistant", provider: "fixture", model: "fixture", content: [{ type: "text", text: `m${i}` }], ...(i === 1 ? { usage: usage(100, 10, 0.25) } : {}) } + : { role: "user", content: `m${i}` }, + }); +} +entries.push({ type: "compaction", id: "cmp", parentId: "e299", timestamp, summary: "压缩摘要", firstKeptEntryId: "e250", tokensBefore: 110, usage: usage(5, 1, 0.05) }); +entries.push({ type: "message", id: "after", parentId: "cmp", timestamp, message: { role: "user", content: "after compaction" } }); +writeFileSync(filePath, entries.map(x => JSON.stringify(x)).join("\n") + "\n"); +cacheSessionPath(id, filePath); +after(() => { + delete globalThis.__piDetailRouteRpcs; + invalidateSessionPathCache(id); + invalidateParsedSession(filePath); + rmSync(directory, { recursive: true, force: true }); +}); +const params = (sessionId = id) => ({ params: Promise.resolve({ id: sessionId }) }); + +async function json(handler, query = "", sessionId = id, status = 200) { + const response = await handler(new Request(`http://localhost/api/sessions/${sessionId}${query}`), params(sessionId)); + const body = await response.json(); + assert.equal(response.status, status, JSON.stringify(body)); + assert.match(response.headers.get("Server-Timing"), /total;dur=/); + return body; +} + +test("详情首屏保持 60 条,累计用量与压缩前输入历史不随窗口缩小", async () => { + const body = await json(detail, "?tail=60"); + assert.equal(body.context.messages.length, 60); + assert.deepEqual(body.contextPage, { startIndex: 242, endIndex: 302, totalMessages: 302, hasEarlier: true }); + assert.deepEqual(body.context.entryIds, [...Array.from({ length: 58 }, (_, i) => `e${242 + i}`), "cmp", "after"]); + assert.equal(body.context.oldestEntryId, "e242"); + assert.equal(body.context.hasMore, true); + assert.equal(body.contextStats.totalMessages, 302); + assert.equal(body.stats.totalMessages, 301); + assert.equal(body.stats.tokens.total, 116); + assert.equal(body.stats.cost, 0.3); + assert.equal(body.inputHistory.length, 50); + assert.equal(body.inputHistory.at(-1), "after compaction"); + assert.equal(body.info.firstMessage, "m0"); + assert.equal(body.info.messageCount, 301); + assert.deepEqual(body.toolNames, []); +}); + +test("context 使用数字 before 和 120 条窗口,上翻覆盖压缩前历史且不重复", async () => { + const tail = await json(detail, "?tail=60"); + const earlier = await json(context, "?before=242&limit=120"); + assert.deepEqual(earlier.page, { startIndex: 122, endIndex: 242, totalMessages: 302, hasEarlier: true }); + assert.deepEqual(earlier.context.entryIds, Array.from({ length: 120 }, (_, i) => `e${122 + i}`)); + assert.equal(earlier.context.entryIds.some(id => tail.context.entryIds.includes(id)), false); + assert.equal(earlier.context.messages.length, earlier.context.entryIds.length); + assert.deepEqual(earlier.inputHistory, tail.inputHistory); + const beginning = await json(context, "?before=2&limit=120"); + assert.deepEqual(beginning.context.entryIds, ["e0", "e1"]); + assert.equal(beginning.page.hasEarlier, false); + assert.equal(beginning.context.hasMore, false); + const empty = await json(context, "?before=0&limit=120"); + assert.deepEqual(empty.context.entryIds, []); + assert.equal(empty.context.oldestEntryId, null); +}); + +test("分页参数严格校验,窗口最大 240,未指定分页时保留完整分支接口", async () => { + for (const handler of [detail, context]) { + assert.equal((await json(handler, "?tail=10000")).context.messages.length, 240); + assert.equal((await json(handler, "?tail=1")).context.messages.length, 1); + assert.equal((await json(handler)).context.messages.length, 302); + for (const query of ["?tail=NaN", "?tail=0", "?before=e242", "?before=-1", "?limit=10"]) { + await json(handler, query, id, 400); + } + } +}); + +test("分支查询只返回所选祖先链,不能复用另一叶节点的缓存", async () => { + const selected = await json(context, "?leafId=e10&tail=5"); + assert.deepEqual(selected.context.entryIds, ["e6", "e7", "e8", "e9", "e10"]); + assert.equal(selected.contextStats.totalMessages, 11); + const empty = await json(context, "?leafId=&tail=5"); + assert.deepEqual(empty.context.entryIds, []); + const latest = await json(context, "?tail=5"); + assert.deepEqual(latest.context.entryIds, ["e297", "e298", "e299", "cmp", "after"]); +}); + +test("详情与 context 的惰性工具图片使用当前会话 URL,不污染完整媒体缓存", async (t) => { + const mediaId = "media-fixture"; + const mediaPath = join(directory, "media.jsonl"); + const image = { type: "image", source: { type: "base64", media_type: "image/png", data: "QUJDRA==" } }; + writeFileSync(mediaPath, [ + { type: "session", version: 3, id: mediaId, timestamp, cwd: directory }, + { type: "message", id: "user", parentId: null, timestamp, message: { role: "user", content: "图片" } }, + { type: "message", id: "result", parentId: "user", timestamp, message: { role: "toolResult", toolCallId: "read1", toolName: "read", content: [image] } }, + ].map(x => JSON.stringify(x)).join("\n") + "\n"); + cacheSessionPath(mediaId, mediaPath); + t.after(() => { invalidateSessionPathCache(mediaId); invalidateParsedSession(mediaPath); }); + for (const handler of [detail, context]) { + const deferred = await json(handler, "?tail=1&deferMedia=1", mediaId); + assert.equal(deferred.context.messages[0].content[0].source.url, "/api/sessions/media-fixture/entries/result/tool-result-image?blockIndex=0"); + const full = await json(handler, "?tail=1", mediaId); + assert.deepEqual(full.context.messages[0].content[0], image); } - const ctx = buildSessionContext(entries, "e4999", { tail: 50 }); - assert.equal(ctx.messages.length, 50); - // The transferred window is the tail, not the full 5000-entry forest. - assert.equal(ctx.entryIds[0], "e4950"); - assert.equal(ctx.entryIds[ctx.entryIds.length - 1], "e4999"); }); -test("detail route with an out-of-range tail still caps at 1000", () => { - const entries = []; - for (let i = 0; i < 5000; i++) { - entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } }); +test("运行中空叶节点不回退到磁盘快照或旧消息", async () => { + const liveId = "live-empty-fixture"; + globalThis.__piDetailRouteRpcs = new Map([[liveId, { + isAlive: () => true, isRunning: () => false, sessionFile: "", + inner: { sessionManager: { + getEntries: () => [{ type: "message", id: "old", parentId: null, timestamp, message: { role: "user", content: "旧分支" } }], + getLeafId: () => null, getTree: () => [], getSessionName: () => undefined, + getHeader: () => ({ type: "session", version: 3, id: liveId, timestamp, cwd: directory }), + } }, + }]]); + try { + for (const handler of [detail, context]) { + const body = await json(handler, "?tail=60", liveId); + assert.deepEqual(body.context.messages, []); + assert.deepEqual(body.context.entryIds, []); + assert.equal(body.context.oldestEntryId, null); + } + } finally { + delete globalThis.__piDetailRouteRpcs; } - const ctx = buildSessionContext(entries, "e4999", { tail: 5000 }); - assert.equal(ctx.messages.length, 5000); }); diff --git a/app/api/sessions/list-route.test.mjs b/app/api/sessions/list-route.test.mjs new file mode 100644 index 000000000..0b21f0aa0 --- /dev/null +++ b/app/api/sessions/list-route.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { gunzipSync } from "node:zlib"; +import { createJiti } from "jiti"; + +// Isolate the runtime registry so this read-only route test never creates a real agent. +const directory = mkdtempSync(join(tmpdir(), "pi-web-list-route-")); +const stateKey = "__piWebListRouteTest"; +const readerStub = join(directory, "reader.mjs"); +const rpcStub = join(directory, "rpc.mjs"); +const indexStub = join(directory, "index.mjs"); +writeFileSync(readerStub, ` +const state = () => globalThis.${stateKey}; +export const getSessionListVersion = () => state().version; +export const listAllSessions = async (options) => { + state().calls.push(options); + options.onTiming?.("session-scan", 1); + return state().load(); +}; +export const attachSessionProjectInfo = async (sessions) => sessions; +export const mergeSessionLists = (disk, live) => state().merge(disk, live); +`); +writeFileSync(rpcStub, ` +const state = () => globalThis.${stateKey}; +export const getRpcSessionInfos = () => state().runtime; +export const getRunningRpcSessionIds = () => ["running"]; +export const getCompletionNotificationSuppressedRpcSessionIds = () => ["child"]; +`); +writeFileSync(indexStub, ` +export const refreshSessionIndexInBackground = () => { globalThis.${stateKey}.validations += 1; }; +`); +after(() => { + delete globalThis[stateKey]; + rmSync(directory, { recursive: true, force: true }); +}); +const actual = createJiti(import.meta.url, { alias: { "@": process.cwd() } }); +const { mergeSessionLists } = await actual.import("../../../lib/session-reader.ts"); +const jiti = createJiti(import.meta.url, { + moduleCache: false, + alias: { + "@/lib/session-reader": readerStub, + "@/lib/rpc-manager": rpcStub, + "@/lib/session-index": indexStub, + "@": process.cwd(), + }, +}); +const { GET } = await jiti.import("./route.ts"); +const { GET: running } = await jiti.import("../agent/running/route.ts"); + +function setup() { + const state = { version: 7, calls: [], validations: 0, runtime: [], load: async () => [], merge: mergeSessionLists }; + globalThis[stateKey] = state; + return state; +} + +test("列表请求只加载一次,保留计时、版本和通知字段", async () => { + const state = setup(); + const response = await GET(new Request("http://localhost/api/sessions?force=1")); + assert.equal(response.status, 200); + assert.equal(state.calls.length, 1); + assert.equal(state.calls[0].force, true); + assert.match(response.headers.get("Server-Timing"), /session-scan;dur=/); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + assert.equal(response.headers.get("Vary"), "Accept-Encoding"); + assert.deepEqual(await response.json(), { + sessions: [], sessionListVersion: 7, + runningSessionIds: ["running"], completionNotificationSuppressedSessionIds: ["child"], + }); +}); + +test("客户端支持 gzip 时压缩大响应并保持 JSON 内容一致", async () => { + const state = setup(); + state.load = async () => [{ id: "large", name: "x".repeat(4096) }]; + state.merge = disk => disk; + + const compressed = await GET(new Request("http://localhost/api/sessions", { + headers: { "Accept-Encoding": "br, gzip" }, + })); + assert.equal(compressed.status, 200); + assert.equal(compressed.headers.get("Content-Encoding"), "gzip"); + assert.equal(compressed.headers.get("Vary"), "Accept-Encoding"); + assert.match(compressed.headers.get("Server-Timing"), /compress;dur=/); + const decoded = JSON.parse(gunzipSync(Buffer.from(await compressed.arrayBuffer())).toString("utf8")); + assert.equal(decoded.sessions[0].name.length, 4096); + + const uncompressed = await GET(new Request("http://localhost/api/sessions", { + headers: { "Accept-Encoding": "gzip;q=0, *;q=1" }, + })); + assert.equal(uncompressed.headers.get("Content-Encoding"), null); + assert.equal((await uncompressed.json()).sessions[0].name.length, 4096); +}); + +test("扫描期间发生变化时不把旧结果标记成新版本", async () => { + const state = setup(); + let release; + state.load = () => new Promise(resolve => { release = resolve; }); + const pending = GET(new Request("http://localhost/api/sessions")); + assert.equal(state.calls.length, 1); + assert.equal(state.calls[0].force, false); + state.version = 8; + release([]); + assert.equal((await (await pending).json()).sessionListVersion, 7); +}); + +test("列表失败仍返回不可缓存的错误和请求计时", async () => { + const state = setup(); + state.load = async () => { throw new Error("fixture failure"); }; + const response = await GET(new Request("http://localhost/api/sessions")); + assert.equal(response.status, 500); + assert.equal(state.calls.length, 1); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + assert.match(response.headers.get("Server-Timing"), /total;dur=/); + assert.match((await response.json()).error, /fixture failure/); +}); + +test("运行态轮询只调度后台验证,不等待目录列表加载", async () => { + const state = setup(); + state.load = () => { throw new Error("运行态响应不能等待目录加载"); }; + const response = await running(); + assert.equal(response.status, 200); + assert.equal(state.validations, 1); + assert.equal(state.calls.length, 0); + assert.deepEqual(await response.json(), { + sessionListVersion: 7, runningSessionIds: ["running"], completionNotificationSuppressedSessionIds: ["child"], + }); +}); diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index a5231a0a3..926938ffa 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; +import { gzip } from "node:zlib"; import { attachSessionProjectInfo, getSessionListVersion, listAllSessions, mergeSessionLists, } from "@/lib/session-reader"; +import { createServerTiming } from "@/lib/server-timing"; import { getCompletionNotificationSuppressedRpcSessionIds, getRpcSessionInfos, @@ -13,30 +15,74 @@ import { export const dynamic = "force-dynamic"; +const MIN_GZIP_BYTES = 1024; + +function acceptsGzip(value: string | null): boolean { + if (!value) return false; + + let wildcardAccepted = false; + for (const entry of value.split(",")) { + const [rawCoding, ...rawParameters] = entry.trim().split(";"); + const coding = rawCoding.trim().toLowerCase(); + if (coding !== "gzip" && coding !== "*") continue; + + let quality = 1; + for (const rawParameter of rawParameters) { + const match = /^q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/i.exec(rawParameter.trim()); + if (match) quality = Number(match[1]); + } + if (coding === "gzip") return quality > 0; + wildcardAccepted = quality > 0; + } + return wildcardAccepted; +} + +function gzipJson(value: string): Promise { + return new Promise((resolve, reject) => { + gzip(value, { level: 6 }, (error, compressed) => { + if (error) reject(error); + else resolve(compressed); + }); + }); +} + export async function GET(req: Request) { + const timing = createServerTiming(); try { const force = new URL(req.url).searchParams.get("force") === "1"; - const persistedSessionsPromise = listAllSessions({ force }); + const persistedSessionsPromise = timing.time("session-list", () => listAllSessions({ + force, + onTiming: (stage, durationMs) => timing.record(stage, durationMs), + })); // Capture before awaiting: mutations during the scan still require a later refresh. const sessionListVersion = getSessionListVersion(); const [persistedSessions, runtimeSessions] = await Promise.all([ persistedSessionsPromise, - attachSessionProjectInfo(getRpcSessionInfos()), + timing.time("runtime-project", () => attachSessionProjectInfo(getRpcSessionInfos())), ]); - const sessions = mergeSessionLists(persistedSessions, runtimeSessions); - return NextResponse.json( - { - sessions, - sessionListVersion, - runningSessionIds: getRunningRpcSessionIds(), - completionNotificationSuppressedSessionIds: getCompletionNotificationSuppressedRpcSessionIds(), - }, - { headers: { "Cache-Control": "no-store" } }, - ); + const sessions = timing.timeSync("merge", () => mergeSessionLists(persistedSessions, runtimeSessions)); + const serialized = timing.timeSync("serialize", () => JSON.stringify({ + sessions, + sessionListVersion, + runningSessionIds: getRunningRpcSessionIds(), + completionNotificationSuppressedSessionIds: getCompletionNotificationSuppressedRpcSessionIds(), + })); + const headers = new Headers({ + "Cache-Control": "no-store", + "Content-Type": "application/json", + "Vary": "Accept-Encoding", + }); + if (Buffer.byteLength(serialized) < MIN_GZIP_BYTES || !acceptsGzip(req.headers.get("Accept-Encoding"))) { + return timing.finish(new Response(serialized, { headers })); + } + + const compressed = await timing.time("compress", () => gzipJson(serialized)); + headers.set("Content-Encoding", "gzip"); + return timing.finish(new Response(new Uint8Array(compressed), { headers })); } catch (error) { - return NextResponse.json( + return timing.finish(NextResponse.json( { error: String(error) }, { status: 500, headers: { "Cache-Control": "no-store" } }, - ); + )); } } diff --git a/app/api/sessions/runtime-route.test.mjs b/app/api/sessions/runtime-route.test.mjs index 8149972a9..e50882834 100644 --- a/app/api/sessions/runtime-route.test.mjs +++ b/app/api/sessions/runtime-route.test.mjs @@ -15,6 +15,8 @@ const jiti = createJiti(import.meta.url, { moduleCache: false, }); const { DELETE: deleteSession, GET: getSessionDetail, PATCH: renameSession } = await jiti.import("./[id]/route.ts"); +const { GET: getSessionContext } = await jiti.import("./[id]/context/route.ts"); +const { GET: getSessionMeta } = await jiti.import("./[id]/meta/route.ts"); const { GET: getSessionList } = await jiti.import("./route.ts"); const { GET: getRunningSessions } = await jiti.import("../agent/running/route.ts"); const { GET: getSessionState } = await jiti.import("./[id]/state/route.ts"); @@ -77,9 +79,11 @@ test("list versions expose idle session creation, rename and deletion to other w test("session listing merges live registry snapshots and honors force refresh", () => { assert.match(listRoute, /searchParams\.get\("force"\) === "1"/); - assert.match(listRoute, /listAllSessions\(\{ force \}\)/); + assert.match(listRoute, /listAllSessions\(\{\s*force,/); assert.match(listRoute, /attachSessionProjectInfo\(getRpcSessionInfos\(\)\)/); assert.match(listRoute, /mergeSessionLists\(persistedSessions, runtimeSessions\)/); + assert.match(listRoute, /onTiming: \(stage, durationMs\) => timing\.record\(stage, durationMs\)/); + assert.match(listRoute, /timing\.timeSync\("serialize"/); assert.match(listRoute, /"Cache-Control": "no-store"/); }); @@ -89,7 +93,8 @@ test("session reads use the live SessionManager before requiring a JSONL path", const pathLookup = source.indexOf("resolveSessionPath(id)"); assert.ok(liveLookup >= 0); assert.ok(pathLookup > liveLookup); - assert.match(source, /liveRpc\?\.inner\.sessionManager \?\? SessionManager\.open/); + assert.match(source, /const diskSnapshot = liveRpc\s*\? null\s*:\s*await timing\.time\("parse", \(\) => getParsedSessionSnapshot/); + assert.match(source, /liveRpc\?\.inner\.sessionManager/); } }); @@ -170,10 +175,17 @@ test("live detail and state routes work without a persisted JSONL file", async ( timestamp, message: { role: "user", content: "hello live" }, }; + const secondEntry = { + type: "message", + id: "u2", + parentId: entry.id, + timestamp: "2026-08-12T01:02:04.000Z", + message: { role: "user", content: "second live message" }, + }; const sessionManager = { getHeader: () => ({ type: "session", id, cwd: "/tmp", timestamp }), - getEntries: () => [entry], - getLeafId: () => entry.id, + getEntries: () => [entry, secondEntry], + getLeafId: () => secondEntry.id, getTree: () => [], getSessionName: () => undefined, getSessionFile: () => `/tmp/pi-web-live-route-not-persisted-${process.pid}.jsonl`, @@ -196,17 +208,66 @@ test("live detail and state routes work without a persisted JSONL file", async ( new Request(`http://localhost/api/sessions/${id}`), routeContext, ); + const pagedResponse = await getSessionDetail( + new Request(`http://localhost/api/sessions/${id}?tail=1`), + routeContext, + ); + const earlierResponse = await getSessionContext( + new Request(`http://localhost/api/sessions/${id}/context?before=1&limit=1`), + routeContext, + ); + const invalidPageResponse = await getSessionContext( + new Request(`http://localhost/api/sessions/${id}/context?tail=0`), + routeContext, + ); + const metaResponse = await getSessionMeta( + new Request(`http://localhost/api/sessions/${id}/meta`), + routeContext, + ); const stateResponse = await getSessionState( new Request(`http://localhost/api/sessions/${id}/state`), routeContext, ); const detail = await detailResponse.json(); + const paged = await pagedResponse.json(); + const earlier = await earlierResponse.json(); + const meta = await metaResponse.json(); assert.equal(detailResponse.status, 200); + assert.match(detailResponse.headers.get("Server-Timing") ?? "", /session-read;dur=\d+\.\d/); + assert.match(detailResponse.headers.get("Server-Timing") ?? "", /context;dur=\d+\.\d/); + assert.match(detailResponse.headers.get("Server-Timing") ?? "", /serialize;dur=\d+\.\d/); + assert.match(detailResponse.headers.get("Server-Timing") ?? "", /total;dur=\d+\.\d/); assert.equal(detail.info.transient, true); assert.equal(detail.info.projectRoot, "/tmp"); assert.equal(typeof detail.info.projectKey, "string"); - assert.deepEqual(detail.context.messages.map((message) => message.content), ["hello live"]); + assert.deepEqual( + detail.context.messages.map((message) => message.content), + ["hello live", "second live message"], + ); + assert.equal(pagedResponse.status, 200); + assert.deepEqual(paged.context.messages.map((message) => message.content), ["second live message"]); + assert.deepEqual(paged.contextPage, { + startIndex: 1, + endIndex: 2, + totalMessages: 2, + hasEarlier: true, + }); + assert.equal(paged.contextStats.totalMessages, 2); + assert.deepEqual(paged.inputHistory, ["hello live", "second live message"]); + assert.equal(earlierResponse.status, 200); + assert.equal(invalidPageResponse.status, 400); + assert.deepEqual(earlier.context.messages.map((message) => message.content), ["hello live"]); + assert.deepEqual(earlier.page, { + startIndex: 0, + endIndex: 1, + totalMessages: 2, + hasEarlier: false, + }); + assert.equal(metaResponse.status, 200); + assert.equal(meta.session.id, id); + assert.equal(meta.session.transient, true); + assert.equal(typeof meta.session.projectKey, "string"); assert.equal(stateResponse.status, 200); assert.deepEqual(await stateResponse.json(), { running: true, diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index 291be3ba6..980fed942 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -10,8 +10,9 @@ import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-acces export const dynamic = "force-dynamic"; // GET /api/skills?cwd= -// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json -// skill paths, package skills, and .agents/skills directories are all included. +// Uses DefaultResourceLoader for settings paths, package skills, and +// .agents/skills, but disables extensions because this request has no session +// lifecycle in which to dispatch resources_discover or session_shutdown. export async function GET(req: Request) { const { searchParams } = new URL(req.url); const cwd = searchParams.get("cwd"); diff --git a/app/api/worktrees/route.ts b/app/api/worktrees/route.ts index f74dff454..dfeadab77 100644 --- a/app/api/worktrees/route.ts +++ b/app/api/worktrees/route.ts @@ -3,6 +3,7 @@ import { existsSync } from "fs"; import { addWorktree, findCurrentWorktreePath, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree"; import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access"; import { projectIdentityKey } from "@/lib/project-identity"; +import { createServerTiming } from "@/lib/server-timing"; /** Same gate as /api/files: only session cwds / project roots / explicitly * allowed dirs may be inspected or mutated through this endpoint. */ @@ -16,22 +17,28 @@ async function checkCwdAllowed(cwd: string): Promise { // GET /api/worktrees?cwd= → { projectRoot, projectKey, isGit, isTopLevel, currentWorktreePath, worktrees } export async function GET(req: Request) { + const timing = createServerTiming(); try { - const cwd = new URL(req.url).searchParams.get("cwd"); + const searchParams = new URL(req.url).searchParams; + const cwd = searchParams.get("cwd"); + const force = searchParams.get("force") === "1"; if (!cwd) { - return NextResponse.json({ error: "cwd is required" }, { status: 400 }); + return timing.finish(NextResponse.json({ error: "cwd is required" }, { status: 400 })); } - const denied = await checkCwdAllowed(cwd); - if (denied) return denied; + const denied = await timing.time("auth", () => checkCwdAllowed(cwd)); + if (denied) return timing.finish(denied); - const project = await resolveProject(cwd); + const project = await timing.time("project", () => resolveProject(cwd)); let worktrees: Awaited> = []; let currentWorktreePath: string | null = null; let isGit = true; try { // For a removed-worktree cwd (session of a deleted worktree), fall back // to the inferred project root so the switcher still shows the project. - worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot); + worktrees = await timing.time("git", () => listWorktrees( + existsSync(cwd) ? cwd : project.projectRoot, + { force }, + )); currentWorktreePath = findCurrentWorktreePath(worktrees, cwd); } catch { isGit = false; @@ -40,16 +47,17 @@ export async function GET(req: Request) { // file explorer to browse them even before they have any session (the // in-memory allowlist from addWorktree does not survive server restarts). for (const w of worktrees) allowFileRoot(w.path); - return NextResponse.json({ + const response = timing.timeSync("serialize", () => NextResponse.json({ projectRoot: project.projectRoot, projectKey: projectIdentityKey(project.projectRoot), isGit, isTopLevel: project.isTopLevel, currentWorktreePath, worktrees, - }); + })); + return timing.finish(response); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + return timing.finish(NextResponse.json({ error: String(error) }, { status: 500 })); } } diff --git a/app/globals.css b/app/globals.css index 61c4bca47..3cedf4dce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,25 +20,29 @@ } :root { - --bg: #ffffff; - --bg-panel: #f5f5f5; - --bg-hover: #eeeeee; - --bg-selected: #e8e8e8; - --border: #e0e0e0; - --text: #1a1a1a; - --text-muted: #6b7280; - --text-dim: #9ca3af; - --accent: #2563eb; - --accent-hover: #1d4ed8; - --user-bg: #eff6ff; - --assistant-bg: #ffffff; - --tool-bg: #f9fafb; - --bg-subtle: rgba(0,0,0,0.03); + color-scheme: light; + --bg: #fafbfc; + --bg-panel: #f2f4f7; + --bg-hover: #e9edf2; + --bg-selected: #dfe6ee; + --border: #d5dce5; + --text: #18202a; + --text-muted: #566476; + --text-dim: #647285; + --accent: #315f9f; + --accent-hover: #274d82; + --user-bg: #edf3f9; + --user-border: #cbd9e9; + --assistant-bg: #fafbfc; + --tool-bg: #f5f7f9; + --bg-subtle: rgba(24, 32, 42, 0.045); + --shadow-soft: 0 1px 2px rgba(24, 32, 42, 0.06), 0 10px 28px -18px rgba(24, 32, 42, 0.3); --chat-content-max-width: 820px; --chat-content-font-size: 14px; } html.dark { + color-scheme: dark; --bg: #1a1a1a; --bg-panel: #242424; --bg-hover: #2e2e2e; @@ -50,9 +54,11 @@ html.dark { --accent: #60a5fa; --accent-hover: #93c5fd; --user-bg: #1e293b; + --user-border: rgba(96, 165, 250, 0.2); --assistant-bg: #1a1a1a; --tool-bg: #1f2937; --bg-subtle: rgba(255,255,255,0.04); + --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.18), 0 10px 28px -18px rgba(0, 0, 0, 0.7); } /* WebKit reports dvh/innerHeight without the status-bar strip for installed @@ -119,8 +125,13 @@ html, body { overflow: hidden; background: var(--bg); color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: var(--font-geist-sans), var(--font-noto-sans-sc), "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 14px; + font-weight: 450; + font-synthesis: none; + font-optical-sizing: auto; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; overscroll-behavior: none; } @@ -129,6 +140,11 @@ body { flex-direction: column; } +html:not(.dark) body { + font-weight: 450; + letter-spacing: 0.002em; +} + .terminal-panel { display: grid; grid-template-rows: 38px auto minmax(0, 1fr); @@ -457,13 +473,35 @@ button.extension-widget-trigger:focus-visible { } :root { - --font-mono: var(--font-noto-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace; + --font-mono: var(--font-geist-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, var(--font-noto-sans-sc), 'Noto Sans CJK SC', 'PingFang SC', 'Microsoft YaHei', monospace; } pre, code { font-family: var(--font-mono); } +button, +input, +textarea, +select { + font: inherit; +} + +:where(button, input, textarea, select, a):focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 68%, white); + outline-offset: 2px; +} + +.chat-composer:focus-within { + border-color: color-mix(in srgb, var(--accent) 58%, var(--border)) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent), var(--shadow-soft) !important; +} + +.chat-composer-input::placeholder { + color: var(--text-dim); + opacity: 0.88; +} + /* react-markdown output styles */ .chat-content { --chat-font-size-offset: calc(var(--chat-content-font-size, 14px) - 14px); @@ -473,24 +511,29 @@ pre, code { min-width: 0; max-width: 100%; overflow-x: hidden; - font-size: calc(14px + var(--chat-font-size-offset, 0px)); - line-height: 1.7; + font-size: calc(15px + var(--chat-font-size-offset, 0px)); + font-weight: 450; + line-height: 1.74; + letter-spacing: 0; color: var(--text); word-break: break-word; } -.markdown-body p { margin: 0 0 8px; } +.markdown-body p { margin: 0 0 10px; } .markdown-body p:last-child { margin-bottom: 0; } +.markdown-body > :is(p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6) { + max-width: 72ch; +} .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { - font-weight: 600; - margin: 10px 0 5px; + font-weight: 650; + margin: 14px 0 6px; color: var(--text); line-height: 1.35; - letter-spacing: 0; + letter-spacing: -0.015em; } -.markdown-body h1 { font-size: 1.16em; } -.markdown-body h2 { font-size: 1.08em; } -.markdown-body h3 { font-size: 0.98em; color: color-mix(in srgb, var(--text) 88%, var(--text-muted)); } +.markdown-body h1 { font-size: 1.22em; } +.markdown-body h2 { font-size: 1.12em; } +.markdown-body h3 { font-size: 1.02em; color: color-mix(in srgb, var(--text) 90%, var(--text-muted)); } .markdown-body ul, .markdown-body ol { padding-left: 22px; margin: 5px 0 8px; @@ -573,10 +616,10 @@ pre, code { transform: rotate(45deg) translateY(-1px); } .markdown-body blockquote { - border-left: 3px solid color-mix(in srgb, var(--border) 75%, var(--text-muted)); + border-left: 3px solid color-mix(in srgb, var(--accent) 36%, var(--border)); border-radius: 0 6px 6px 0; - margin: 6px 0; - padding: 6px 11px; + margin: 8px 0 10px; + padding: 7px 12px; background: var(--bg-subtle); color: var(--text-muted); } @@ -729,7 +772,7 @@ pre, code { border-radius: 7px; overflow: hidden; background: var(--bg); - box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 42%, transparent); + box-shadow: var(--shadow-soft); } .markdown-code-header { padding: 5px 10px; @@ -1027,6 +1070,14 @@ pre, code { } } +/* Keep the complete source/diff DOM for selection and references while + letting the browser skip layout and paint for off-screen rows. */ +.file-source-line, +.file-diff-line { + content-visibility: auto; + contain-intrinsic-size: auto 20.8px; +} + /* File viewer toolbar */ .file-viewer-toolbar { min-width: 0; diff --git a/app/layout.tsx b/app/layout.tsx index 3c416f1b6..f1c986f0d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,19 +1,32 @@ import type { Metadata, Viewport } from "next"; -import { Noto_Sans_Mono } from "next/font/google"; +import { Geist, Geist_Mono, Noto_Sans_SC } from "next/font/google"; import { PwaRegistration } from "@/components/PwaRegistration"; import "katex/dist/katex.min.css"; import "./globals.css"; import "./settings.css"; -const notoSansMono = Noto_Sans_Mono({ - subsets: ["latin", "cyrillic"], - variable: "--font-noto-mono", +const geistSans = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", display: "swap", }); +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", + display: "swap", +}); + +const notoSansSC = Noto_Sans_SC({ + weight: "variable", + variable: "--font-noto-sans-sc", + display: "swap", + preload: false, +}); + export const metadata: Metadata = { - title: "Pi Web", - description: "Pi Web interface for the pi coding agent", + title: "Pi Agent Web", + description: "Pi Coding Agent Web Interface", applicationName: "Pi Web", manifest: "/manifest.webmanifest", icons: { @@ -59,7 +72,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - +