feat(server,web): DB-served session list, CLI-session visibility toggle, and sidebar group paging (#158)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,12 @@ export interface UiPrefs {
|
|||||||
lastProjectId?: string;
|
lastProjectId?: string;
|
||||||
/** Whether the "no API key configured" guide has already been shown: once ever (on first visit to the chat page). */
|
/** Whether the "no API key configured" guide has already been shown: once ever (on first visit to the chat page). */
|
||||||
credentialGuideSeen?: boolean;
|
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;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,5 +24,17 @@ export function openDatabase(dbPath: string): DatabaseSync {
|
|||||||
db.exec("PRAGMA journal_mode = WAL;");
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
db.exec("PRAGMA foreign_keys = ON;");
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
db.exec(SCHEMA_SQL);
|
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;
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,16 @@ export interface SessionRow {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
|
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
|
||||||
archivedAt?: string | 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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +43,8 @@ function mapRow(r: Record<string, unknown>): SessionRow {
|
|||||||
approvalMode: r.approval_mode as ApprovalMode,
|
approvalMode: r.approval_mode as ApprovalMode,
|
||||||
title: (r.title as string | null) ?? null,
|
title: (r.title as string | null) ?? null,
|
||||||
archivedAt: (r.archived_at 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,
|
createdAt: r.created_at as string,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -45,8 +55,8 @@ export class SessionsRepo {
|
|||||||
insert(row: SessionRow): void {
|
insert(row: SessionRow): void {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
row.sessionId,
|
row.sessionId,
|
||||||
@@ -57,6 +67,8 @@ export class SessionsRepo {
|
|||||||
row.workspace,
|
row.workspace,
|
||||||
row.approvalMode,
|
row.approvalMode,
|
||||||
row.title,
|
row.title,
|
||||||
|
row.client ?? null,
|
||||||
|
row.hasTrace ? 1 : 0,
|
||||||
row.createdAt,
|
row.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,8 +77,8 @@ export class SessionsRepo {
|
|||||||
insertOrIgnore(row: SessionRow): void {
|
insertOrIgnore(row: SessionRow): void {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
|
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
row.sessionId,
|
row.sessionId,
|
||||||
@@ -77,18 +89,34 @@ export class SessionsRepo {
|
|||||||
row.workspace,
|
row.workspace,
|
||||||
row.approvalMode,
|
row.approvalMode,
|
||||||
row.title,
|
row.title,
|
||||||
|
row.client ?? null,
|
||||||
|
row.hasTrace ? 1 : 0,
|
||||||
row.createdAt,
|
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 {
|
findById(sessionId: string): SessionRow | null {
|
||||||
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
||||||
return r ? mapRow(r) : null;
|
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
|
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);
|
.all(projectId, agentId);
|
||||||
return rows.map(mapRow);
|
return rows.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
* SQLite stores only indexes and aggregates: users / login sessions / Project authorization /
|
* SQLite stores only indexes and aggregates: users / login sessions / Project authorization /
|
||||||
* Agent & Session indexes / usage summaries / error records / UI preferences. Agent State,
|
* Agent & Session indexes / usage summaries / error records / UI preferences. Agent State,
|
||||||
* Trace, and Workspace still follow the local directory-based storage rules.
|
* 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 = `
|
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
|
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")
|
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")
|
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
|
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 (
|
CREATE TABLE IF NOT EXISTS usage_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts TEXT NOT NULL,
|
ts TEXT NOT NULL,
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ function parseGoalField(body: Record<string, unknown>): { budget: number } | nul
|
|||||||
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// `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) => {
|
app.get("/", async (c) => {
|
||||||
// Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects).
|
// Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects).
|
||||||
const projectId = requireValidId(c, "projectId");
|
const projectId = requireValidId(c, "projectId");
|
||||||
@@ -234,6 +236,8 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
}
|
}
|
||||||
const rawCounts = c.req.query("counts");
|
const rawCounts = c.req.query("counts");
|
||||||
if (rawCounts !== undefined && rawCounts !== "1") throw badRequest("counts only accepts 1.");
|
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(
|
const { sessions, counts, workspaceCounts } = await deps.sessionService.listSessions(
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -241,6 +245,7 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
...(paging ? { paging } : {}),
|
...(paging ? { paging } : {}),
|
||||||
...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}),
|
...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}),
|
||||||
...(rawCounts !== undefined ? { withCounts: true } : {}),
|
...(rawCounts !== undefined ? { withCounts: true } : {}),
|
||||||
|
...(rawCli !== undefined ? { includeCli: true } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|||||||
@@ -1056,6 +1056,10 @@ export class SessionManager {
|
|||||||
gen: AsyncGenerator<OmniMessage>,
|
gen: AsyncGenerator<OmniMessage>,
|
||||||
titleSource?: { userExcerpt: string },
|
titleSource?: { userExcerpt: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 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 earlyTitleFired = false;
|
||||||
let mainBodyChars = 0;
|
let mainBodyChars = 0;
|
||||||
const ctx: UsageContext = {
|
const ctx: UsageContext = {
|
||||||
@@ -1276,6 +1280,8 @@ export class SessionManager {
|
|||||||
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
|
||||||
approvalMode: "allow-all",
|
approvalMode: "allow-all",
|
||||||
title: null,
|
title: null,
|
||||||
|
// Spawned by this server's run (client NULL = web); its Trace exists by construction.
|
||||||
|
hasTrace: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
// Make the subagent appear immediately in the sidebar: notify via the parent
|
// Make the subagent appear immediately in the sidebar: notify via the parent
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Session index service.
|
* Session index service.
|
||||||
*
|
*
|
||||||
* The list is DB index ∪ Trace directory discovery: scans
|
* The default list is served from the DB index alone (web rows: `client` web/NULL) — no
|
||||||
* `<agent>/traces/<date>/<session_id>_<index3>.jsonl`; an unmanaged Session (e.g.
|
* Trace directory scanning in steady state (#139). `includeCli` widens it to DB ∪ Trace
|
||||||
* one started via the CLI) has its first line's session_meta read for
|
* directory discovery: scans `<agent>/traces/<date>/<session_id>_<index3>.jsonl`; an
|
||||||
* (provider, model_id) / workspace, which is backfilled into a DB row
|
* unmanaged Session (one started via the CLI) has its first line's session_meta read for
|
||||||
* (approval_mode defaults, createdAt is taken from the timestamp embedded in
|
* (provider, model_id) / workspace, which is backfilled into a DB row marked
|
||||||
* session_id).
|
* `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
|
* 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
|
* (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
|
* 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
|
* List, sorted by createdAt descending. The default serves **web sessions straight from
|
||||||
* `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
|
* the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to
|
||||||
* more"); slicing happens before toInfo, so per-request source derivation (lazy
|
* many-session sidebar reloads re-walking the filesystem on every request (#139). One
|
||||||
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
|
* lazy discovery walk still runs for a list call that contains rows this process has not
|
||||||
* the whole directory — the union and global ordering need every id.
|
* 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
|
* `category` filters to one sidebar bucket **before** paging, so offset/limit page
|
||||||
* within the category. Filtering needs each walked row's category (a possible
|
* within the category. Filtering needs each walked row's category (a possible
|
||||||
@@ -196,30 +206,50 @@ export class SessionService {
|
|||||||
paging?: { offset: number; limit: number };
|
paging?: { offset: number; limit: number };
|
||||||
category?: SessionCategory;
|
category?: SessionCategory;
|
||||||
withCounts?: boolean;
|
withCounts?: boolean;
|
||||||
|
includeCli?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
sessions: SessionInfo[];
|
sessions: SessionInfo[];
|
||||||
counts?: SessionCategoryCounts;
|
counts?: SessionCategoryCounts;
|
||||||
workspaceCounts?: Record<string, SessionCategoryCounts>;
|
workspaceCounts?: Record<string, SessionCategoryCounts>;
|
||||||
}> {
|
}> {
|
||||||
const { paging, category, withCounts } = opts;
|
const { paging, category, withCounts, includeCli } = opts;
|
||||||
const traces = await this.discoverTraces(projectId, agentId);
|
|
||||||
const rows = new Map(
|
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.
|
let traces: ReadonlyMap<string, TraceLocation> | undefined;
|
||||||
for (const [sessionId, location] of traces) {
|
if (includeCli) {
|
||||||
if (rows.has(sessionId)) continue;
|
traces = await this.discoverTraces(projectId, agentId);
|
||||||
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
|
// Unmanaged Trace Sessions (the CLI's): backfill an index row by reading the first
|
||||||
if (discovered) rows.set(sessionId, discovered);
|
// 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(
|
const sorted = [...rows.values()].sort(
|
||||||
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
|
(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[]) =>
|
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).
|
// No classification asked for: slice straight away (the pre-category behavior).
|
||||||
if (category === undefined && !withCounts) {
|
if (category === undefined && !withCounts) {
|
||||||
@@ -236,7 +266,7 @@ export class SessionService {
|
|||||||
const matched: SessionRow[] = [];
|
const matched: SessionRow[] = [];
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
if (!withCounts && matched.length >= want) break;
|
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;
|
counts[cat] += 1;
|
||||||
if (withCounts) {
|
if (withCounts) {
|
||||||
const ws = (workspaceCounts[row.workspace] ??= {
|
const ws = (workspaceCounts[row.workspace] ??= {
|
||||||
@@ -384,6 +414,9 @@ export class SessionService {
|
|||||||
workspace: session.workspaceDir,
|
workspace: session.workspaceDir,
|
||||||
approvalMode: args.approvalMode ?? "allow-all",
|
approvalMode: args.approvalMode ?? "allow-all",
|
||||||
title: null,
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.deps.sessions.insert(row);
|
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.
|
// 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",
|
approvalMode: "allow-all",
|
||||||
title: null,
|
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,
|
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).
|
// Idempotent backfill: concurrent list calls may discover the same Session for the first time simultaneously (consistent with AgentsRepo's convention).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,7 +194,7 @@ describe("session-index", () => {
|
|||||||
sessionMeta(junkMeta),
|
sessionMeta(junkMeta),
|
||||||
userText("junk"),
|
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 === adopted)?.source).toBe("schedule");
|
||||||
expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined();
|
expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -395,7 +395,13 @@ describe("session-index", () => {
|
|||||||
userText("cli session"),
|
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);
|
const found = list.sessions.find((s) => s.sessionId === discovered);
|
||||||
expect(found).toBeDefined();
|
expect(found).toBeDefined();
|
||||||
expect(found!.modelId).toBe("cli-model");
|
expect(found!.modelId).toBe("cli-model");
|
||||||
@@ -404,11 +410,33 @@ describe("session-index", () => {
|
|||||||
expect(found!.hasTrace).toBe(true);
|
expect(found!.hasTrace).toBe(true);
|
||||||
expect(found!.createdAt).toBe(sessionIdCreatedAt(discovered));
|
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}`);
|
const single = await api.get(`/api/sessions/${discovered}`);
|
||||||
expect(single.status).toBe(200);
|
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 () => {
|
it("DELETE Session: clears the index row and every Trace shard; the list doesn't resurrect it; re-delete 404", async () => {
|
||||||
await configureModels();
|
await configureModels();
|
||||||
const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse;
|
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 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[0]!.sessionId).toBe(created.session.sessionId);
|
||||||
expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older);
|
expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ describe("session-manager", () => {
|
|||||||
expect(first.payload.text).toBe("hello");
|
expect(first.payload.text).toBe("hello");
|
||||||
const states = serverEvents(events).filter((e) => e.type === "task_state");
|
const states = serverEvents(events).filter((e) => e.type === "task_state");
|
||||||
expect(states.map((s) => s.state)).toEqual(["running", "idle"]);
|
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.
|
// Outputs and events are forwarded one by one and handed to the recorder.
|
||||||
expect(recordedCtx[0]).toEqual({
|
expect(recordedCtx[0]).toEqual({
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
|
|||||||
@@ -221,12 +221,20 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
|
|||||||
export const listSessions = (
|
export const listSessions = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: 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
|
const qs = opts
|
||||||
? `?limit=${opts.limit}&offset=${opts.offset}` +
|
? `?limit=${opts.limit}&offset=${opts.offset}` +
|
||||||
(opts.category ? `&category=${opts.category}` : "") +
|
(opts.category ? `&category=${opts.category}` : "") +
|
||||||
(opts.withCounts ? "&counts=1" : "")
|
(opts.withCounts ? "&counts=1" : "") +
|
||||||
|
(opts.cli ? "&cli=1" : "")
|
||||||
: "";
|
: "";
|
||||||
return apiFetch<SessionsResponse>(
|
return apiFetch<SessionsResponse>(
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${qs}`,
|
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${qs}`,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { agentDisplayName, projectDisplayName, useProject } from "../../state/pr
|
|||||||
import { useSessions } from "../../state/sessions";
|
import { useSessions } from "../../state/sessions";
|
||||||
import {
|
import {
|
||||||
FOLDER_CATEGORIES,
|
FOLDER_CATEGORIES,
|
||||||
|
SIDEBAR_GROUP_PAGE_SIZE,
|
||||||
SIDEBAR_PAGE_SIZE,
|
SIDEBAR_PAGE_SIZE,
|
||||||
aggregateWorkspaceCounts,
|
aggregateWorkspaceCounts,
|
||||||
groupSessionsByWorkspace,
|
groupSessionsByWorkspace,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
workspaceGroupKey,
|
workspaceGroupKey,
|
||||||
} from "../../lib/session-grouping";
|
} from "../../lib/session-grouping";
|
||||||
import type { FolderCategory, SessionPartition } from "../../lib/session-grouping";
|
import type { FolderCategory, SessionPartition } from "../../lib/session-grouping";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
import { Dropdown } from "../ui/dropdown";
|
import { Dropdown } from "../ui/dropdown";
|
||||||
import { AgentAvatar } from "../ui/agent-avatar";
|
import { AgentAvatar } from "../ui/agent-avatar";
|
||||||
import { Chevron } from "../ui/chevron";
|
import { Chevron } from "../ui/chevron";
|
||||||
@@ -214,6 +216,8 @@ export function Sidebar({
|
|||||||
loading,
|
loading,
|
||||||
remove,
|
remove,
|
||||||
replace,
|
replace,
|
||||||
|
showCliSessions,
|
||||||
|
setShowCliSessions,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const chatMatch = useMatch("/chat/:sessionId");
|
const chatMatch = useMatch("/chat/:sessionId");
|
||||||
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
||||||
@@ -281,6 +285,7 @@ export function Sidebar({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedGroups(loadGroupSet(collapseStoreKey));
|
setCollapsedGroups(loadGroupSet(collapseStoreKey));
|
||||||
setPinnedGroups(loadGroupSet(pinStoreKey));
|
setPinnedGroups(loadGroupSet(pinStoreKey));
|
||||||
|
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
|
||||||
}, [collapseStoreKey, pinStoreKey]);
|
}, [collapseStoreKey, pinStoreKey]);
|
||||||
/** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
|
/** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
|
||||||
const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
|
const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
|
||||||
@@ -288,6 +293,8 @@ export function Sidebar({
|
|||||||
const [pendingLoads, setPendingLoads] = useState<ReadonlySet<string>>(new Set());
|
const [pendingLoads, setPendingLoads] = useState<ReadonlySet<string>>(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. */
|
/** 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<ReadonlyMap<string, number>>(new Map());
|
const [groupCaps, setGroupCaps] = useState<ReadonlyMap<string, number>>(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). */
|
/** Session pending delete confirmation (null = none). */
|
||||||
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
||||||
const [deletingBusy, setDeletingBusy] = useState(false);
|
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||||
@@ -300,6 +307,8 @@ export function Sidebar({
|
|||||||
const setGroupMode = (mode: GroupMode) => {
|
const setGroupMode = (mode: GroupMode) => {
|
||||||
localStorage.setItem(GROUP_MODE_KEY, mode);
|
localStorage.setItem(GROUP_MODE_KEY, mode);
|
||||||
setGroupModeState(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. */
|
/** 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) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGroupCap((c) => c + SIDEBAR_GROUP_PAGE_SIZE)}
|
||||||
|
className={`${folderClass} mt-1`}
|
||||||
|
>
|
||||||
|
<span className="w-3" aria-hidden />
|
||||||
|
{S.chat.moreGroups(total - groupCap)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
const navItems: Array<{ to: string; label: string; icon: string }> = [
|
const navItems: Array<{ to: string; label: string; icon: string }> = [
|
||||||
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
|
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
|
||||||
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
|
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
|
||||||
@@ -869,7 +890,7 @@ export function Sidebar({
|
|||||||
loading && agents.length === 0 ? (
|
loading && agents.length === 0 ? (
|
||||||
<SkeletonList rows={5} />
|
<SkeletonList rows={5} />
|
||||||
) : (
|
) : (
|
||||||
orderedAgents.map((agent) => {
|
orderedAgents.slice(0, groupCap).map((agent) => {
|
||||||
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
|
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
|
||||||
const collapsed = collapsedGroups.has(agent.agentId);
|
const collapsed = collapsedGroups.has(agent.agentId);
|
||||||
const pinned = pinnedGroups.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 ? (
|
||||||
<SkeletonList rows={5} />
|
<SkeletonList rows={5} />
|
||||||
) : orderedWorkspaceGroups.length === 0 ? (
|
) : orderedWorkspaceGroups.length === 0 ? (
|
||||||
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
|
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
|
||||||
{S.chat.noSessions}
|
{S.chat.noSessions}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
orderedWorkspaceGroups.map((group) => {
|
orderedWorkspaceGroups.slice(0, groupCap).map((group) => {
|
||||||
const parts = partitionSessions(group.sessions);
|
const parts = partitionSessions(group.sessions);
|
||||||
const collapsed = collapsedGroups.has(group.key);
|
const collapsed = collapsedGroups.has(group.key);
|
||||||
const pinned = pinnedGroups.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}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom user config */}
|
{/* Bottom user config */}
|
||||||
@@ -1058,6 +1086,11 @@ export function Sidebar({
|
|||||||
<SettingRow label={S.settings.language}>
|
<SettingRow label={S.settings.language}>
|
||||||
<Segmented options={langOptions} value={lang} onChange={setLang} />
|
<Segmented options={langOptions} value={lang} onChange={setLang} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
{/* Off (default) = the sidebar lists only web-created Sessions, served straight
|
||||||
|
from the DB; on = CLI Sessions are discovered from the Trace directory too. */}
|
||||||
|
<SettingRow label={S.settings.showCliSessions}>
|
||||||
|
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
|
||||||
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export function workspaceLabel(workspace: string): string {
|
|||||||
*/
|
*/
|
||||||
export const SIDEBAR_PAGE_SIZE = 10;
|
export const SIDEBAR_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups (Agents / Workspace groups) rendered per sidebar "page": the initial render cap,
|
||||||
|
* raised by one page per "more groups" click. A pure display cap — with dozens of groups the
|
||||||
|
* full list renders too tall to scan (#139); the groups' data loading is unchanged.
|
||||||
|
*/
|
||||||
|
export const SIDEBAR_GROUP_PAGE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the limit+1 fetch trick: `fetched` came from a request with `limit = pageSize + 1`;
|
* Applies the limit+1 fetch trick: `fetched` came from a request with `limit = pageSize + 1`;
|
||||||
* the visible page is the first `pageSize` items, and an overflow item (never shown) proves
|
* the visible page is the first `pageSize` items, and an overflow item (never shown) proves
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const en: Strings = {
|
|||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
language: "Language",
|
language: "Language",
|
||||||
|
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||||
|
showCliSessions: "Show CLI sessions",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
themeLight: "Light",
|
themeLight: "Light",
|
||||||
themeDark: "Dark",
|
themeDark: "Dark",
|
||||||
@@ -798,6 +800,8 @@ Scenarios:
|
|||||||
openPreview: "Click to preview",
|
openPreview: "Click to preview",
|
||||||
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
|
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
|
||||||
showLess: "Show less",
|
showLess: "Show less",
|
||||||
|
/** Reveal the next page of sidebar groups (#139); n = groups still hidden. */
|
||||||
|
moreGroups: (n: number) => `More groups (${n})`,
|
||||||
contextUsage: "Context usage",
|
contextUsage: "Context usage",
|
||||||
contextUnknown: "Context usage: unknown until the next request reports it",
|
contextUnknown: "Context usage: unknown until the next request reports it",
|
||||||
slashHint: "Type / for commands",
|
slashHint: "Type / for commands",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const zh = {
|
|||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
language: "语言",
|
language: "语言",
|
||||||
|
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||||
|
showCliSessions: "显示 CLI 会话",
|
||||||
theme: "主题",
|
theme: "主题",
|
||||||
themeLight: "浅色",
|
themeLight: "浅色",
|
||||||
themeDark: "深色",
|
themeDark: "深色",
|
||||||
@@ -779,6 +781,8 @@ Benchmark:
|
|||||||
openPreview: "点击预览",
|
openPreview: "点击预览",
|
||||||
showMoreFiles: (n: number) => `显示其余 ${n} 个文件`,
|
showMoreFiles: (n: number) => `显示其余 ${n} 个文件`,
|
||||||
showLess: "收起",
|
showLess: "收起",
|
||||||
|
/** Reveal the next page of sidebar groups (#139); n = groups still hidden. */
|
||||||
|
moreGroups: (n: number) => `更多分组(${n})`,
|
||||||
contextUsage: "上下文占用",
|
contextUsage: "上下文占用",
|
||||||
contextUnknown: "上下文占用:压缩后待下次请求回报",
|
contextUnknown: "上下文占用:压缩后待下次请求回报",
|
||||||
slashHint: "输入 / 使用命令",
|
slashHint: "输入 / 使用命令",
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ interface SessionsContextValue {
|
|||||||
setStatus: (sessionId: string, status: SessionStatus) => void;
|
setStatus: (sessionId: string, status: SessionStatus) => void;
|
||||||
/** session_title server event → update the title in place. */
|
/** session_title server event → update the title in place. */
|
||||||
setTitle: (sessionId: string, title: string) => void;
|
setTitle: (sessionId: string, title: string) => void;
|
||||||
|
/** Whether CLI-created Sessions are listed too (persisted per user; default off = the list is served from the DB without Trace scanning). */
|
||||||
|
showCliSessions: boolean;
|
||||||
|
/** Flip the CLI-session preference: persists it and refetches the whole list under the new filter. */
|
||||||
|
setShowCliSessions: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionsContext = createContext<SessionsContextValue | null>(null);
|
const SessionsContext = createContext<SessionsContextValue | null>(null);
|
||||||
@@ -108,6 +112,28 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
|
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// "Show CLI sessions" preference: server-persisted per user (ui_prefs); hydrated once on
|
||||||
|
// mount, default off. Changing it recreates reload(), and the reset effect below refetches
|
||||||
|
// the whole list under the new filter.
|
||||||
|
const [showCliSessions, setShowCliSessionsState] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void api
|
||||||
|
.getPrefs()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled && res.prefs.showCliSessions === true) setShowCliSessionsState(true);
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // Unreachable prefs: stay with the default (web only).
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const setShowCliSessions = useCallback((value: boolean) => {
|
||||||
|
setShowCliSessionsState(value);
|
||||||
|
// Fire-and-forget: PUT /me/prefs merges shallowly; a lost write only costs persistence,
|
||||||
|
// the in-memory toggle already took effect.
|
||||||
|
void api.putPrefs({ showCliSessions: value }).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
||||||
// changes or a reload happens.
|
// changes or a reload happens.
|
||||||
const gen = useRef(0);
|
const gen = useRef(0);
|
||||||
@@ -142,6 +168,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
limit: SIDEBAR_PAGE_SIZE + 1,
|
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||||
category,
|
category,
|
||||||
...(category === "active" ? { withCounts: true } : {}),
|
...(category === "active" ? { withCounts: true } : {}),
|
||||||
|
...(showCliSessions ? { cli: true } : {}),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
category,
|
category,
|
||||||
@@ -190,7 +217,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (g === gen.current) setLoading(false);
|
if (g === gen.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectId, agentIdsKey]);
|
}, [projectId, agentIdsKey, showCliSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
@@ -232,6 +259,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
offset,
|
offset,
|
||||||
limit: SIDEBAR_PAGE_SIZE + 1,
|
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||||
category,
|
category,
|
||||||
|
...(showCliSessions ? { cli: true } : {}),
|
||||||
})
|
})
|
||||||
).sessions;
|
).sessions;
|
||||||
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
||||||
@@ -261,7 +289,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[projectId],
|
[projectId, showCliSessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoadedFor = useCallback(
|
const isLoadedFor = useCallback(
|
||||||
@@ -413,6 +441,8 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
replace,
|
replace,
|
||||||
setStatus,
|
setStatus,
|
||||||
setTitle,
|
setTitle,
|
||||||
|
showCliSessions,
|
||||||
|
setShowCliSessions,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
sessions,
|
sessions,
|
||||||
@@ -428,6 +458,8 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
|||||||
replace,
|
replace,
|
||||||
setStatus,
|
setStatus,
|
||||||
setTitle,
|
setTitle,
|
||||||
|
showCliSessions,
|
||||||
|
setShowCliSessions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
||||||
|
|||||||
Reference in New Issue
Block a user