diff --git a/changelog/unreleased/2026-07-22-web-app.md b/changelog/unreleased/2026-07-22-web-app.md index d2ec802..f033835 100644 --- a/changelog/unreleased/2026-07-22-web-app.md +++ b/changelog/unreleased/2026-07-22-web-app.md @@ -1,4 +1,6 @@ -# Web App: Workspace HTML previews open on a separate origin +# Web App + +## Workspace HTML previews open on a separate origin "Open in new tab" for a Workspace html file used to serve the page from the app's own origin under a CSP sandbox without `allow-same-origin`. That kept Agent-generated markup away from the session cookie and the API, but it also put the document in an opaque origin, so `localStorage` and `document.cookie` threw `SecurityError` and any third-party embed on the page — analytics, chat widgets, maps — failed outright. The query-parameter URL had a second problem: a page's relative subresources resolved to `/api/sessions//files/`, a route that does not exist, so multi-file generated apps could never load their own CSS or JS. @@ -9,3 +11,47 @@ Because the origin is now the isolation boundary, the preview response no longer The preview URL takes its port from the server's own binding rather than the browser's current origin. Those differ in development — the SPA runs on Vite's port and only `/api` is proxied — so deriving it from the request would point at a port that does not serve `/preview` at all, and the tab would fail to connect. In production the two coincide. Only a loopback-name bind (`127.0.0.1` / `localhost`) offers a loopback preview; a wildcard bind (`0.0.0.0` / `::`) or a specific non-loopback address has no reachable counterpart — `localhost` may resolve to `::1`, which a `0.0.0.0` bind never serves — so those fall back and must set `PENGUIN_PREVIEW_ORIGIN`, rather than pointing somewhere dead while `/api/me` claims isolation. The link is a plain anchor with `rel="noopener noreferrer"` rather than a fetch followed by `window.open`: opening a tab after an await trips popup blockers, and a script-opened window keeps an `opener` handle back to the app — the exact reference the separate origin exists to deny. The server also binds the IPv6 loopback alongside IPv4, because `localhost` commonly resolves to `::1` first and every preview URL would otherwise refuse the connection. + +## Unified form controls, one notification rule, and deduplicated copy + +The form-control layer had grown five near-identical copies of the same scaffolding. A shared `Field` wrapper now owns the label / hint / error markup, and a shared `controlBase` class string owns the border, hover, focus-ring and dark-mode treatment, so `Input`, `Textarea`, `Select`, `OptionMenu` and `PasswordInput` all read from one source instead of each carrying their own. `Select` dropped its hand-rolled open/close/position effect and now reuses the same `usePortalPanel` hook `OptionMenu` already used, so both menus flip up-or-down, clamp to the viewport and close on scroll/Esc through one code path. The close button triplicated across `Modal` / `Drawer` / `Sheet` became a single `CloseButton`, and the chevron, checkmark and plus glyphs that were inlined at a dozen call sites became shared icon components. The Agent runtime tab's thinking-level and compaction-mode dropdowns dropped their separate "Reset to default" link (a `ResettableOptionMenu` wrapper) in favour of a plain `OptionMenu` — the link only rewound an in-session pick, which closing the dialog already does. + +Notifications now follow one rule. A normal result — a save, an "already up to date", a connection failure — surfaces as a toast that slides in from the top and disappears on its own; the toaster gained an `info` tone alongside success and error for neutral messages. A value error on a specific field no longer lands in a generic red line at the bottom of the form: the offending input turns red and its message sits directly beneath it, so on a failed submit the eye goes straight to the field to fix. Every create/settings form was moved onto this split — required-field, format and mismatch errors went inline, ad-hoc emerald "saved" banners and transient action failures became toasts — with page-load-failure states that pair with a Retry button left inline where a disappearing toast would be wrong. + +Review hardening on that rule: the inline error text is now announced to assistive tech — `FieldError` carries `role="alert"` and every shared control links its message via `aria-describedby`, wired once in the control layer so all forms inherit it. `Select` and `OptionMenu` gained the same `error` prop the text inputs have (red border + message below), so a failed dropdown participates too. And a server rejection now lands under the field its **error code** names — the change-password and create-user dialogs used to pin every rejection to one hardcoded field, so a too-short new password rendered under "Current password" / "Username". + +The i18n catalog was pruned and deduplicated: 41 dead keys removed, and roughly two dozen values that were identical in both languages (Name, Username, Role, Actions, Created, Cost, Time, …) folded into shared `common` keys so a label is defined once. Mid-sentence "Agent" in English copy was lowercased to "agent" — it is a common noun, capitalized only at the start of a label or a sentence — while the domain terms (Workspace, Token, Task, Session, Project, Trace) and proper names (Agent State, AgentHub) keep their capitals. + +## Confirm before a skill update overwrites installs + +Updating a skill install — the card's rotate button, or a per-agent "Update" in the manage-installs dialog — reinstalls the library copy over the agent's files. That overwrite silently dropped any local edits to the installed skill, with no confirmation and (unlike an Agent State import) no snapshot. It now opens a confirmation dialog first: it lists each affected agent with its `v_old → v_new`, warns that the overwrite loses local edits, and suggests exporting a backup if needed. A shared `ConfirmModal` (Cancel / Confirm footer, danger or primary tone) backs it and is reusable for future confirmations. Existing destructive actions already confirmed (deleting a user / agent / project / session / vault entry / schedule), and an Agent State import still auto-snapshots and additionally confirms on a version conflict — those are unchanged. + +## Localized error prompts, full error detail, and required-field markers + +The server's `{error:{code,message}}` message is English-only, so a Chinese-locale user saw untranslated text for anything the frontend didn't map — and only two codes were mapped. `apiErrorText` now localizes ~two dozen common codes (auth, permission, not-found, id-already-taken, file/path, task-in-progress, version-conflict, …) via a new `S.errors.byCode` table, falling back to the raw server message only for unmapped codes, and every error surface that previously showed the raw `e.message` now routes through it. (The stale comment claiming the server messages were Chinese is fixed — they're English.) + +The Cost Center's recent-errors table truncated each message to one line with the full text reachable only through a hover title, so a request failure showed just its head — e.g. `llm request error: 402 openai_error` — and cut the provider's actual reason. Each message still shows on **one line by default** (an error storm shouldn't fill the table), but **clicking it expands the full text in place** (click again to collapse); the full text stays in the hover title too. The server keeps up to 500 chars; the full stack stays in the logs. + +Required form fields now carry a red `*` after the label, so required vs. optional is visible at a glance. The mark is driven by a `required` prop on the shared field controls (`Input`/`Textarea`/`Select`/`OptionMenu`/`PasswordInput`), which also sets `aria-required` while deliberately not forwarding the native `required` attribute (the app validates on submit; browser bubbles would collide with the inline field errors). + +## Sidebar: the session list loads only active sessions; folders load on click and page on their own + +Subagent- and schedule-created Sessions already live in their own labelled, collapsed folders (Subagents / Scheduled), so the small source badge repeated next to each title was noise — it's gone. + +The list's fetching and its "More" affordance were also untangled. Server pagination used to be source-blind: a page of 20 could arrive full of subagent/scheduled/archived rows that all disappear into collapsed folders, so the visible active list stayed short while "More" kept showing (or — worse — active sessions parked behind such pages were unreachable, because a group whose first page held no active row never offered "More" at all). The session list endpoint now takes a `category` filter (`active` / `subagent` / `schedule` / `archived`, the same archived-wins precedence the sidebar renders by) plus an optional `counts=1` for per-category totals, and the sidebar keys everything off it: + +- The default load fetches **only the active** (user-created, non-archived) rows — 10 per page, "More" loading 10 at a time — plus the category totals; archived, scheduled and subagent Sessions are no longer loaded up front. +- A group's "More" counts **only the outer active rows** (the collapsed folders never feed it), and since the pages it fetches are category-filtered, every click now grows the visible list by a full page. +- A collapsed folder is labelled from the server totals and fetches its first page **when first opened**. Once open it pages independently with its own "More" row — each group and each folder advances alone, without dragging pages in for the others. +- The counted response also breaks the totals down **by Workspace path**, and every group — workspace groups included — shows a folder only for its **own share**: exact label counts, and "More" only while that share isn't fully loaded. (Initially a workspace group inferred its folders from per-Agent totals, so an Agent's subagent/scheduled Sessions living in *other* Workspaces made unrelated groups grow an empty folder whose "More" click made the whole folder vanish.) +- Review follow-ups: the paging cursor is each pair's **fetched-row count**, so a deep-linked row that `add()` slips into the list no longer makes the next page skip a server row; a clicked "More" disables and reads "loading…" while its fetch runs (in workspace mode a page can land entirely in other groups — the click still visibly does something); and the server locates each Session's earliest Trace shard in the **same single directory walk** discovery already does, so `counts=1` classification no longer re-walks the Traces tree once per Session. + +One consequence of not loading folder content up front: in workspace grouping, a Workspace whose loaded Sessions are all archived or automation-created no longer forms a group by itself (nothing visible remains to hang it on); its Sessions stay reachable through agent grouping's folders. + +The auto-opened "last conversation" follows one rule now too. Entering `/chat` without a Session used to take the flat list's literal first row (the first Agent's newest loaded row — not the Project's newest, and after a deep link possibly an archived or subagent row), and the collapsed rail's entry merely skipped archived rows. Both — plus the after-delete jump to a neighbouring Session — now pick the newest loaded **active or schedule** conversation: an archived row is hidden by choice and a subagent Session belongs to its parent conversation, so neither is ever auto-opened. When the auto-opened conversation is schedule-created, only its group's Scheduled folder expands and loads; the outer active list's "More" is untouched. + +## Every file-writing action confirms first; an empty save says so + +Actions that write files on the server used to be a mix: deletions confirmed, a skill *update* confirmed, but a settings save (AGENTS.md / system prompt, runtime params, tool overrides), a skill *uninstall* (which deletes the installed copy, local edits included) and a workspace upload landing on an existing name all executed on one click. They now all confirm first through the shared `ConfirmModal`, which also got a face-lift: a compact card with **no title bar** — just a tinted icon badge that sets the tone at a glance (red warning triangle for deletions, neutral pencil for saves/overwrites), the message beside it (each message carries its own subject and consequence), and a small Cancel/Confirm pair. The former titles still name the dialogs for assistive tech. Every existing confirmation (delete session/agent/project/schedule/vault entry, model dialog writes, import version conflict) was moved onto the same component, so confirmations look identical everywhere. Adding a vault variable under an already-configured name also confirms now (it overwrites an unrecoverable value); quick-set controls inside the chat (approval mode, thinking level) intentionally stay one-click. + +Clicking save with nothing changed used to do nothing silently (most forms skipped the request; the models dialog even reported "Saved"). Every save path now detects the no-op — the agent settings tabs, the schedule edit dialog and the model edit dialog — and shows an info toast: "No changes to save". diff --git a/changelog/unreleased/2026-07-24-llm-request-errors.md b/changelog/unreleased/2026-07-24-llm-request-errors.md new file mode 100644 index 0000000..d96da5c --- /dev/null +++ b/changelog/unreleased/2026-07-24-llm-request-errors.md @@ -0,0 +1,5 @@ +# LLM request errors surface their underlying cause + +A failed LLM request used to record a bare, unactionable message like `terminated`. Node's `fetch` wraps the real transport failure as a `TypeError: terminated` and hangs the actual reason — a socket close, `ECONNRESET`, a provider aborting the stream — on the error's `cause`; taking only `.message` when building the request outcome threw that away. + +The outcome message now walks the `cause` chain and appends each level's message and error `code`, so the same failure surfaces as e.g. `terminated: other side closed (UND_ERR_SOCKET)`. This flows straight through to the abort reason (`llm request error: …`), the Cost Center's recent-errors table, and Traces. Segments are de-duplicated, a non-Error cause tail is kept, and the walk guards against a cyclic `cause` chain. Covered by a `describeError` unit test. diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md index 4b1a710..572c9cc 100644 --- a/changelog/unreleased/README.md +++ b/changelog/unreleased/README.md @@ -2,4 +2,5 @@ Changes since v0.1.1. The version number is assigned at release, when this folder is renamed. -- [2026-07-22] Web App: Workspace HTML previews open on a separate origin with a signed token, so `localStorage`, cookies and third-party embeds work while Agent-generated pages still cannot reach the session cookie or the API. ([details](2026-07-22-web-app.md)) +- [2026-07-24] LLM request errors surface their underlying `cause` (e.g. `terminated: other side closed (UND_ERR_SOCKET)`) instead of a bare `terminated`, visible in the Cost Center and Traces. ([details](2026-07-24-llm-request-errors.md)) +- [2026-07-22] Web App: unified the form controls onto a shared Field/portal layer (now with required-field `*` markers), moved notifications to one rule (success/info → top toast, field errors inline with a red border; error prompts localized by code), generalized write confirmations (every file-writing save / uninstall / upload-overwrite now confirms via one polished dialog with tone icons — skill updates show each agent's `v_old → v_new` — and an unchanged save toasts "no changes to save"), made the Cost Center show the full error message, deduplicated the i18n copy (dead keys removed, shared `common` labels, lowercase "agent"), and reworked the sidebar session list onto category-filtered paging (default load = active rows only; the Subagents/Scheduled/Archived folders load on first open and page independently); plus the earlier separate-origin Workspace HTML previews. ([details](2026-07-22-web-app.md)) diff --git a/packages/core/src/llm/generative-model.ts b/packages/core/src/llm/generative-model.ts index 0751f0c..d261f72 100644 --- a/packages/core/src/llm/generative-model.ts +++ b/packages/core/src/llm/generative-model.ts @@ -701,6 +701,35 @@ export function translateEvents( // Retry policy // --------------------------------------------------------------------------- +/** + * A fuller error string than `err.message` for the LLM request outcome. Node's `fetch` + * wraps the real transport failure as `TypeError: terminated` and puts the actual reason + * on `err.cause` (a socket close, `ECONNRESET`, a provider stream abort, …); taking only + * `.message` throws that away and leaves a bare, unactionable "terminated". This walks the + * `cause` chain and appends each level's message and error `code`, so it surfaces as e.g. + * "terminated: other side closed (UND_ERR_SOCKET)". Segments are de-duplicated and the + * chain walk guards against cycles; a non-Error cause tail (string/number) is still kept. + */ +export function describeError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const parts: string[] = []; + const seen = new Set(); + let cur: unknown = error; + while (cur instanceof Error && !seen.has(cur)) { + seen.add(cur); + const code = (cur as { code?: unknown }).code; + let piece = cur.message || cur.name; + if (typeof code === "string" && code && !piece.includes(code)) piece = `${piece} (${code})`; + if (piece && !parts.includes(piece)) parts.push(piece); + cur = (cur as { cause?: unknown }).cause; + } + if (cur != null && !(cur instanceof Error)) { + const tail = String(cur); + if (tail && !parts.includes(tail)) parts.push(tail); + } + return parts.join(": ") || error.message || String(error); +} + /** * Determines whether an error is an AgentHub / Provider "response delivered but unusable" * parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x): @@ -906,7 +935,7 @@ export class GenerativeModel implements LLMInterface { } catch (err) { return { status: "failed", - message: err instanceof Error ? err.message : String(err), + message: describeError(err), }; } @@ -984,7 +1013,7 @@ export class GenerativeModel implements LLMInterface { // malformed to reconnect and retry — must not be classified as failed. outcome = { status: "malformed", - message: error instanceof Error ? error.message : String(error), + message: describeError(error), }; } else if (isRetryableError(error)) { outcome = { status: "timeout" }; // Network drop/network error -> needs reconnection @@ -993,7 +1022,7 @@ export class GenerativeModel implements LLMInterface { } else { outcome = { status: "failed", - message: error instanceof Error ? error.message : String(error), + message: describeError(error), }; } } finally { diff --git a/packages/core/src/state/model-catalog.ts b/packages/core/src/state/model-catalog.ts index 7b2c630..35a3b63 100644 --- a/packages/core/src/state/model-catalog.ts +++ b/packages/core/src/state/model-catalog.ts @@ -18,9 +18,9 @@ * Scope: excludes deepseek-chat / deepseek-reasoner legacy aliases that AgentHub cannot * auto-route (deprecated 2026-07-24), glm-5v-turbo (image input unsupported by AgentHub's GLM * client), non-chat models (embedding / image generation / TTS), and Bedrock. Direct-vendor - * ids are auto-routed by AgentHub and leave client_type unset; gateway entries (OpenRouter / - * SiliconFlow / Qwen Token Plan) can't be auto-routed, so they set `client_type: "openai"` - * and inline their preset base URL. + * ids are auto-routed by AgentHub and leave client_type unset; the five gateway groups + * (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go) can't be + * auto-routed, so they set `client_type: "openai"` and inline their preset base URL. * * This file imports no Node built-ins (type-only imports only), so it can be bundled directly * for the browser. @@ -74,8 +74,9 @@ const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1"; /** * Provider list (web model page groups in this order): DeepSeek first (the default model's - * provider), followed by the OpenRouter, SiliconFlow, and Qwen Token Plan gateways, then - * Google Gemini before Anthropic; custom groups custom OpenAI-protocol models and comes last. + * provider), followed by the five gateways (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token + * Plan, Qwen Pay-As-You-Go), then the first-party providers Google Gemini, Anthropic, OpenAI, + * Z.AI (GLM) and Moonshot (Kimi); custom groups custom OpenAI-protocol models and comes last. */ export const MODEL_PROVIDERS: ModelProviderInfo[] = [ { diff --git a/packages/core/test/describe-error.test.ts b/packages/core/test/describe-error.test.ts new file mode 100644 index 0000000..a14257b --- /dev/null +++ b/packages/core/test/describe-error.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { describeError } from "../src/llm/generative-model.js"; + +describe("describeError", () => { + it("walks the cause chain, surfacing the real reason behind a wrapper like 'terminated'", () => { + // Node's fetch throws TypeError("terminated") with the actual transport failure on cause. + const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }); + const err = new TypeError("terminated", { cause }); + expect(describeError(err)).toBe("terminated: other side closed (UND_ERR_SOCKET)"); + }); + + it("appends a top-level error code when not already in the message", () => { + const err = Object.assign(new Error("connect failed"), { code: "ECONNREFUSED" }); + expect(describeError(err)).toBe("connect failed (ECONNREFUSED)"); + }); + + it("does not duplicate a code already present in the message", () => { + const err = Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }); + expect(describeError(err)).toBe("connect ECONNREFUSED 1.2.3.4:443"); + }); + + it("returns a plain message unchanged and stringifies non-Errors", () => { + expect(describeError(new Error("boom"))).toBe("boom"); + expect(describeError("nope")).toBe("nope"); + }); + + it("keeps a non-Error cause tail and de-duplicates repeated segments", () => { + expect(describeError(new Error("outer", { cause: "inner reason" }))).toBe( + "outer: inner reason", + ); + const a = new Error("same"); + (a as { cause?: unknown }).cause = new Error("same"); // duplicate message, dropped + expect(describeError(a)).toBe("same"); + }); + + it("guards against a cyclic cause chain", () => { + const a = new Error("a"); + const b = new Error("b"); + (a as { cause?: unknown }).cause = b; + (b as { cause?: unknown }).cause = a; // cycle + expect(describeError(a)).toBe("a: b"); + }); +}); diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 87a8406..c0a37df 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -475,8 +475,27 @@ export interface SessionInfo { archived: boolean; } +/** + * Session list category, the sidebar's four-way split applied server-side: archived wins + * regardless of origin (archiving is an explicit user action), then the origin's bucket, + * and a Session with no (or an unknown) source is `active` — user-created rows. + */ +export type SessionCategory = "active" | SessionSource | "archived"; + +/** Per-category totals across an Agent's whole Session list (returned when the list is requested with counts). */ +export type SessionCategoryCounts = Record; + export interface SessionsResponse { sessions: SessionInfo[]; + /** Present when the request asked for counts (`counts=1`): totals per category over the full list, not just the returned page. */ + counts?: SessionCategoryCounts; + /** + * Present with `counts`: the same totals broken down by Workspace path (only paths + * with at least one Session appear). The sidebar's workspace grouping decides each + * group's folders and "More" from its own share, so a group never advertises + * content that lives in other Workspaces. + */ + workspaceCounts?: Record; } /** Server directory browsing (advanced new-Workspace picker): starts from the home directory by default, can navigate up to the root. */ diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index f044d70..ecbbe94 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -17,6 +17,7 @@ import type { FilesStatResponse, MessagesResponse, ServerEvent, + SessionCategory, SessionCreateResponse, SessionResponse, SessionsResponse, @@ -57,6 +58,14 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [ "always-ask", ]; +/** Accepted `category` query values of the list endpoint (SessionCategory, spelled out for validation). */ +const SESSION_CATEGORIES: readonly SessionCategory[] = [ + "active", + "subagent", + "schedule", + "archived", +]; + /** Validate Prompt input parts: text or image (data: / http(s) URL). */ function parseTaskInput(body: Record): OmniMessage[] { const input = body.input; @@ -101,12 +110,28 @@ export function agentSessionsRoutes(deps: AppDeps): Hono { // Optional paging (absent = full list, the pre-paging contract): the sidebar requests // limit+1 and shows limit, detecting "has more" without a response-envelope change. const paging = optionalPagingQuery(c); - const sessions = await deps.sessionService.listSessions( + // Optional category filter (paging then applies within the category) and per-category + // totals — the sidebar loads active rows only and labels the collapsed folders from counts. + const rawCategory = c.req.query("category"); + if (rawCategory !== undefined && !SESSION_CATEGORIES.includes(rawCategory as SessionCategory)) { + throw badRequest(`category must be one of ${SESSION_CATEGORIES.join(" / ")}.`); + } + const rawCounts = c.req.query("counts"); + if (rawCounts !== undefined && rawCounts !== "1") throw badRequest("counts only accepts 1."); + const { sessions, counts, workspaceCounts } = await deps.sessionService.listSessions( projectId, agentId, - ...(paging ? [paging] : []), + { + ...(paging ? { paging } : {}), + ...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}), + ...(rawCounts !== undefined ? { withCounts: true } : {}), + }, ); - return c.json({ sessions } satisfies SessionsResponse); + return c.json({ + sessions, + ...(counts ? { counts } : {}), + ...(workspaceCounts ? { workspaceCounts } : {}), + } satisfies SessionsResponse); }); app.post("/", async (c) => { diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index 6936d8e..d236635 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -13,15 +13,22 @@ * added to session-manager's active table (state idle). */ import path from "node:path"; -import { readdir } from "node:fs/promises"; +import { open, readdir } from "node:fs/promises"; import { createAgent, isSessionMeta, + parseTraceLines, readTraceTolerant, tracesDir, } from "@prismshadow/penguin-core"; import type { SessionMetaMessage } from "@prismshadow/penguin-core"; -import type { ApprovalMode, SessionInfo, SessionSource } from "../api/types.js"; +import type { + ApprovalMode, + SessionCategory, + SessionCategoryCounts, + SessionInfo, + SessionSource, +} from "../api/types.js"; import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js"; import { badRequest } from "../http/validate.js"; import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js"; @@ -33,6 +40,39 @@ import type { ProjectConfigService } from "./project-config-service.js"; const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; const SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/; +/** Head window for session_meta reads: generous for a long system prompt, far below a whole multi-MB shard. */ +const TRACE_HEAD_BYTES = 256 * 1024; + +/** + * Parse a Trace file's head window only. session_meta is the first line core writes to + * every shard, so a bounded read finds it without pulling the whole file into memory — + * category filtering / counts may need every Session's source in a single request. + * The window is cut at its last newline (the tail fragment is incomplete); a first line + * larger than the whole window falls back to the full tolerant read. + */ +async function readTraceHead(filePath: string) { + const fh = await open(filePath, "r"); + let text: string; + let truncated: boolean; + try { + // allocUnsafe: only subarray(0, bytesRead) is ever read, so the uninitialized tail never leaks. + const { buffer, bytesRead } = await fh.read( + Buffer.allocUnsafe(TRACE_HEAD_BYTES), + 0, + TRACE_HEAD_BYTES, + 0, + ); + text = buffer.subarray(0, bytesRead).toString("utf8"); + truncated = bytesRead === TRACE_HEAD_BYTES; + } finally { + await fh.close(); + } + if (!truncated) return parseTraceLines(text); + const nl = text.lastIndexOf("\n"); + if (nl === -1) return readTraceTolerant(filePath); + return parseTraceLines(text.slice(0, nl + 1)); +} + /** Derives creation time from the local timestamp embedded in session_id; returns null if it doesn't match. */ export function sessionIdCreatedAt(sessionId: string): string | null { const m = SESSION_ID_TS_RE.exec(sessionId); @@ -57,10 +97,15 @@ export class SessionService { /** * DB row -> SessionInfo (run status and pending approval count come from session-manager). * Async because `source` is derived from session_meta: a registry miss (Session predating - * this process) falls back to reading the Trace head once (see sourceOf). + * this process) falls back to reading the Trace head once (see sourceOf). `traces` is the + * list flow's one-walk discovery result; without it a miss locates the shard itself. */ - async toInfo(row: SessionRow, hasTrace: boolean): Promise { - const source = await this.sourceOf(row, hasTrace); + async toInfo( + row: SessionRow, + hasTrace: boolean, + traces?: ReadonlyMap, + ): Promise { + const source = await this.sourceOf(row, hasTrace, traces); return { sessionId: row.sessionId, projectId: row.projectId, @@ -86,11 +131,20 @@ export class SessionService { * session_meta is read once and cached. A Session with no Trace yet stays unknown and * is NOT cached negatively — its meta may appear with the first run. */ - private async sourceOf(row: SessionRow, hasTrace: boolean): Promise { + private async sourceOf( + row: SessionRow, + hasTrace: boolean, + traces?: ReadonlyMap, + ): Promise { const known = this.deps.sources.get(row.sessionId); if (known !== undefined) return known ?? undefined; if (!hasTrace) return undefined; - const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId); + // Single-session paths carry no discovery map: locate this Session's earliest shard on demand. + const location = + traces?.get(row.sessionId) ?? + (await this.discoverTraces(row.projectId, row.agentId)).get(row.sessionId); + if (!location) return undefined; + const meta = await this.readTraceMeta(location); if (!meta) return undefined; // Unreadable/corrupt Trace: stay unknown, retry on the next list. // On-disk values are untrusted: only the exact known origins pass, junk = user-created. const source = asSessionSource(meta.payload.source) ?? null; @@ -100,8 +154,22 @@ export class SessionService { /** Whether this Session already has a Trace record (a Task has been run). */ async hasTrace(row: SessionRow): Promise { - const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId); - return ids.has(row.sessionId); + return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId); + } + + /** + * The list category of a row: archived wins (an explicit user action), then the + * origin's bucket, and no/unknown source is `active` — the same precedence the + * sidebar's partition applies to loaded rows, so server filtering and client + * rendering can never disagree. + */ + private async categoryOf( + row: SessionRow, + hasTrace: boolean, + traces?: ReadonlyMap, + ): Promise { + if ((row.archivedAt ?? null) !== null) return "archived"; + return (await this.sourceOf(row, hasTrace, traces)) ?? "active"; } /** @@ -110,29 +178,77 @@ export class SessionService { * 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. + * + * `category` filters to one sidebar bucket **before** paging, so offset/limit page + * within the category. Filtering needs each walked row's category (a possible + * Trace-head read per row, cached in the sources registry); without `withCounts` + * the walk stops as soon as the requested page is complete. `withCounts` classifies + * every row and returns per-category totals over the whole list — plus the same + * totals broken down by Workspace path — so the sidebar can label the collapsed + * folders (and a workspace group can know its own share) without loading them. */ async listSessions( projectId: string, agentId: string, - paging?: { offset: number; limit: number }, - ): Promise { - const traceIds = await this.discoverTraceSessionIds(projectId, agentId); + opts: { + paging?: { offset: number; limit: number }; + category?: SessionCategory; + withCounts?: boolean; + } = {}, + ): Promise<{ + sessions: SessionInfo[]; + counts?: SessionCategoryCounts; + workspaceCounts?: Record; + }> { + const { paging, category, withCounts } = opts; + const traces = await this.discoverTraces(projectId, agentId); const rows = new Map( this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]), ); // Unmanaged Trace Sessions: backfill an index row by reading the first line's session_meta. - for (const sessionId of traceIds) { + for (const [sessionId, location] of traces) { if (rows.has(sessionId)) continue; - const discovered = await this.adoptTraceSession(projectId, agentId, sessionId); + const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location); if (discovered) rows.set(sessionId, discovered); } const sorted = [...rows.values()].sort( (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId), ); - const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted; - return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId)))); + const toPage = (page: SessionRow[]) => + Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces))); + + // No classification asked for: slice straight away (the pre-category behavior). + if (category === undefined && !withCounts) { + return { + sessions: await toPage( + paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted, + ), + }; + } + + const want = paging ? paging.offset + paging.limit : Infinity; + const counts: SessionCategoryCounts = { active: 0, subagent: 0, schedule: 0, archived: 0 }; + const workspaceCounts: Record = {}; + 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); + counts[cat] += 1; + if (withCounts) { + const ws = (workspaceCounts[row.workspace] ??= { + active: 0, + subagent: 0, + schedule: 0, + archived: 0, + }); + ws[cat] += 1; + } + if ((category === undefined || cat === category) && matched.length < want) matched.push(row); + } + const sessions = await toPage(paging ? matched.slice(paging.offset, want) : matched); + return withCounts ? { sessions, counts, workspaceCounts } : { sessions }; } /** @@ -273,45 +389,41 @@ export class SessionService { return this.toInfo(row, false); } - /** Scans the Trace directory to get the set of session_ids with records. */ - private async discoverTraceSessionIds(projectId: string, agentId: string): Promise> { - const dir = tracesDir(this.deps.root, projectId, agentId); - const ids = new Set(); - for (const dateDir of await listDirsSafe(dir)) { - for (const file of await listFilesSafe(path.join(dir, dateDir))) { - const match = TRACE_FILE_RE.exec(file); - if (match) ids.add(match[1]!); - } - } - return ids; - } - /** - * session_meta from the earliest Trace shard of a Session (the shard whose head carries - * the original meta); null when there is no readable Trace or it has no meta. Shared by - * adoption backfill and lazy `source` resolution. + * One walk over the Trace directory: session_id → its **earliest** shard (the shard + * whose head carries the original session_meta). Discovery (which Sessions have + * records) and the meta-read location come out of a single pass, so classifying every + * row (`counts=1`) costs one directory walk total instead of one per Session. */ - private async readTraceMeta( + private async discoverTraces( projectId: string, agentId: string, - sessionId: string, - ): Promise { + ): Promise> { const dir = tracesDir(this.deps.root, projectId, agentId); - let earliest: { path: string; index: number } | null = null; + const out = new Map(); for (const dateDir of await listDirsSafe(dir)) { for (const file of await listFilesSafe(path.join(dir, dateDir))) { const match = TRACE_FILE_RE.exec(file); - if (!match || match[1] !== sessionId) continue; + if (!match) continue; + const sessionId = match[1]!; const index = Number(match[2]); - if (!earliest || index < earliest.index) { - earliest = { path: path.join(dir, dateDir, file), index }; + const cur = out.get(sessionId); + if (!cur || index < cur.index) { + out.set(sessionId, { path: path.join(dir, dateDir, file), index }); } } } - if (!earliest) return null; + return out; + } + + /** + * session_meta from a located Trace shard head; null when unreadable or it has no + * meta. Shared by adoption backfill and lazy `source` resolution. + */ + private async readTraceMeta(location: TraceLocation): Promise { let messages; try { - messages = await readTraceTolerant(earliest.path); + messages = await readTraceHead(location.path); } catch { return null; // Corrupt file: skip (does not block the list) } @@ -323,8 +435,9 @@ export class SessionService { projectId: string, agentId: string, sessionId: string, + location: TraceLocation, ): Promise { - const meta = await this.readTraceMeta(projectId, agentId, sessionId); + const meta = await this.readTraceMeta(location); if (!meta) return null; // An older Trace version's session_meta lacks provider (the model reference // wasn't split into separate fields yet): no backward compat, skip adoption @@ -352,6 +465,12 @@ export class SessionService { } } +/** A located Trace shard of one Session: absolute path plus its shard index. */ +interface TraceLocation { + path: string; + index: number; +} + /** Local date as yyyy-mm-dd (matches the Trace date directory convention: core's internal formatLocalDate, not publicly exported). */ function localDate(d: Date): string { const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); diff --git a/packages/server/test/session-index.test.ts b/packages/server/test/session-index.test.ts index b5fb289..7d242b2 100644 --- a/packages/server/test/session-index.test.ts +++ b/packages/server/test/session-index.test.ts @@ -237,6 +237,113 @@ describe("session-index", () => { expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400); }); + it("category filter: each sidebar bucket lists only its rows, paging applies within the category, counts return full totals", async () => { + await configureModels(); + // Two active user Sessions + one archived (HTTP), two schedule-created (service, one + // then archived — archived must win over the origin), and one subagent Session whose + // source only exists in its Trace head (cold-registry derivation during the walk). + const mkUser = async () => + ((await (await api.post(base(), {})).json()) as SessionCreateResponse).session.sessionId; + const activeA = await mkUser(); + const activeB = await mkUser(); + const archivedC = await mkUser(); + expect((await api.patch(`/api/sessions/${archivedC}`, { archived: true })).status).toBe(200); + const mkSchedule = async () => + ( + await t.deps.sessionService.createSession({ + projectId, + agentId: "default_agent", + source: "schedule", + }) + ).sessionId; + const scheduleD = await mkSchedule(); + const archivedScheduleF = await mkSchedule(); + expect((await api.patch(`/api/sessions/${archivedScheduleF}`, { archived: true })).status).toBe( + 200, + ); + const subagentE = "session-2026-07-02-09-30-00-cafe0001"; + t.deps.sessionsRepo.insert({ + sessionId: subagentE, + projectId, + agentId: "default_agent", + provider: "custom", + modelId: "m-x", + workspace: "/tmp/w-sub", + approvalMode: "allow-all", + title: null, + createdAt: "2026-07-02T09:30:00.000Z", + }); + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", subagentE, 1, [ + sessionMeta({ + session_id: subagentE, + model_id: "m-x", + provider: "custom", + model_context_window: 1000, + system_prompt: "", + tools: [], + thinking_level: "default", + agent_state: "/tmp/a", + workspace: "/tmp/w-sub", + source: "subagent", + }), + userText("child work"), + ]); + + const list = async (qs: string) => { + const res = await api.get(`${base()}${qs}`); + expect(res.status, qs).toBe(200); + return (await res.json()) as SessionsResponse; + }; + const idSet = (body: SessionsResponse) => new Set(body.sessions.map((s) => s.sessionId)); + + expect(idSet(await list("?category=active"))).toEqual(new Set([activeA, activeB])); + expect(idSet(await list("?category=schedule"))).toEqual(new Set([scheduleD])); + expect(idSet(await list("?category=subagent"))).toEqual(new Set([subagentE])); + expect(idSet(await list("?category=archived"))).toEqual( + new Set([archivedC, archivedScheduleF]), + ); + + // Paging applies within the category: the two archived rows page one at a time. + const page1 = await list("?category=archived&limit=1&offset=0"); + const page2 = await list("?category=archived&limit=1&offset=1"); + expect(page1.sessions).toHaveLength(1); + expect(page2.sessions).toHaveLength(1); + expect(new Set([...idSet(page1), ...idSet(page2)])).toEqual( + new Set([archivedC, archivedScheduleF]), + ); + expect((await list("?category=archived&limit=1&offset=2")).sessions).toEqual([]); + + // counts=1 returns totals over the whole list, not the returned page — with or without a filter. + const counted = await list("?category=active&counts=1&limit=1"); + expect(counted.sessions).toHaveLength(1); + expect(counted.counts).toEqual({ active: 2, subagent: 1, schedule: 1, archived: 2 }); + const full = await list("?counts=1"); + expect(full.sessions).toHaveLength(6); + expect(full.counts).toEqual({ active: 2, subagent: 1, schedule: 1, archived: 2 }); + expect((await list("")).counts).toBeUndefined(); + expect((await list("")).workspaceCounts).toBeUndefined(); + + // The per-Workspace breakdown accompanies the totals and sums back to them: the + // subagent Session sits alone in its path; every other row lives in its own auto + // temp directory. + const byWorkspace = full.workspaceCounts!; + expect(byWorkspace["/tmp/w-sub"]).toEqual({ + active: 0, + subagent: 1, + schedule: 0, + archived: 0, + }); + const summed = { active: 0, subagent: 0, schedule: 0, archived: 0 }; + for (const ws of Object.values(byWorkspace)) { + for (const key of Object.keys(summed) as (keyof typeof summed)[]) summed[key] += ws[key]; + } + expect(summed).toEqual(full.counts); + + // Junk values are rejected, never silently unfiltered. + expect((await api.get(`${base()}?category=weird`)).status).toBe(400); + expect((await api.get(`${base()}?counts=yes`)).status).toBe(400); + }); + it("half a model reference is 400: the missing half is never inferred", async () => { await configureModels(); // Only modelId: even though it names the one configured model, the provider is never diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index fadc40d..e179419 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -42,6 +42,7 @@ import type { ScheduleItem, SchedulesResponse, ScheduleUpsertRequest, + SessionCategory, SessionCreateRequest, SessionCreateResponse, SessionPatchRequest, @@ -185,17 +186,25 @@ export const getAgentTraces = (projectId: string, agentId: string) => // Session --------------------------------------------------------------------- -/** Optional paging (both absent = full list): the store requests `limit+1` per page to detect "has more". */ +/** + * Optional paging (absent = full unfiltered list): the store requests `limit+1` per page to + * detect "has more". `category` filters server-side (paging applies within the category); + * `withCounts` asks for per-category totals over the whole list alongside the page. + */ export const listSessions = ( projectId: string, agentId: string, - paging?: { offset: number; limit: number }, -) => - apiFetch( - `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${ - paging ? `?limit=${paging.limit}&offset=${paging.offset}` : "" - }`, + opts?: { offset: number; limit: number; category?: SessionCategory; withCounts?: boolean }, +) => { + const qs = opts + ? `?limit=${opts.limit}&offset=${opts.offset}` + + (opts.category ? `&category=${opts.category}` : "") + + (opts.withCounts ? "&counts=1" : "") + : ""; + return apiFetch( + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${qs}`, ); +}; /** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */ export const listDirs = (projectId: string, path = "") => diff --git a/packages/web/src/components/account/change-password-dialog.tsx b/packages/web/src/components/account/change-password-dialog.tsx index 7a96644..74fb580 100644 --- a/packages/web/src/components/account/change-password-dialog.tsx +++ b/packages/web/src/components/account/change-password-dialog.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import * as api from "../../api/endpoints"; import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; import { useAuth } from "../../state/auth"; import { Button } from "../ui/button"; import { PasswordInput } from "../ui/password-input"; @@ -17,34 +18,42 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose const [oldPassword, setOldPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(null); + const [errors, setErrors] = useState<{ old?: string; new?: string; confirm?: string }>({}); const [busy, setBusy] = useState(false); + const clearErrors = () => setErrors((p) => (p.old || p.new || p.confirm ? {} : p)); useEffect(() => { if (!open) return; setOldPassword(""); setNewPassword(""); setConfirmPassword(""); - setError(null); + setErrors({}); }, [open]); const submit = async () => { - if (!oldPassword || !newPassword || !confirmPassword) { - setError(S.common.requiredField); - return; - } - if (newPassword !== confirmPassword) { - setError(S.account.passwordMismatch); + const next: { old?: string; new?: string; confirm?: string } = {}; + if (!oldPassword) next.old = S.common.requiredField; + if (!newPassword) next.new = S.common.requiredField; + if (!confirmPassword) next.confirm = S.common.requiredField; + if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch; + if (next.old || next.new || next.confirm) { + setErrors(next); return; } setBusy(true); - setError(null); + setErrors({}); try { await api.changePassword({ oldPassword, newPassword }); await refresh(); onClose(); } catch (e) { - setError(e instanceof ApiError ? e.message : S.common.unknownError); + // Route by error code: invalid_password is about the NEW password's strength; + // password_mismatch (and anything unrecognized) is about the current one. + if (e instanceof ApiError && e.code === "invalid_password") { + setErrors({ new: apiErrorText(e) }); + } else { + setErrors({ old: apiErrorText(e) }); + } } finally { setBusy(false); } @@ -69,29 +78,43 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
setOldPassword(e.target.value)} + onChange={(e) => { + setOldPassword(e.target.value); + clearErrors(); + }} + error={errors.old} autoComplete="current-password" hint={S.account.oldPasswordHint} autoFocus /> setNewPassword(e.target.value)} + onChange={(e) => { + setNewPassword(e.target.value); + clearErrors(); + }} + error={errors.new} autoComplete="new-password" hint={S.auth.passwordHint} /> setConfirmPassword(e.target.value)} + onChange={(e) => { + setConfirmPassword(e.target.value); + clearErrors(); + }} + error={errors.confirm} autoComplete="new-password" /> - {error &&

{error}

}
); diff --git a/packages/web/src/components/layout/app-layout.tsx b/packages/web/src/components/layout/app-layout.tsx index 6b97032..8356d03 100644 --- a/packages/web/src/components/layout/app-layout.tsx +++ b/packages/web/src/components/layout/app-layout.tsx @@ -6,8 +6,8 @@ */ import { useMemo, useState } from "react"; import { NavLink, Outlet, useMatch, useNavigate } from "react-router"; -import type { SessionInfo } from "@prismshadow/penguin-server/api"; import { S } from "../../lib/strings"; +import { latestConversation } from "../../lib/session-grouping"; import { useAuth } from "../../state/auth"; import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; @@ -43,16 +43,8 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) { /** On some conversation (any non-draft /chat/:id): the "you are here" state of the last-conversation entry. */ const onConversation = activeSessionId !== null && activeSessionId !== DRAFT_SESSION_ID; - /** Newest non-archived Session across the current Project by createdAt (the flat list is only ordered per Agent). */ - const lastSession = useMemo( - () => - sessions.reduce( - (best, s) => - !s.archived && (!best || Date.parse(s.createdAt) > Date.parse(best.createdAt)) ? s : best, - null, - ), - [sessions], - ); + /** Newest loaded conversation across the current Project (active/schedule only — archived and subagent rows are never auto-opened; the flat list is only ordered per Agent). */ + const lastSession = useMemo(() => latestConversation(sessions), [sessions]); /** Mirrors Sidebar.openSession: the current Agent follows the opened Session's Agent. */ const openLastSession = () => { diff --git a/packages/web/src/components/layout/project-dialogs.tsx b/packages/web/src/components/layout/project-dialogs.tsx index dbe70d8..be99b96 100644 --- a/packages/web/src/components/layout/project-dialogs.tsx +++ b/packages/web/src/components/layout/project-dialogs.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from "react"; import type { MemberInfo } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; -import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; import { PROJECT_ID_MAX_LENGTH, PROJECT_SUFFIX_PATTERN, @@ -16,7 +16,10 @@ import { projectDisplayName, useProject } from "../../state/project"; import { useAuth } from "../../state/auth"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { FieldError, FieldHint, FieldLabel } from "../ui/field"; +import { toastError } from "../ui/toast"; import { Modal } from "../ui/modal"; +import { ConfirmModal } from "../ui/confirm-modal"; import { Badge } from "../ui/badge"; export function CreateProjectDialog({ @@ -33,7 +36,8 @@ export function CreateProjectDialog({ const prefix = user && !user.isAdmin ? `${user.userId}-` : ""; const [idInput, setIdInput] = useState(""); const [name, setName] = useState(""); - const [error, setError] = useState(null); + // The id is the only validated field; format problems and the server's rejection (e.g. duplicate id) both land beside it. + const [idError, setIdError] = useState(undefined); const [busy, setBusy] = useState(false); // No draft is kept: the form starts empty every time it opens. @@ -41,13 +45,13 @@ export function CreateProjectDialog({ if (!open) return; setIdInput(""); setName(""); - setError(null); + setIdError(undefined); }, [open]); const submit = async () => { const id = prefix + idInput.trim(); if (!idInput.trim()) { - setError(S.common.requiredField); + setIdError(S.common.requiredField); return; } // Non-admin: validate the suffix segment (the hyphen is a reserved separator, appearing only once at the prefix join); admin: validate the whole string. @@ -55,11 +59,11 @@ export function CreateProjectDialog({ ? PROJECT_SUFFIX_PATTERN.test(idInput.trim()) && id.length <= PROJECT_ID_MAX_LENGTH : SEMANTIC_ID_PATTERN.test(id); if (!valid) { - setError(prefix ? S.project.idPrefixHint : S.project.idHint); + setIdError(prefix ? S.project.idPrefixHint : S.project.idHint); return; } setBusy(true); - setError(null); + setIdError(undefined); try { const res = await api.createProject({ projectId: id, @@ -67,7 +71,7 @@ export function CreateProjectDialog({ }); onCreated(res.project.projectId); } catch (e) { - setError(e instanceof ApiError ? e.message : S.common.unknownError); + setIdError(apiErrorText(e)); } finally { setBusy(false); } @@ -90,9 +94,7 @@ export function CreateProjectDialog({
{prefix ? (
- - {S.project.id} - + {S.project.id}
{prefix} @@ -101,20 +103,31 @@ export function CreateProjectDialog({ size="sm" className="rounded-l-none" value={idInput} - onChange={(e) => setIdInput(e.target.value)} + invalid={Boolean(idError)} + onChange={(e) => { + setIdInput(e.target.value); + setIdError(undefined); + }} autoFocus />
- - {S.project.idPrefixHint} - + {idError ? ( + {idError} + ) : ( + {S.project.idPrefixHint} + )}
) : ( setIdInput(e.target.value)} + error={idError} + onChange={(e) => { + setIdInput(e.target.value); + setIdError(undefined); + }} hint={S.project.idHint} autoFocus /> @@ -125,7 +138,6 @@ export function CreateProjectDialog({ value={name} onChange={(e) => setName(e.target.value)} /> - {error &&

{error}

}
); @@ -137,7 +149,8 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject(); const [members, setMembers] = useState(null); const [newMemberId, setNewMemberId] = useState(""); - const [error, setError] = useState(null); + // Only the initial member-list load shows inline (in place of the table); action failures pop a toast. + const [loadError, setLoadError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const projectId = currentProject?.projectId; @@ -146,42 +159,39 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos useEffect(() => { if (!open || !projectId) return; setMembers(null); - setError(null); + setLoadError(null); setConfirmDelete(false); api .listMembers(projectId) .then((res) => setMembers(res.members)) - .catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)); + .catch((e: unknown) => setLoadError(apiErrorText(e))); }, [open, projectId]); if (!currentProject || !projectId) return null; const addMember = async () => { if (!newMemberId.trim()) return; - setError(null); try { await api.addMember(projectId, { userId: newMemberId.trim() }); setNewMemberId(""); const res = await api.listMembers(projectId); setMembers(res.members); } catch (e) { - setError(e instanceof ApiError ? e.message : S.common.unknownError); + toastError(apiErrorText(e)); } }; const doRemove = async (memberId: string) => { - setError(null); try { await api.removeMember(projectId, memberId); const res = await api.listMembers(projectId); setMembers(res.members); } catch (e) { - setError(e instanceof ApiError ? e.message : S.common.unknownError); + toastError(apiErrorText(e)); } }; const doDelete = async () => { - setError(null); try { await api.deleteProject(projectId); onClose(); @@ -189,7 +199,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos await reloadProjects(); if (next) setCurrentProjectId(next.projectId); } catch (e) { - setError(e instanceof ApiError ? e.message : S.common.unknownError); + toastError(apiErrorText(e)); } }; @@ -206,7 +216,9 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos

{S.project.members}

- {members === null ? ( + {loadError ? ( +

{loadError}

+ ) : members === null ? (

{S.common.loading}

) : ( // Member permission table: username / role / actions; cells never wrap. @@ -216,13 +228,11 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos - {S.project.memberUsername} - - - {S.project.memberRole} + {S.common.username} + {S.common.role} - {S.project.memberActions} + {S.common.actions} @@ -246,7 +256,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setNewMemberId(e.target.value)} @@ -283,18 +293,6 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos // Last accessible Project: deleting it would leave the account with no Project to select // (the page would get stuck on the skeleton screen), so the frontend hides the entry point outright, matching the server's 409 rejection.

{S.project.deleteLastForbidden}

- ) : confirmDelete ? ( -
-

{S.project.deleteConfirm}

-
- - -
-
) : (
)} - - {error &&

{error}

} + + {/* Delete confirmation (shared ConfirmModal, stacked above the settings dialog). */} + setConfirmDelete(false)} + onConfirm={() => void doDelete()} + > +

{S.project.deleteConfirm}

+
); } diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index c1808f0..b468aeb 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -18,10 +18,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { NavLink, useMatch, useNavigate } from "react-router"; -import type { SessionInfo, SessionSource } from "@prismshadow/penguin-server/api"; +import type { + SessionCategory, + SessionCategoryCounts, + SessionInfo, +} from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; -import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; import { useAuth } from "../../state/auth"; import { useLocale } from "../../state/locale"; import type { LangPref } from "../../state/locale"; @@ -30,20 +34,25 @@ import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme"; import { agentDisplayName, projectDisplayName, useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; import { + FOLDER_CATEGORIES, SIDEBAR_PAGE_SIZE, - groupAgentsWithMore, + aggregateWorkspaceCounts, groupSessionsByWorkspace, partitionSessions, pinnedFirst, + sessionCategory, workspaceGroupKey, } from "../../lib/session-grouping"; -import type { SessionPartition } from "../../lib/session-grouping"; +import type { FolderCategory, SessionPartition } from "../../lib/session-grouping"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; +import { ChevronDown } from "../ui/icons"; +import { toastError } from "../ui/toast"; import { Truncated } from "../ui/truncated"; import { Badge } from "../ui/badge"; import { Modal } from "../ui/modal"; +import { ConfirmModal } from "../ui/confirm-modal"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Segmented } from "../ui/segmented"; @@ -71,15 +80,6 @@ function Icon({ d, size = 16 }: { d: string; size?: number }) { ); } -/** Dropdown caret (used by the Project switcher; distinct from the collapse-indicator Chevron). */ -function DropdownCaret() { - return ( - - - - ); -} - /** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */ export const NAV_ICONS = { agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01", @@ -152,11 +152,11 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet): voi } /** - * Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder - * has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite - * never collides across groups or with plain group keys. + * Open-state key of a collapsed folder (subagent / scheduled / archived) inside a group: + * each folder has its own state. "\0" never appears in Agent ids or Workspace paths, so + * the composite never collides across groups or with plain group keys. */ -const sourceFolderKey = (groupKey: string, source: SessionSource) => `${source}\0${groupKey}`; +const folderKey = (groupKey: string, category: FolderCategory) => `${category}\0${groupKey}`; /** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */ function StatusDot({ session }: { session: SessionInfo }) { @@ -200,8 +200,18 @@ export function Sidebar({ currentAgent, setCurrentAgentId, } = useProject(); - const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } = - useSessions(); + const { + sessions, + byAgent, + countsByAgent, + workspaceCountsByAgent, + isLoadedFor, + hasMoreFor, + loadMoreFor, + loading, + remove, + replace, + } = useSessions(); const chatMatch = useMatch("/chat/:sessionId"); const activeSessionId = chatMatch?.params.sessionId ?? null; @@ -228,16 +238,15 @@ export function Sidebar({ setCollapsedGroups(loadGroupSet(collapseStoreKey)); setPinnedGroups(loadGroupSet(pinStoreKey)); }, [collapseStoreKey, pinStoreKey]); - /** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */ - const [openArchived, setOpenArchived] = useState>(new Set()); - /** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */ - const [openSourceFolders, setOpenSourceFolders] = useState>(new Set()); + /** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */ + const [openFolders, setOpenFolders] = useState>(new Set()); + /** "More" rows with a fetch in flight, keyed `${category}\0${groupKey}` — the row disables and reads "loading" so a page that lands entirely in other groups still visibly did something. */ + 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()); /** Session pending delete confirmation (null = none). */ const [deletingSession, setDeletingSession] = useState(null); const [deletingBusy, setDeletingBusy] = useState(false); - const [deleteError, setDeleteError] = useState(null); /** Session currently being renamed (null = none) and the title being typed. */ const [renamingSession, setRenamingSession] = useState(null); const [renameText, setRenameText] = useState(""); @@ -252,6 +261,12 @@ export function Sidebar({ /** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */ const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]); + /** Workspace-mode per-group exact server totals (folded from the per-Agent per-Workspace counts). */ + const workspaceGroupCounts = useMemo( + () => aggregateWorkspaceCounts(workspaceCountsByAgent), + [workspaceCountsByAgent], + ); + // Pinned groups first within each mode; inside each partition the existing order is kept // (recency for Workspace groups, the configured Agent order for Agents). const orderedAgents = useMemo( @@ -286,22 +301,42 @@ export function Sidebar({ saveGroupSet(pinStoreKey, next); }; - const toggleArchivedGroup = (key: string) => - setOpenArchived((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); + /** In-flight key of one group's category "More" (folderKey shares the same composite for folder categories). */ + const loadKey = (groupKey: string, category: SessionCategory) => `${category}\0${groupKey}`; - const toggleSourceFolder = (groupKey: string, source: SessionSource) => - setOpenSourceFolders((prev) => { - const key = sourceFolderKey(groupKey, source); + /** loadMoreFor with an in-flight marker for the triggering "More" row (disable + loading text). */ + const trackedLoadMore = (groupKey: string, category: SessionCategory, agentIds: string[]) => { + const key = loadKey(groupKey, category); + setPendingLoads((prev) => new Set(prev).add(key)); + void loadMoreFor(agentIds, category).finally(() => { + setPendingLoads((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }); + }; + + /** + * Open/close a group's folder. A folder's content is loaded on demand: the first + * expand fetches its category's first page for every contributing Agent that hasn't + * been asked yet (already-loaded rows stay put — re-expanding never refetches; the + * folder's own "More" row does the paging from there). + */ + const toggleFolder = (groupKey: string, category: FolderCategory, agentIds: string[]) => { + const key = folderKey(groupKey, category); + const opening = !openFolders.has(key); + setOpenFolders((prev) => { const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); + if (opening) next.add(key); + else next.delete(key); return next; }); + if (opening) { + const unloaded = agentIds.filter((id) => !isLoadedFor(id, category)); + if (unloaded.length > 0) void loadMoreFor(unloaded, category); + } + }; // The open chat is an automation-created Session: expand exactly its origin's folder in its // group, so the active row is never hidden inside a collapsed folder (mirrors the archived @@ -315,20 +350,25 @@ export function Sidebar({ useEffect(() => { if (!activeSessionId) return; const s = sessions.find((x) => x.sessionId === activeSessionId); - if (!s || !s.source || s.archived) return; + if (!s) return; + const category = sessionCategory(s); + if (category === "active" || category === "archived") return; const guard = `${groupMode}\0${activeSessionId}`; if (lastAutoExpandedRef.current === guard) return; lastAutoExpandedRef.current = guard; const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace); - const key = sourceFolderKey(groupKey, s.source); - setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); - }, [activeSessionId, sessions, groupMode]); + const key = folderKey(groupKey, category); + setOpenFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + // Same on-demand load a click-expand does, for this Session's own Agent (siblings of + // other contributing Agents stay behind the folder's "More"). + if (!isLoadedFor(s.agentId, category)) void loadMoreFor([s.agentId], category); + }, [activeSessionId, sessions, groupMode, isLoadedFor, loadMoreFor]); /** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */ const toggleArchive = async (s: SessionInfo) => { - // Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back. + // Archiving the currently open chat: expand the "archived" folder so it doesn't silently vanish from the sidebar with no way back. if (!s.archived && s.sessionId === activeSessionId) { - setOpenArchived((prev) => new Set(prev).add(sessionGroupKey(s))); + setOpenFolders((prev) => new Set(prev).add(folderKey(sessionGroupKey(s), "archived"))); } try { const res = await api.patchSession(s.sessionId, { archived: !s.archived }); @@ -349,7 +389,7 @@ export function Sidebar({ replace(res.session); setRenamingSession(null); } catch (e) { - setRenameError(e instanceof ApiError ? e.message : S.common.unknownError); + setRenameError(apiErrorText(e)); } finally { setRenameBusy(false); } @@ -358,7 +398,6 @@ export function Sidebar({ const confirmDeleteSession = async () => { if (!deletingSession) return; setDeletingBusy(true); - setDeleteError(null); const target = deletingSession; try { await api.deleteSession(target.sessionId); @@ -366,17 +405,21 @@ export function Sidebar({ // The session is gone, so clear its input draft too (no orphaned keys left in localStorage; keys are scoped per user, #68). if (user) clearDraft(sessionDraftKey(user.userId, target.sessionId)); setDeletingSession(null); - // The deleted session was the one open: jump to another **unarchived** Session in the same - // group, otherwise fall back to the chat home page (never jump into an archived session — - // it's hidden by default, so landing there would look like the chat vanished into thin air). + // The deleted session was the one open: jump to this Agent's next conversation, otherwise + // fall back to the chat home page. Auto-opened conversations are never archived (hidden by + // default — landing there would look like the chat vanished into thin air) and never + // subagent children (they belong to some other conversation). if (activeSessionId === target.sessionId) { - const rest = (byAgent.get(target.agentId) ?? []).filter( - (s) => s.sessionId !== target.sessionId && !s.archived, - ); + const rest = (byAgent.get(target.agentId) ?? []).filter((s) => { + const category = sessionCategory(s); + return ( + s.sessionId !== target.sessionId && (category === "active" || category === "schedule") + ); + }); navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat"); } } catch (e) { - setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError); + toastError(apiErrorText(e)); } finally { setDeletingBusy(false); } @@ -440,10 +483,7 @@ export function Sidebar({ setRenameText(x.title ?? ""); setRenamingSession(x); }} - onDelete={(x) => { - setDeleteError(null); - setDeletingSession(x); - }} + onDelete={(x) => setDeletingSession(x)} onToggleArchive={(x) => void toggleArchive(x)} /> ))} @@ -453,65 +493,102 @@ export function Sidebar({ const folderClass = "flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"; - /** Collapsed-by-default per-origin folder (subagent / scheduled), parallel to the archived folder. */ - const renderSourceFolder = ( + /** + * Collapsed-by-default lazy folder (subagent / scheduled / archived): nothing is + * fetched until the first expand, and once open the folder pages independently with + * its own "More" row. Everything is driven by the group's **own** exact server share + * (`totals` — the Agent's counts in agent mode, the per-Workspace fold in workspace + * mode): the folder exists only while its share is non-zero, the label shows that + * share, and "More" shows only while loaded rows fall short of it — an Agent's + * content in *other* Workspaces can never surface a folder here. + */ + const renderFolder = ( groupKey: string, - source: SessionSource, - rows: SessionInfo[], + category: FolderCategory, + parts: SessionPartition, withAgentHint: boolean, + /** Agents that may hold this group's rows of this category (fetch fan-out set). */ + agentIds: string[], + totals: SessionCategoryCounts | undefined, ) => { - if (rows.length === 0) return null; - const open = openSourceFolders.has(sourceFolderKey(groupKey, source)); + const rows = parts[category]; + // Loaded rows win a disagreement with the totals (counts refresh only on reload). + const total = Math.max(totals?.[category] ?? 0, rows.length); + if (total === 0) return null; + const open = openFolders.has(folderKey(groupKey, category)); + // More while the group's share isn't fully loaded AND somewhere is left to fetch from + // (counts drifting above reality would otherwise leave a dead button until reload). + const more = rows.length < total && agentIds.some((id) => hasMoreFor(id, category)); + const pending = pendingLoads.has(loadKey(groupKey, category)); return ( -
+
{open && renderRows(rows, withAgentHint)} + {/* The folder's own paging, independent of the active list's "More". In workspace + mode a fetched page can land rows in other groups' folders too, so one click may + grow this folder by fewer than a full page — the row shows a loading state while + the fetch runs and stays until this group's share is fully loaded. */} + {open && more && ( + + )}
); }; - /** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */ - const showMore = (groupKey: string, moreAgents: string[]) => { + /** Active-list "More": reveal one more page of already-loaded active rows AND fetch the next active server page for every Agent that still has one. */ + const showMore = (groupKey: string, agentIds: string[]) => { setGroupCaps((prev) => { const next = new Map(prev); next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE); return next; }); - if (moreAgents.length > 0) void loadMoreFor(moreAgents); + if (agentIds.length > 0) trackedLoadMore(groupKey, "active", agentIds); }; /** - * Expanded group body shared by both modes: user rows (display-capped; "More" reveals and - * loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups - * (keyed by the group key; rendered uncapped over loaded data — they are collapsed by - * default and only ever hold what the pages brought in). + * Expanded group body shared by both modes: active user rows (display-capped; "More" + * reveals and loads further **active-only** pages — the folders below never feed it) + + * the collapsed-by-default subagent / scheduled / archived folders, each loading on + * first expand and paging on its own. `totals` / `agentsFor` carry the group's exact + * server share and its fetch fan-out set per category. */ const renderGroupBody = ( groupKey: string, parts: SessionPartition, withAgentHint: boolean, - /** Agents contributing to this group that still have unfetched server pages. */ - moreAgents: string[], + totals: SessionCategoryCounts | undefined, + agentsFor: (category: SessionCategory) => string[], ) => { - const archivedOpen = openArchived.has(groupKey); const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE; const shownActive = parts.active.slice(0, cap); - // "More" while hidden loaded rows exist OR any contributing Agent has server-side pages - // left; a fetched page can also land rows in the folders below, so one click may grow - // the visible list by fewer than a full page — the row simply stays until exhausted. - const hasMore = parts.active.length > cap || moreAgents.length > 0; - const empty = - parts.active.length === 0 && - parts.subagent.length === 0 && - parts.schedule.length === 0 && - parts.archived.length === 0; + // Only the outer active rows drive the group's "More" — the folders never feed it: + // hidden loaded rows exist, or the group's own active share isn't fully loaded yet. + const activeAgents = agentsFor("active"); + const activeTotal = Math.max(totals?.active ?? 0, parts.active.length); + const hasMore = + parts.active.length > cap || + (parts.active.length < activeTotal && activeAgents.some((id) => hasMoreFor(id, "active"))); + const folders = FOLDER_CATEGORIES.map((category) => + renderFolder(groupKey, category, parts, withAgentHint, agentsFor(category), totals), + ); + const empty = parts.active.length === 0 && folders.every((f) => f === null); + const activePending = pendingLoads.has(loadKey(groupKey, "active")); return ( <> {empty ? ( @@ -527,33 +604,19 @@ export function Sidebar({ )} - {/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned - from the conversations at hand — then scheduled background runs. */} - {renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)} - {renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)} - - {/* Archived group (collapsed by default; archived wins over the per-origin folders) */} - {parts.archived.length > 0 && ( -
- - {archivedOpen && renderRows(parts.archived, withAgentHint)} -
- )} + {/* Folders (collapsed by default): subagent first — spawned from the conversations + at hand — then scheduled background runs, then archived (archived wins over the + origin folders). */} + {folders} ); }; @@ -617,7 +680,7 @@ export function Sidebar({ {currentProject ? projectDisplayName(currentProject) : S.common.loading} - + } @@ -803,7 +866,8 @@ export function Sidebar({ agent.agentId, parts, false, - hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [], + countsByAgent.get(agent.agentId), + () => [agent.agentId], )}
); @@ -820,6 +884,12 @@ export function Sidebar({ const parts = partitionSessions(group.sessions); const collapsed = collapsedGroups.has(group.key); const pinned = pinnedGroups.has(group.key); + /** This group's exact server share (per-Workspace fold) and its per-category fetch fan-out. */ + const counts = workspaceGroupCounts.get(group.key); + const contributingAgents = [...new Set(group.sessions.map((s) => s.agentId))]; + const agentsFor = (category: SessionCategory) => [ + ...new Set([...(counts?.agents[category] ?? []), ...contributingAgents]), + ]; return (
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace. @@ -842,9 +912,10 @@ export function Sidebar({ {group.temp ? S.chat.tempWorkspaces : group.label} - {/* Header count = non-archived Sessions (user + automation-created; unchanged semantics) */} + {/* Header count = the group's active conversations only (exact server share; + loaded rows win a disagreement) — the folders never feed it. */} - {parts.active.length + parts.subagent.length + parts.schedule.length} + {Math.max(counts?.totals.active ?? 0, parts.active.length)} @@ -862,15 +933,13 @@ export function Sidebar({
- {/* A workspace group can span Agents: "More" fans out to every contributing Agent that still has pages. */} + {/* A workspace group can span Agents: the group body fans folder loads and "More" + out per category to the Agents whose share of THIS group is non-zero (plus the + Agents already contributing loaded rows) — the active list and each folder + page independently. */} {collapsed ? null - : renderGroupBody( - group.key, - parts, - true, - groupAgentsWithMore(group.sessions, hasMoreByAgent), - )} + : renderGroupBody(group.key, parts, true, counts?.totals, agentsFor)} ); }) @@ -1001,47 +1070,34 @@ export function Sidebar({ setRenameText(e.target.value)} + onChange={(e) => { + setRenameText(e.target.value); + if (renameError) setRenameError(null); + }} onKeyDown={(e) => { if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename(); }} /> - {renameError && ( -

{renameError}

- )} - {/* Delete chat confirmation */} - (deletingBusy ? undefined : setDeletingSession(null))} - footer={ - <> - - - - } + onConfirm={() => void confirmDeleteSession()} >

{deletingSession ? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle) : ""}

- {deleteError && ( -

{deleteError}

- )} -
+ ); } @@ -1129,8 +1185,7 @@ function SessionRow({ : "text-gray-700 dark:text-gray-300" }`} /> - {/* Source badge: schedule / sub-session (user-created sessions have no source and show nothing). */} - {s.source && {S.chat.sourceNames[s.source] ?? s.source}} + {/* No per-row source tag: subagent / scheduled Sessions live in their own labelled, collapsed folders, so a badge on the title would just repeat the folder. */} {s.pendingApprovalCount > 0 && ( diff --git a/packages/web/src/components/ui/confirm-modal.tsx b/packages/web/src/components/ui/confirm-modal.tsx new file mode 100644 index 0000000..74737c8 --- /dev/null +++ b/packages/web/src/components/ui/confirm-modal.tsx @@ -0,0 +1,116 @@ +/** + * Confirmation dialog for destructive or overwriting actions (delete, + * overwrite-on-update, save-to-file, …), deliberately minimal: **no title bar** — + * just the tone icon, the message and a small Cancel / Confirm pair, so every + * confirmation in the app is one compact, identical card. `tone` picks the look — + * danger (red) for deletions, primary for saves and other overwrites. The message + * and any details (e.g. a version list) go in children; `title` only names the + * dialog for assistive tech (never rendered). + */ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Modal } from "./modal"; +import { Button } from "./button"; +import { S } from "../../lib/strings"; + +/** Tinted icon badge per tone: warning triangle on red for danger, pencil-on-gray for confirmations that overwrite/save. */ +function ToneBadge({ tone }: { tone: "danger" | "primary" }) { + const glyph = + tone === "danger" + ? // Triangle alert (lucide): outline + exclamation. + "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3zM12 9v4m0 4h.01" + : // Pencil-line (lucide): writing changes down. + "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"; + return ( + + + + + + ); +} + +export function ConfirmModal({ + open, + title, + onClose, + onConfirm, + confirmLabel, + tone = "danger", + busy = false, + children, +}: { + open: boolean; + /** Accessible dialog name only — the compact card renders no title bar. */ + title: string; + onClose: () => void; + onConfirm: () => void; + /** Confirm button text (defaults to the shared "Confirm"). */ + confirmLabel?: string; + /** Confirm button variant: danger for deletions, primary for saves and other overwrites. */ + tone?: "danger" | "primary"; + busy?: boolean; + children: ReactNode; +}) { + return ( + +
+ +
{children}
+
+
+ + +
+
+ ); +} + +/** + * Shared confirm-before-save flow for the settings forms: `requestSave(run)` opens a + * standard "save these changes?" dialog and Confirm executes `run`. The caller decides + * beforehand whether there is anything to save (no changes → an info toast, not a + * dialog). Render `element` once per surface. + */ +export function useSaveConfirm(): { + requestSave: (run: () => void) => void; + element: ReactNode; +} { + const [pending, setPending] = useState<(() => void) | null>(null); + const element = ( + setPending(null)} + onConfirm={() => { + pending?.(); + setPending(null); + }} + > +

{S.common.confirmSaveBody}

+
+ ); + return { requestSave: (run: () => void) => setPending(() => run), element }; +} diff --git a/packages/web/src/components/ui/drawer.tsx b/packages/web/src/components/ui/drawer.tsx index 2aac7b0..e58b174 100644 --- a/packages/web/src/components/ui/drawer.tsx +++ b/packages/web/src/components/ui/drawer.tsx @@ -4,7 +4,7 @@ */ import { useEffect } from "react"; import type { ReactNode } from "react"; -import { S } from "../../lib/strings"; +import { CloseButton } from "./icons"; export interface DrawerProps { open: boolean; @@ -39,16 +39,7 @@ export function Drawer({ open, side = "left", title, onClose, children, widthCla >
{title ?? ""} - +
{children} diff --git a/packages/web/src/components/ui/field.tsx b/packages/web/src/components/ui/field.tsx new file mode 100644 index 0000000..d7d9ab0 --- /dev/null +++ b/packages/web/src/components/ui/field.tsx @@ -0,0 +1,92 @@ +/** + * Shared field scaffolding: the label / hint / error text and the wrapping + *