diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index d6d207e..d3306cc 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -110,6 +110,12 @@ export interface UiPrefs { lastProjectId?: string; /** Whether the "no API key configured" guide has already been shown: once ever (on first visit to the chat page). */ credentialGuideSeen?: boolean; + /** + * Also list CLI-created Sessions in the sidebar (`cli=1` on the sessions list). Default + * off: the list then serves web rows straight from the DB, with no Trace-directory + * scanning (#139). + */ + showCliSessions?: boolean; [key: string]: unknown; } diff --git a/packages/server/src/db/database.ts b/packages/server/src/db/database.ts index 6fd4467..3509612 100644 --- a/packages/server/src/db/database.ts +++ b/packages/server/src/db/database.ts @@ -24,5 +24,17 @@ export function openDatabase(dbPath: string): DatabaseSync { db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec(SCHEMA_SQL); + // Columns added to the schema after a web.db was formed: CREATE TABLE IF NOT EXISTS never + // touches an existing table, so they are ALTERed in here. Keep the list in sync with + // schema.ts; drop entries only in a release allowed to break existing web.db files. + ensureColumn(db, "sessions", "client", "TEXT"); + ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0"); return db; } + +/** Idempotent per-column upgrade for databases formed before the column existed. */ +function ensureColumn(db: DatabaseSync, table: string, column: string, ddl: string): void { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + if (cols.some((c) => c.name === column)) return; + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`); +} diff --git a/packages/server/src/db/repos/sessions.ts b/packages/server/src/db/repos/sessions.ts index 522b4c9..7196c23 100644 --- a/packages/server/src/db/repos/sessions.ts +++ b/packages/server/src/db/repos/sessions.ts @@ -19,8 +19,16 @@ export interface SessionRow { title: string | null; /** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */ archivedAt?: string | null; - // The Session origin (schedule / subagent) is deliberately NOT a row field: core - // session_meta in the Trace is the single source of truth (runtime/session-sources.ts). + /** + * Creating client: "web" (created via the Web App/server) or "cli" (adopted from a Trace + * the CLI left behind); NULL = legacy row from before the column existed, treated as web. + * The schedule/subagent SOURCE is deliberately NOT a row field — core session_meta in the + * Trace stays the single source of truth for it (runtime/session-sources.ts); `client` is + * a separate, DB-only axis that meta never records. + */ + client?: "web" | "cli" | null; + /** Cache: a Trace record exists (set at task start / adoption / subagent registration; backfilled by list hydration). */ + hasTrace?: boolean; createdAt: string; } @@ -35,6 +43,8 @@ function mapRow(r: Record): SessionRow { approvalMode: r.approval_mode as ApprovalMode, title: (r.title as string | null) ?? null, archivedAt: (r.archived_at as string | null) ?? null, + client: (r.client as "web" | "cli" | null) ?? null, + hasTrace: (r.has_trace as number) === 1, createdAt: r.created_at as string, }; } @@ -45,8 +55,8 @@ export class SessionsRepo { insert(row: SessionRow): void { this.db .prepare( - `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( row.sessionId, @@ -57,6 +67,8 @@ export class SessionsRepo { row.workspace, row.approvalMode, row.title, + row.client ?? null, + row.hasTrace ? 1 : 0, row.createdAt, ); } @@ -65,8 +77,8 @@ export class SessionsRepo { insertOrIgnore(row: SessionRow): void { this.db .prepare( - `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( row.sessionId, @@ -77,18 +89,34 @@ export class SessionsRepo { row.workspace, row.approvalMode, row.title, + row.client ?? null, + row.hasTrace ? 1 : 0, row.createdAt, ); } + /** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */ + markHasTrace(sessionId: string): void { + this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId); + } + findById(sessionId: string): SessionRow | null { const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId); return r ? mapRow(r) : null; } - listByAgent(projectId: string, agentId: string): SessionRow[] { + /** + * An Agent's rows, newest first (the list order the sidebar shows; served by + * idx_sessions_agent_created). `webOnly` keeps only web-created rows — NULL counts as + * web (legacy rows predate the column and the user chose to grandfather them as visible). + */ + listByAgent(projectId: string, agentId: string, opts: { webOnly?: boolean } = {}): SessionRow[] { + const filter = opts.webOnly ? " AND (client IS NULL OR client = 'web')" : ""; const rows = this.db - .prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?") + .prepare( + `SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?${filter} + ORDER BY created_at DESC, session_id DESC`, + ) .all(projectId, agentId); return rows.map(mapRow); } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 2db7087..ffbe4ca 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -4,7 +4,10 @@ * SQLite stores only indexes and aggregates: users / login sessions / Project authorization / * Agent & Session indexes / usage summaries / error records / UI preferences. Agent State, * Trace, and Workspace still follow the local directory-based storage rules. - * Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed once. + * Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed + * once. The one exception: columns added to an existing table after release of a web.db are + * ALTERed in by the idempotent per-column guard in database.ts (ensureColumn), since CREATE + * TABLE IF NOT EXISTS never touches an existing table. */ export const SCHEMA_SQL = ` @@ -48,8 +51,11 @@ CREATE TABLE IF NOT EXISTS sessions ( approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat") archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived") + client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web + has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration) created_at TEXT NOT NULL -); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts) +); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row) +CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC); CREATE TABLE IF NOT EXISTS usage_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index 44330ac..369b4b8 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -217,6 +217,8 @@ function parseGoalField(body: Record): { budget: number } | nul export function agentSessionsRoutes(deps: AppDeps): Hono { const app = new Hono(); + // `cli=1` widens the list to CLI-created Sessions (Trace-directory discovery + adoption); + // the default serves web rows straight from the DB (see SessionService.listSessions). app.get("/", async (c) => { // Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects). const projectId = requireValidId(c, "projectId"); @@ -234,6 +236,8 @@ export function agentSessionsRoutes(deps: AppDeps): Hono { } const rawCounts = c.req.query("counts"); if (rawCounts !== undefined && rawCounts !== "1") throw badRequest("counts only accepts 1."); + const rawCli = c.req.query("cli"); + if (rawCli !== undefined && rawCli !== "1") throw badRequest("cli only accepts 1."); const { sessions, counts, workspaceCounts } = await deps.sessionService.listSessions( projectId, agentId, @@ -241,6 +245,7 @@ export function agentSessionsRoutes(deps: AppDeps): Hono { ...(paging ? { paging } : {}), ...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}), ...(rawCounts !== undefined ? { withCounts: true } : {}), + ...(rawCli !== undefined ? { includeCli: true } : {}), }, ); return c.json({ diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index a1b7a08..952984a 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -1056,6 +1056,10 @@ export class SessionManager { gen: AsyncGenerator, titleSource?: { userExcerpt: string }, ): Promise { + // Every driven run (task, goal round, compaction) writes Trace lines: flip the row's + // has_trace cache here, the single choke point, so listing can serve it from the DB + // without a directory walk (see SessionService.listSessions). + this.deps.sessions.markHasTrace(entry.sessionId); let earlyTitleFired = false; let mainBodyChars = 0; const ctx: UsageContext = { @@ -1276,6 +1280,8 @@ export class SessionManager { // inserted with defaults (matches the convention for Sessions discovered by the CLI). approvalMode: "allow-all", title: null, + // Spawned by this server's run (client NULL = web); its Trace exists by construction. + hasTrace: true, createdAt: new Date().toISOString(), }); // Make the subagent appear immediately in the sidebar: notify via the parent diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index 295c2ab..c173adf 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -1,12 +1,14 @@ /** * Session index service. * - * The list is DB index ∪ Trace directory discovery: scans - * `/traces//_.jsonl`; an unmanaged Session (e.g. - * one started via the CLI) has its first line's session_meta read for - * (provider, model_id) / workspace, which is backfilled into a DB row - * (approval_mode defaults, createdAt is taken from the timestamp embedded in - * session_id). + * The default list is served from the DB index alone (web rows: `client` web/NULL) — no + * Trace directory scanning in steady state (#139). `includeCli` widens it to DB ∪ Trace + * directory discovery: scans `/traces//_.jsonl`; an + * unmanaged Session (one started via the CLI) has its first line's session_meta read for + * (provider, model_id) / workspace, which is backfilled into a DB row marked + * `client: "cli"` (approval_mode defaults, createdAt is taken from the timestamp embedded + * in session_id) — the default list keeps excluding it, but it becomes individually + * reachable (deep links). * Create: via core's `agent.createSession` (the model reference is always a complete * (provider, modelId) pair — both or neither; omitting both falls back to the * Project's default reference, 400 if there is none); the new Session is @@ -175,11 +177,19 @@ export class SessionService { } /** - * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional - * `paging` returns just that slice (the sidebar pages with limit+1 to detect "has - * more"); slicing happens before toInfo, so per-request source derivation (lazy - * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans - * the whole directory — the union and global ordering need every id. + * List, sorted by createdAt descending. The default serves **web sessions straight from + * the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to + * many-session sidebar reloads re-walking the filesystem on every request (#139). One + * lazy discovery walk still runs for a list call that contains rows this process has not + * classified yet (no in-process source entry): it supplies the Trace locations for the + * one-time head reads and backfills the `has_trace` cache; once every row is classified, + * list calls touch only the DB. `includeCli` widens the list to DB ∪ Trace directory + * discovery (adopting unmanaged CLI Traces as `client: "cli"` rows), which inherently + * scans — that path is opt-in via the "show CLI sessions" preference. + * + * Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect + * "has more"); slicing happens before toInfo, so per-request source derivation (lazy + * Trace-head reads) stays bounded by the page size. * * `category` filters to one sidebar bucket **before** paging, so offset/limit page * within the category. Filtering needs each walked row's category (a possible @@ -196,30 +206,50 @@ export class SessionService { paging?: { offset: number; limit: number }; category?: SessionCategory; withCounts?: boolean; + includeCli?: boolean; } = {}, ): Promise<{ sessions: SessionInfo[]; counts?: SessionCategoryCounts; workspaceCounts?: Record; }> { - const { paging, category, withCounts } = opts; - const traces = await this.discoverTraces(projectId, agentId); + const { paging, category, withCounts, includeCli } = opts; const rows = new Map( - this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]), + this.deps.sessions + .listByAgent(projectId, agentId, { webOnly: !includeCli }) + .map((r) => [r.sessionId, r]), ); - // Unmanaged Trace Sessions: backfill an index row by reading the first line's session_meta. - for (const [sessionId, location] of traces) { - if (rows.has(sessionId)) continue; - const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location); - if (discovered) rows.set(sessionId, discovered); + let traces: ReadonlyMap | undefined; + if (includeCli) { + traces = await this.discoverTraces(projectId, agentId); + // Unmanaged Trace Sessions (the CLI's): backfill an index row by reading the first + // line's session_meta, marked client "cli" so the default list can exclude them. + for (const [sessionId, location] of traces) { + if (rows.has(sessionId)) continue; + const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location); + if (discovered) rows.set(sessionId, discovered); + } + } else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === undefined)) { + // Hydration walk: some rows predate this process and are unclassified — locate their + // Traces once so sourceOf's head reads (cached afterwards) and the has_trace cache + // don't have to walk per row. Steady state (everything classified) skips this. + traces = await this.discoverTraces(projectId, agentId); + for (const row of rows.values()) { + if (!row.hasTrace && traces.has(row.sessionId)) { + row.hasTrace = true; + this.deps.sessions.markHasTrace(row.sessionId); + } + } } const sorted = [...rows.values()].sort( (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId), ); + const rowHasTrace = (row: SessionRow): boolean => + traces ? traces.has(row.sessionId) : row.hasTrace === true; const toPage = (page: SessionRow[]) => - Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces))); + Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces))); // No classification asked for: slice straight away (the pre-category behavior). if (category === undefined && !withCounts) { @@ -236,7 +266,7 @@ export class SessionService { const matched: SessionRow[] = []; for (const row of sorted) { if (!withCounts && matched.length >= want) break; - const cat = await this.categoryOf(row, traces.has(row.sessionId), traces); + const cat = await this.categoryOf(row, rowHasTrace(row), traces); counts[cat] += 1; if (withCounts) { const ws = (workspaceCounts[row.workspace] ??= { @@ -384,6 +414,9 @@ export class SessionService { workspace: session.workspaceDir, approvalMode: args.approvalMode ?? "allow-all", title: null, + // Everything created through this service is the Web App's (schedule runs included); + // "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web. + client: "web", createdAt: new Date().toISOString(), }; this.deps.sessions.insert(row); @@ -474,6 +507,10 @@ export class SessionService { // The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value. approvalMode: "allow-all", title: null, + // Adopted = a Trace this server never created, i.e. the CLI's: the default list + // (web-only) excludes these rows; the "show CLI sessions" preference includes them. + client: "cli", + hasTrace: true, createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp, }; // Idempotent backfill: concurrent list calls may discover the same Session for the first time simultaneously (consistent with AgentsRepo's convention). diff --git a/packages/server/test/db-upgrade.test.ts b/packages/server/test/db-upgrade.test.ts new file mode 100644 index 0000000..0d88a2b --- /dev/null +++ b/packages/server/test/db-upgrade.test.ts @@ -0,0 +1,99 @@ +/** + * openDatabase's per-column upgrade guard (ensureColumn): a web.db formed before a column + * existed gets it ALTERed in on open — CREATE TABLE IF NOT EXISTS alone never touches an + * existing table, so without the guard, code writing the new columns would break on every + * pre-existing database. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { openDatabase } from "../src/db/database.js"; +import { SessionsRepo } from "../src/db/repos/sessions.js"; + +const sqlite = process.getBuiltinModule("node:sqlite"); + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "penguin-db-upgrade-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("openDatabase column upgrade", () => { + it("adds client/has_trace to a sessions table formed before the columns existed", () => { + const dbPath = path.join(dir, "web.db"); + // A database formed by the pre-#139 schema: sessions without client / has_trace. + const old = new sqlite.DatabaseSync(dbPath); + old.exec(`CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + workspace TEXT NOT NULL, + approval_mode TEXT NOT NULL DEFAULT 'allow-all', + title TEXT, + archived_at TEXT, + created_at TEXT NOT NULL + );`); + old + .prepare( + `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, created_at) + VALUES ('session-legacy', 'p1', 'a1', 'custom', 'm1', '/w', '2026-01-01T00:00:00.000Z')`, + ) + .run(); + old.close(); + + const db = openDatabase(dbPath); + try { + const repo = new SessionsRepo(db); + // The legacy row reads back with the grandfathered defaults: client NULL (treated as + // web — it stays in the default list) and has_trace false. + const legacy = repo.findById("session-legacy"); + expect(legacy).not.toBeNull(); + expect(legacy!.client).toBeNull(); + expect(legacy!.hasTrace).toBe(false); + expect(repo.listByAgent("p1", "a1", { webOnly: true }).map((r) => r.sessionId)).toEqual([ + "session-legacy", + ]); + // The upgraded table accepts writes to the new columns. + repo.insert({ + sessionId: "session-new", + projectId: "p1", + agentId: "a1", + provider: "custom", + modelId: "m1", + workspace: "/w", + approvalMode: "allow-all", + title: null, + client: "cli", + hasTrace: true, + createdAt: "2026-01-02T00:00:00.000Z", + }); + expect(repo.findById("session-new")!.client).toBe("cli"); + expect(repo.listByAgent("p1", "a1", { webOnly: true }).map((r) => r.sessionId)).toEqual([ + "session-legacy", + ]); + repo.markHasTrace("session-legacy"); + expect(repo.findById("session-legacy")!.hasTrace).toBe(true); + } finally { + db.close(); + } + }); + + it("is idempotent: reopening an already-upgraded database changes nothing", () => { + const dbPath = path.join(dir, "web.db"); + openDatabase(dbPath).close(); + const db = openDatabase(dbPath); + try { + const cols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]; + expect(cols.filter((c) => c.name === "client")).toHaveLength(1); + expect(cols.filter((c) => c.name === "has_trace")).toHaveLength(1); + } finally { + db.close(); + } + }); +}); diff --git a/packages/server/test/session-index.test.ts b/packages/server/test/session-index.test.ts index 339f7ee..d901ea6 100644 --- a/packages/server/test/session-index.test.ts +++ b/packages/server/test/session-index.test.ts @@ -194,7 +194,7 @@ describe("session-index", () => { sessionMeta(junkMeta), userText("junk"), ]); - const list = (await (await api.get(base())).json()) as SessionsResponse; + const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule"); expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined(); }); @@ -395,7 +395,13 @@ describe("session-index", () => { userText("cli session"), ]); - const list = (await (await api.get(base())).json()) as SessionsResponse; + // The default list is DB-only (web rows): an unmanaged CLI Trace is neither listed + // nor adopted by it (#139 — no Trace-directory scanning on the default path). + const webOnly = (await (await api.get(base())).json()) as SessionsResponse; + expect(webOnly.sessions.find((s) => s.sessionId === discovered)).toBeUndefined(); + expect((await api.get(`/api/sessions/${discovered}`)).status).toBe(404); + + const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; const found = list.sessions.find((s) => s.sessionId === discovered); expect(found).toBeDefined(); expect(found!.modelId).toBe("cli-model"); @@ -404,11 +410,33 @@ describe("session-index", () => { expect(found!.hasTrace).toBe(true); expect(found!.createdAt).toBe(sessionIdCreatedAt(discovered)); - // Already indexed: visible via the single-lookup endpoint. + // Adopted as client "cli": the default list still excludes it afterwards, and counts + // follow the same filter… + const after = (await (await api.get(`${base()}?counts=1`)).json()) as SessionsResponse; + expect(after.sessions.find((s) => s.sessionId === discovered)).toBeUndefined(); + expect(after.counts!.active).toBe(0); + // …but the adopted row makes the Session individually reachable (deep links work). const single = await api.get(`/api/sessions/${discovered}`); expect(single.status).toBe(200); }); + it("legacy rows without a client marker stay visible by default (grandfathered as web)", async () => { + const legacy = "session-2026-07-02-09-00-00-0abc0001"; + t.deps.sessionsRepo.insert({ + sessionId: legacy, + projectId, + agentId: "default_agent", + provider: "custom", + modelId: "m-legacy", + workspace: "/tmp/w-legacy", + approvalMode: "allow-all", + title: null, + createdAt: "2026-07-02T09:00:00.000Z", + }); + const list = (await (await api.get(base())).json()) as SessionsResponse; + expect(list.sessions.find((s) => s.sessionId === legacy)).toBeDefined(); + }); + it("DELETE Session: clears the index row and every Trace shard; the list doesn't resurrect it; re-delete 404", async () => { await configureModels(); const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; @@ -484,7 +512,7 @@ describe("session-index", () => { }), ]); const created = (await (await api.post(base(), {})).json()) as SessionCreateResponse; - const list = (await (await api.get(base())).json()) as SessionsResponse; + const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; expect(list.sessions[0]!.sessionId).toBe(created.session.sessionId); expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older); }); diff --git a/packages/server/test/session-manager.test.ts b/packages/server/test/session-manager.test.ts index ae1783f..6eacf6e 100644 --- a/packages/server/test/session-manager.test.ts +++ b/packages/server/test/session-manager.test.ts @@ -147,6 +147,8 @@ describe("session-manager", () => { expect(first.payload.text).toBe("hello"); const states = serverEvents(events).filter((e) => e.type === "task_state"); expect(states.map((s) => s.state)).toEqual(["running", "idle"]); + // Driving a run flips the row's has_trace cache (listing then never walks for it). + expect(sessions.findById("session-1")!.hasTrace).toBe(true); // Outputs and events are forwarded one by one and handed to the recorder. expect(recordedCtx[0]).toEqual({ projectId: "p1", diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 3872972..e351a1f 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -221,12 +221,20 @@ export const getAgentTraces = (projectId: string, agentId: string) => export const listSessions = ( projectId: string, agentId: string, - opts?: { offset: number; limit: number; category?: SessionCategory; withCounts?: boolean }, + opts?: { + offset: number; + limit: number; + category?: SessionCategory; + withCounts?: boolean; + /** Also list CLI-created Sessions (Trace discovery + adoption); default = web rows straight from the DB. */ + cli?: boolean; + }, ) => { const qs = opts ? `?limit=${opts.limit}&offset=${opts.offset}` + (opts.category ? `&category=${opts.category}` : "") + - (opts.withCounts ? "&counts=1" : "") + (opts.withCounts ? "&counts=1" : "") + + (opts.cli ? "&cli=1" : "") : ""; return apiFetch( `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${qs}`, diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 9963693..6591980 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -36,6 +36,7 @@ import { agentDisplayName, projectDisplayName, useProject } from "../../state/pr import { useSessions } from "../../state/sessions"; import { FOLDER_CATEGORIES, + SIDEBAR_GROUP_PAGE_SIZE, SIDEBAR_PAGE_SIZE, aggregateWorkspaceCounts, groupSessionsByWorkspace, @@ -45,6 +46,7 @@ import { workspaceGroupKey, } from "../../lib/session-grouping"; import type { FolderCategory, SessionPartition } from "../../lib/session-grouping"; +import { Switch } from "../ui/switch"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; @@ -214,6 +216,8 @@ export function Sidebar({ loading, remove, replace, + showCliSessions, + setShowCliSessions, } = useSessions(); const chatMatch = useMatch("/chat/:sessionId"); const activeSessionId = chatMatch?.params.sessionId ?? null; @@ -281,6 +285,7 @@ export function Sidebar({ useEffect(() => { setCollapsedGroups(loadGroupSet(collapseStoreKey)); setPinnedGroups(loadGroupSet(pinStoreKey)); + setGroupCap(SIDEBAR_GROUP_PAGE_SIZE); }, [collapseStoreKey, pinStoreKey]); /** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */ const [openFolders, setOpenFolders] = useState>(new Set()); @@ -288,6 +293,8 @@ export function Sidebar({ const [pendingLoads, setPendingLoads] = useState>(new Set()); /** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */ const [groupCaps, setGroupCaps] = useState>(new Map()); + /** How many GROUPS render (#139: dozens of Agents/Workspaces made the list too tall to scan); "more groups" raises it a page at a time, reset per Project and on a mode switch. */ + const [groupCap, setGroupCap] = useState(SIDEBAR_GROUP_PAGE_SIZE); /** Session pending delete confirmation (null = none). */ const [deletingSession, setDeletingSession] = useState(null); const [deletingBusy, setDeletingBusy] = useState(false); @@ -300,6 +307,8 @@ export function Sidebar({ const setGroupMode = (mode: GroupMode) => { localStorage.setItem(GROUP_MODE_KEY, mode); setGroupModeState(mode); + // The two modes have unrelated group lists: restart the reveal window. + setGroupCap(SIDEBAR_GROUP_PAGE_SIZE); }; /** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */ @@ -665,6 +674,18 @@ export function Sidebar({ ); }; + /** Reveal-next-page-of-groups row (render cap only — data loading is untouched). */ + const moreGroupsRow = (total: number) => ( + + ); + const navItems: Array<{ to: string; label: string; icon: string }> = [ { to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents }, { to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills }, @@ -869,7 +890,7 @@ export function Sidebar({ loading && agents.length === 0 ? ( ) : ( - orderedAgents.map((agent) => { + orderedAgents.slice(0, groupCap).map((agent) => { const parts = partitionSessions(byAgent.get(agent.agentId) ?? []); const collapsed = collapsedGroups.has(agent.agentId); const pinned = pinnedGroups.has(agent.agentId); @@ -933,14 +954,18 @@ export function Sidebar({ ); }) ) - ) : loading && sessions.length === 0 ? ( + ) : null} + {groupMode === "agent" && orderedAgents.length > groupCap + ? moreGroupsRow(orderedAgents.length) + : null} + {groupMode === "agent" ? null : loading && sessions.length === 0 ? ( ) : orderedWorkspaceGroups.length === 0 ? (

{S.chat.noSessions}

) : ( - orderedWorkspaceGroups.map((group) => { + orderedWorkspaceGroups.slice(0, groupCap).map((group) => { const parts = partitionSessions(group.sessions); const collapsed = collapsedGroups.has(group.key); const pinned = pinnedGroups.has(group.key); @@ -1004,6 +1029,9 @@ export function Sidebar({ ); }) )} + {groupMode === "workspace" && orderedWorkspaceGroups.length > groupCap + ? moreGroupsRow(orderedWorkspaceGroups.length) + : null} {/* Bottom user config */} @@ -1058,6 +1086,11 @@ export function Sidebar({ + {/* Off (default) = the sidebar lists only web-created Sessions, served straight + from the DB; on = CLI Sessions are discovered from the Trace directory too. */} + + +