refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-24 18:43:18 +08:00
1 parent c7465625a4
commit 3f868ac385
50 files changed
+2391 -1190

No files matched your search

+47 -1
View File
@@ -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/<id>/files/<name>`, a route that does not exist, so multi-file generated apps could never load their own CSS or JS. "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/<id>/files/<name>`, 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 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. 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".
@@ -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.
+2 -1
View File
@@ -2,4 +2,5 @@
Changes since v0.1.1. The version number is assigned at release, when this folder is renamed. 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))
+32 -3
View File
@@ -701,6 +701,35 @@ export function translateEvents(
// Retry policy // 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<unknown>();
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" * Determines whether an error is an AgentHub / Provider "response delivered but unusable"
* parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x): * parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x):
@@ -906,7 +935,7 @@ export class GenerativeModel implements LLMInterface {
} catch (err) { } catch (err) {
return { return {
status: "failed", 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. // malformed to reconnect and retry — must not be classified as failed.
outcome = { outcome = {
status: "malformed", status: "malformed",
message: error instanceof Error ? error.message : String(error), message: describeError(error),
}; };
} else if (isRetryableError(error)) { } else if (isRetryableError(error)) {
outcome = { status: "timeout" }; // Network drop/network error -> needs reconnection outcome = { status: "timeout" }; // Network drop/network error -> needs reconnection
@@ -993,7 +1022,7 @@ export class GenerativeModel implements LLMInterface {
} else { } else {
outcome = { outcome = {
status: "failed", status: "failed",
message: error instanceof Error ? error.message : String(error), message: describeError(error),
}; };
} }
} finally { } finally {
+6 -5
View File
@@ -18,9 +18,9 @@
* Scope: excludes deepseek-chat / deepseek-reasoner legacy aliases that AgentHub cannot * 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 * 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 * 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 / * ids are auto-routed by AgentHub and leave client_type unset; the five gateway groups
* SiliconFlow / Qwen Token Plan) can't be auto-routed, so they set `client_type: "openai"` * (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go) can't be
* and inline their preset base URL. * 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 * This file imports no Node built-ins (type-only imports only), so it can be bundled directly
* for the browser. * 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 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 * provider), followed by the five gateways (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token
* Google Gemini before Anthropic; custom groups custom OpenAI-protocol models and comes last. * 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[] = [ export const MODEL_PROVIDERS: ModelProviderInfo[] = [
{ {
+45
View File
@@ -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");
});
});
+19
View File
@@ -475,8 +475,27 @@ export interface SessionInfo {
archived: boolean; 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<SessionCategory, number>;
export interface SessionsResponse { export interface SessionsResponse {
sessions: SessionInfo[]; 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<string, SessionCategoryCounts>;
} }
/** Server directory browsing (advanced new-Workspace picker): starts from the home directory by default, can navigate up to the root. */ /** Server directory browsing (advanced new-Workspace picker): starts from the home directory by default, can navigate up to the root. */
+28 -3
View File
@@ -17,6 +17,7 @@ import type {
FilesStatResponse, FilesStatResponse,
MessagesResponse, MessagesResponse,
ServerEvent, ServerEvent,
SessionCategory,
SessionCreateResponse, SessionCreateResponse,
SessionResponse, SessionResponse,
SessionsResponse, SessionsResponse,
@@ -57,6 +58,14 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [
"always-ask", "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). */ /** Validate Prompt input parts: text or image (data: / http(s) URL). */
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] { function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
const input = body.input; const input = body.input;
@@ -101,12 +110,28 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
// Optional paging (absent = full list, the pre-paging contract): the sidebar requests // 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. // limit+1 and shows limit, detecting "has more" without a response-envelope change.
const paging = optionalPagingQuery(c); 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, projectId,
agentId, 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) => { app.post("/", async (c) => {
+161 -42
View File
@@ -13,15 +13,22 @@
* added to session-manager's active table (state idle). * added to session-manager's active table (state idle).
*/ */
import path from "node:path"; import path from "node:path";
import { readdir } from "node:fs/promises"; import { open, readdir } from "node:fs/promises";
import { import {
createAgent, createAgent,
isSessionMeta, isSessionMeta,
parseTraceLines,
readTraceTolerant, readTraceTolerant,
tracesDir, tracesDir,
} from "@prismshadow/penguin-core"; } from "@prismshadow/penguin-core";
import type { SessionMetaMessage } 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 { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import { badRequest } from "../http/validate.js"; import { badRequest } from "../http/validate.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.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 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}$/; 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. */ /** 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 { export function sessionIdCreatedAt(sessionId: string): string | null {
const m = SESSION_ID_TS_RE.exec(sessionId); 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). * 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 * 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<SessionInfo> { async toInfo(
const source = await this.sourceOf(row, hasTrace); row: SessionRow,
hasTrace: boolean,
traces?: ReadonlyMap<string, TraceLocation>,
): Promise<SessionInfo> {
const source = await this.sourceOf(row, hasTrace, traces);
return { return {
sessionId: row.sessionId, sessionId: row.sessionId,
projectId: row.projectId, 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 * 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. * is NOT cached negatively — its meta may appear with the first run.
*/ */
private async sourceOf(row: SessionRow, hasTrace: boolean): Promise<SessionSource | undefined> { private async sourceOf(
row: SessionRow,
hasTrace: boolean,
traces?: ReadonlyMap<string, TraceLocation>,
): Promise<SessionSource | undefined> {
const known = this.deps.sources.get(row.sessionId); const known = this.deps.sources.get(row.sessionId);
if (known !== undefined) return known ?? undefined; if (known !== undefined) return known ?? undefined;
if (!hasTrace) return 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. 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. // On-disk values are untrusted: only the exact known origins pass, junk = user-created.
const source = asSessionSource(meta.payload.source) ?? null; 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). */ /** Whether this Session already has a Trace record (a Task has been run). */
async hasTrace(row: SessionRow): Promise<boolean> { async hasTrace(row: SessionRow): Promise<boolean> {
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId); return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId);
return ids.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<string, TraceLocation>,
): Promise<SessionCategory> {
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 * more"); slicing happens before toInfo, so per-request source derivation (lazy
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
* the whole directory — the union and global ordering need every id. * 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( async listSessions(
projectId: string, projectId: string,
agentId: string, agentId: string,
paging?: { offset: number; limit: number }, opts: {
): Promise<SessionInfo[]> { paging?: { offset: number; limit: number };
const traceIds = await this.discoverTraceSessionIds(projectId, agentId); category?: SessionCategory;
withCounts?: boolean;
} = {},
): Promise<{
sessions: SessionInfo[];
counts?: SessionCategoryCounts;
workspaceCounts?: Record<string, SessionCategoryCounts>;
}> {
const { paging, category, withCounts } = 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).map((r) => [r.sessionId, r]),
); );
// Unmanaged Trace Sessions: backfill an index row by reading the first line's session_meta. // 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; 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); if (discovered) rows.set(sessionId, discovered);
} }
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 page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted; const toPage = (page: SessionRow[]) =>
return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId)))); 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<string, SessionCategoryCounts> = {};
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); 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<Set<string>> {
const dir = tracesDir(this.deps.root, projectId, agentId);
const ids = new Set<string>();
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 * One walk over the Trace directory: session_id → its **earliest** shard (the shard
* the original meta); null when there is no readable Trace or it has no meta. Shared by * whose head carries the original session_meta). Discovery (which Sessions have
* adoption backfill and lazy `source` resolution. * 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, projectId: string,
agentId: string, agentId: string,
sessionId: string, ): Promise<Map<string, TraceLocation>> {
): Promise<SessionMetaMessage | null> {
const dir = tracesDir(this.deps.root, projectId, agentId); const dir = tracesDir(this.deps.root, projectId, agentId);
let earliest: { path: string; index: number } | null = null; const out = new Map<string, TraceLocation>();
for (const dateDir of await listDirsSafe(dir)) { for (const dateDir of await listDirsSafe(dir)) {
for (const file of await listFilesSafe(path.join(dir, dateDir))) { for (const file of await listFilesSafe(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file); 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]); const index = Number(match[2]);
if (!earliest || index < earliest.index) { const cur = out.get(sessionId);
earliest = { path: path.join(dir, dateDir, file), index }; 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<SessionMetaMessage | null> {
let messages; let messages;
try { try {
messages = await readTraceTolerant(earliest.path); messages = await readTraceHead(location.path);
} catch { } catch {
return null; // Corrupt file: skip (does not block the list) return null; // Corrupt file: skip (does not block the list)
} }
@@ -323,8 +435,9 @@ export class SessionService {
projectId: string, projectId: string,
agentId: string, agentId: string,
sessionId: string, sessionId: string,
location: TraceLocation,
): Promise<SessionRow | null> { ): Promise<SessionRow | null> {
const meta = await this.readTraceMeta(projectId, agentId, sessionId); const meta = await this.readTraceMeta(location);
if (!meta) return null; if (!meta) return null;
// An older Trace version's session_meta lacks provider (the model reference // An older Trace version's session_meta lacks provider (the model reference
// wasn't split into separate fields yet): no backward compat, skip adoption // 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). */ /** Local date as yyyy-mm-dd (matches the Trace date directory convention: core's internal formatLocalDate, not publicly exported). */
function localDate(d: Date): string { function localDate(d: Date): string {
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
+107
View File
@@ -237,6 +237,113 @@ describe("session-index", () => {
expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400); 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 () => { it("half a model reference is 400: the missing half is never inferred", async () => {
await configureModels(); await configureModels();
// Only modelId: even though it names the one configured model, the provider is never // Only modelId: even though it names the one configured model, the provider is never
+16 -7
View File
@@ -42,6 +42,7 @@ import type {
ScheduleItem, ScheduleItem,
SchedulesResponse, SchedulesResponse,
ScheduleUpsertRequest, ScheduleUpsertRequest,
SessionCategory,
SessionCreateRequest, SessionCreateRequest,
SessionCreateResponse, SessionCreateResponse,
SessionPatchRequest, SessionPatchRequest,
@@ -185,17 +186,25 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
// Session --------------------------------------------------------------------- // 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 = ( export const listSessions = (
projectId: string, projectId: string,
agentId: string, agentId: string,
paging?: { offset: number; limit: number }, opts?: { offset: number; limit: number; category?: SessionCategory; withCounts?: boolean },
) => ) => {
apiFetch<SessionsResponse>( const qs = opts
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${ ? `?limit=${opts.limit}&offset=${opts.offset}` +
paging ? `?limit=${paging.limit}&offset=${paging.offset}` : "" (opts.category ? `&category=${opts.category}` : "") +
}`, (opts.withCounts ? "&counts=1" : "")
: "";
return apiFetch<SessionsResponse>(
`/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. */ /** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
export const listDirs = (projectId: string, path = "") => export const listDirs = (projectId: string, path = "") =>
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { PasswordInput } from "../ui/password-input"; import { PasswordInput } from "../ui/password-input";
@@ -17,34 +18,42 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
const [oldPassword, setOldPassword] = useState(""); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [errors, setErrors] = useState<{ old?: string; new?: string; confirm?: string }>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const clearErrors = () => setErrors((p) => (p.old || p.new || p.confirm ? {} : p));
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setOldPassword(""); setOldPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
setError(null); setErrors({});
}, [open]); }, [open]);
const submit = async () => { const submit = async () => {
if (!oldPassword || !newPassword || !confirmPassword) { const next: { old?: string; new?: string; confirm?: string } = {};
setError(S.common.requiredField); if (!oldPassword) next.old = S.common.requiredField;
return; if (!newPassword) next.new = S.common.requiredField;
} if (!confirmPassword) next.confirm = S.common.requiredField;
if (newPassword !== confirmPassword) { if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch;
setError(S.account.passwordMismatch); if (next.old || next.new || next.confirm) {
setErrors(next);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setErrors({});
try { try {
await api.changePassword({ oldPassword, newPassword }); await api.changePassword({ oldPassword, newPassword });
await refresh(); await refresh();
onClose(); onClose();
} catch (e) { } 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -69,29 +78,43 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
<div className="space-y-3"> <div className="space-y-3">
<PasswordInput <PasswordInput
label={S.account.oldPassword} label={S.account.oldPassword}
required
size="sm" size="sm"
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => {
setOldPassword(e.target.value);
clearErrors();
}}
error={errors.old}
autoComplete="current-password" autoComplete="current-password"
hint={S.account.oldPasswordHint} hint={S.account.oldPasswordHint}
autoFocus autoFocus
/> />
<PasswordInput <PasswordInput
label={S.account.newPassword} label={S.account.newPassword}
required
size="sm" size="sm"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => {
setNewPassword(e.target.value);
clearErrors();
}}
error={errors.new}
autoComplete="new-password" autoComplete="new-password"
hint={S.auth.passwordHint} hint={S.auth.passwordHint}
/> />
<PasswordInput <PasswordInput
label={S.account.confirmPassword} label={S.account.confirmPassword}
required
size="sm" size="sm"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => {
setConfirmPassword(e.target.value);
clearErrors();
}}
error={errors.confirm}
autoComplete="new-password" autoComplete="new-password"
/> />
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
</Modal> </Modal>
); );
@@ -6,8 +6,8 @@
*/ */
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { NavLink, Outlet, useMatch, useNavigate } from "react-router"; import { NavLink, Outlet, useMatch, useNavigate } from "react-router";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { latestConversation } from "../../lib/session-grouping";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions"; 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. */ /** 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; 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). */ /** 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( const lastSession = useMemo(() => latestConversation(sessions), [sessions]);
() =>
sessions.reduce<SessionInfo | null>(
(best, s) =>
!s.archived && (!best || Date.parse(s.createdAt) > Date.parse(best.createdAt)) ? s : best,
null,
),
[sessions],
);
/** Mirrors Sidebar.openSession: the current Agent follows the opened Session's Agent. */ /** Mirrors Sidebar.openSession: the current Agent follows the opened Session's Agent. */
const openLastSession = () => { const openLastSession = () => {
@@ -5,8 +5,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { MemberInfo } from "@prismshadow/penguin-server/api"; import type { MemberInfo } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { import {
PROJECT_ID_MAX_LENGTH, PROJECT_ID_MAX_LENGTH,
PROJECT_SUFFIX_PATTERN, PROJECT_SUFFIX_PATTERN,
@@ -16,7 +16,10 @@ import { projectDisplayName, useProject } from "../../state/project";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { FieldError, FieldHint, FieldLabel } from "../ui/field";
import { toastError } from "../ui/toast";
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
import { ConfirmModal } from "../ui/confirm-modal";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
export function CreateProjectDialog({ export function CreateProjectDialog({
@@ -33,7 +36,8 @@ export function CreateProjectDialog({
const prefix = user && !user.isAdmin ? `${user.userId}-` : ""; const prefix = user && !user.isAdmin ? `${user.userId}-` : "";
const [idInput, setIdInput] = useState(""); const [idInput, setIdInput] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [error, setError] = useState<string | null>(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<string | undefined>(undefined);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// No draft is kept: the form starts empty every time it opens. // No draft is kept: the form starts empty every time it opens.
@@ -41,13 +45,13 @@ export function CreateProjectDialog({
if (!open) return; if (!open) return;
setIdInput(""); setIdInput("");
setName(""); setName("");
setError(null); setIdError(undefined);
}, [open]); }, [open]);
const submit = async () => { const submit = async () => {
const id = prefix + idInput.trim(); const id = prefix + idInput.trim();
if (!idInput.trim()) { if (!idInput.trim()) {
setError(S.common.requiredField); setIdError(S.common.requiredField);
return; 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. // 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 ? PROJECT_SUFFIX_PATTERN.test(idInput.trim()) && id.length <= PROJECT_ID_MAX_LENGTH
: SEMANTIC_ID_PATTERN.test(id); : SEMANTIC_ID_PATTERN.test(id);
if (!valid) { if (!valid) {
setError(prefix ? S.project.idPrefixHint : S.project.idHint); setIdError(prefix ? S.project.idPrefixHint : S.project.idHint);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setIdError(undefined);
try { try {
const res = await api.createProject({ const res = await api.createProject({
projectId: id, projectId: id,
@@ -67,7 +71,7 @@ export function CreateProjectDialog({
}); });
onCreated(res.project.projectId); onCreated(res.project.projectId);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setIdError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -90,9 +94,7 @@ export function CreateProjectDialog({
<div className="space-y-3"> <div className="space-y-3">
{prefix ? ( {prefix ? (
<div> <div>
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> <FieldLabel required>{S.project.id}</FieldLabel>
{S.project.id}
</span>
<div className="flex items-stretch"> <div className="flex items-stretch">
<span className="flex shrink-0 items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-100 px-2 font-mono text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"> <span className="flex shrink-0 items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-100 px-2 font-mono text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{prefix} {prefix}
@@ -101,20 +103,31 @@ export function CreateProjectDialog({
size="sm" size="sm"
className="rounded-l-none" className="rounded-l-none"
value={idInput} value={idInput}
onChange={(e) => setIdInput(e.target.value)} invalid={Boolean(idError)}
onChange={(e) => {
setIdInput(e.target.value);
setIdError(undefined);
}}
autoFocus autoFocus
/> />
</div> </div>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500"> {idError ? (
{S.project.idPrefixHint} <FieldError>{idError}</FieldError>
</span> ) : (
<FieldHint>{S.project.idPrefixHint}</FieldHint>
)}
</div> </div>
) : ( ) : (
<Input <Input
label={S.project.id} label={S.project.id}
required
size="sm" size="sm"
value={idInput} value={idInput}
onChange={(e) => setIdInput(e.target.value)} error={idError}
onChange={(e) => {
setIdInput(e.target.value);
setIdError(undefined);
}}
hint={S.project.idHint} hint={S.project.idHint}
autoFocus autoFocus
/> />
@@ -125,7 +138,6 @@ export function CreateProjectDialog({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
</Modal> </Modal>
); );
@@ -137,7 +149,8 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject(); const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
const [members, setMembers] = useState<MemberInfo[] | null>(null); const [members, setMembers] = useState<MemberInfo[] | null>(null);
const [newMemberId, setNewMemberId] = useState(""); const [newMemberId, setNewMemberId] = useState("");
const [error, setError] = useState<string | null>(null); // Only the initial member-list load shows inline (in place of the table); action failures pop a toast.
const [loadError, setLoadError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const projectId = currentProject?.projectId; const projectId = currentProject?.projectId;
@@ -146,42 +159,39 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
useEffect(() => { useEffect(() => {
if (!open || !projectId) return; if (!open || !projectId) return;
setMembers(null); setMembers(null);
setError(null); setLoadError(null);
setConfirmDelete(false); setConfirmDelete(false);
api api
.listMembers(projectId) .listMembers(projectId)
.then((res) => setMembers(res.members)) .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]); }, [open, projectId]);
if (!currentProject || !projectId) return null; if (!currentProject || !projectId) return null;
const addMember = async () => { const addMember = async () => {
if (!newMemberId.trim()) return; if (!newMemberId.trim()) return;
setError(null);
try { try {
await api.addMember(projectId, { userId: newMemberId.trim() }); await api.addMember(projectId, { userId: newMemberId.trim() });
setNewMemberId(""); setNewMemberId("");
const res = await api.listMembers(projectId); const res = await api.listMembers(projectId);
setMembers(res.members); setMembers(res.members);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} }
}; };
const doRemove = async (memberId: string) => { const doRemove = async (memberId: string) => {
setError(null);
try { try {
await api.removeMember(projectId, memberId); await api.removeMember(projectId, memberId);
const res = await api.listMembers(projectId); const res = await api.listMembers(projectId);
setMembers(res.members); setMembers(res.members);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} }
}; };
const doDelete = async () => { const doDelete = async () => {
setError(null);
try { try {
await api.deleteProject(projectId); await api.deleteProject(projectId);
onClose(); onClose();
@@ -189,7 +199,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
await reloadProjects(); await reloadProjects();
if (next) setCurrentProjectId(next.projectId); if (next) setCurrentProjectId(next.projectId);
} catch (e) { } 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
<div> <div>
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p> <p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
{members === null ? ( {loadError ? (
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
) : members === null ? (
<p className="text-xs text-gray-400">{S.common.loading}</p> <p className="text-xs text-gray-400">{S.common.loading}</p>
) : ( ) : (
// Member permission table: username / role / actions; cells never wrap. // Member permission table: username / role / actions; cells never wrap.
@@ -216,13 +228,11 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
<thead> <thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400"> <tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium"> <th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.project.memberUsername} {S.common.username}
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.project.memberRole}
</th> </th>
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">{S.common.role}</th>
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium"> <th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
{S.project.memberActions} {S.common.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -246,7 +256,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
<tr> <tr>
<td className="px-2.5 py-1.5"> <td className="px-2.5 py-1.5">
<Input <Input
placeholder={S.project.memberUsername} placeholder={S.common.username}
size="sm" size="sm"
value={newMemberId} value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)} onChange={(e) => 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 // 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. // (the page would get stuck on the skeleton screen), so the frontend hides the entry point outright, matching the server's 409 rejection.
<p className="text-xs text-gray-400">{S.project.deleteLastForbidden}</p> <p className="text-xs text-gray-400">{S.project.deleteLastForbidden}</p>
) : confirmDelete ? (
<div className="space-y-2">
<p className="text-xs text-red-600 dark:text-red-400">{S.project.deleteConfirm}</p>
<div className="flex gap-2">
<Button size="sm" onClick={() => setConfirmDelete(false)}>
{S.common.cancel}
</Button>
<Button size="sm" variant="danger" onClick={() => void doDelete()}>
{S.common.confirm}
</Button>
</div>
</div>
) : ( ) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDelete(true)}> <Button size="sm" variant="danger" onClick={() => setConfirmDelete(true)}>
{S.project.deleteProject} {S.project.deleteProject}
@@ -302,9 +300,17 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
)} )}
</div> </div>
)} )}
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
{/* Delete confirmation (shared ConfirmModal, stacked above the settings dialog). */}
<ConfirmModal
open={confirmDelete}
title={S.project.deleteProject}
onClose={() => setConfirmDelete(false)}
onConfirm={() => void doDelete()}
>
<p className="text-sm text-gray-600 dark:text-gray-300">{S.project.deleteConfirm}</p>
</ConfirmModal>
</Modal> </Modal>
); );
} }
+197 -142
View File
@@ -18,10 +18,14 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { NavLink, useMatch, useNavigate } from "react-router"; 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 * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { useLocale } from "../../state/locale"; import { useLocale } from "../../state/locale";
import type { LangPref } 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 { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
import { useSessions } from "../../state/sessions"; import { useSessions } from "../../state/sessions";
import { import {
FOLDER_CATEGORIES,
SIDEBAR_PAGE_SIZE, SIDEBAR_PAGE_SIZE,
groupAgentsWithMore, aggregateWorkspaceCounts,
groupSessionsByWorkspace, groupSessionsByWorkspace,
partitionSessions, partitionSessions,
pinnedFirst, pinnedFirst,
sessionCategory,
workspaceGroupKey, workspaceGroupKey,
} from "../../lib/session-grouping"; } 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 { 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";
import { ChevronDown } from "../ui/icons";
import { toastError } from "../ui/toast";
import { Truncated } from "../ui/truncated"; import { Truncated } from "../ui/truncated";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
import { ConfirmModal } from "../ui/confirm-modal";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Segmented } from "../ui/segmented"; 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 (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" aria-hidden>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */ /** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */
export const NAV_ICONS = { 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", 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<string>): voi
} }
/** /**
* Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder * Open-state key of a collapsed folder (subagent / scheduled / archived) inside a group:
* has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite * each folder has its own state. "\0" never appears in Agent ids or Workspace paths, so
* never collides across groups or with plain group keys. * 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. */ /** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
function StatusDot({ session }: { session: SessionInfo }) { function StatusDot({ session }: { session: SessionInfo }) {
@@ -200,8 +200,18 @@ export function Sidebar({
currentAgent, currentAgent,
setCurrentAgentId, setCurrentAgentId,
} = useProject(); } = useProject();
const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } = const {
useSessions(); sessions,
byAgent,
countsByAgent,
workspaceCountsByAgent,
isLoadedFor,
hasMoreFor,
loadMoreFor,
loading,
remove,
replace,
} = useSessions();
const chatMatch = useMatch("/chat/:sessionId"); const chatMatch = useMatch("/chat/:sessionId");
const activeSessionId = chatMatch?.params.sessionId ?? null; const activeSessionId = chatMatch?.params.sessionId ?? null;
@@ -228,16 +238,15 @@ export function Sidebar({
setCollapsedGroups(loadGroupSet(collapseStoreKey)); setCollapsedGroups(loadGroupSet(collapseStoreKey));
setPinnedGroups(loadGroupSet(pinStoreKey)); setPinnedGroups(loadGroupSet(pinStoreKey));
}, [collapseStoreKey, pinStoreKey]); }, [collapseStoreKey, pinStoreKey]);
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */ /** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set()); const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
/** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */ /** "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 [openSourceFolders, setOpenSourceFolders] = 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());
/** 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);
const [deleteError, setDeleteError] = useState<string | null>(null);
/** Session currently being renamed (null = none) and the title being typed. */ /** Session currently being renamed (null = none) and the title being typed. */
const [renamingSession, setRenamingSession] = useState<SessionInfo | null>(null); const [renamingSession, setRenamingSession] = useState<SessionInfo | null>(null);
const [renameText, setRenameText] = useState(""); const [renameText, setRenameText] = useState("");
@@ -252,6 +261,12 @@ export function Sidebar({
/** 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. */
const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]); 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 // Pinned groups first within each mode; inside each partition the existing order is kept
// (recency for Workspace groups, the configured Agent order for Agents). // (recency for Workspace groups, the configured Agent order for Agents).
const orderedAgents = useMemo( const orderedAgents = useMemo(
@@ -286,22 +301,42 @@ export function Sidebar({
saveGroupSet(pinStoreKey, next); saveGroupSet(pinStoreKey, next);
}; };
const toggleArchivedGroup = (key: string) => /** In-flight key of one group's category "More" (folderKey shares the same composite for folder categories). */
setOpenArchived((prev) => { const loadKey = (groupKey: string, category: SessionCategory) => `${category}\0${groupKey}`;
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleSourceFolder = (groupKey: string, source: SessionSource) => /** loadMoreFor with an in-flight marker for the triggering "More" row (disable + loading text). */
setOpenSourceFolders((prev) => { const trackedLoadMore = (groupKey: string, category: SessionCategory, agentIds: string[]) => {
const key = sourceFolderKey(groupKey, source); 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); const next = new Set(prev);
if (next.has(key)) next.delete(key); if (opening) next.add(key);
else next.add(key); else next.delete(key);
return next; 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 // 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 // group, so the active row is never hidden inside a collapsed folder (mirrors the archived
@@ -315,20 +350,25 @@ export function Sidebar({
useEffect(() => { useEffect(() => {
if (!activeSessionId) return; if (!activeSessionId) return;
const s = sessions.find((x) => x.sessionId === activeSessionId); 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}`; const guard = `${groupMode}\0${activeSessionId}`;
if (lastAutoExpandedRef.current === guard) return; if (lastAutoExpandedRef.current === guard) return;
lastAutoExpandedRef.current = guard; lastAutoExpandedRef.current = guard;
const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace); const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
const key = sourceFolderKey(groupKey, s.source); const key = folderKey(groupKey, category);
setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); setOpenFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
}, [activeSessionId, sessions, groupMode]); // 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). */ /** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
const toggleArchive = async (s: SessionInfo) => { 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) { 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 { try {
const res = await api.patchSession(s.sessionId, { archived: !s.archived }); const res = await api.patchSession(s.sessionId, { archived: !s.archived });
@@ -349,7 +389,7 @@ export function Sidebar({
replace(res.session); replace(res.session);
setRenamingSession(null); setRenamingSession(null);
} catch (e) { } catch (e) {
setRenameError(e instanceof ApiError ? e.message : S.common.unknownError); setRenameError(apiErrorText(e));
} finally { } finally {
setRenameBusy(false); setRenameBusy(false);
} }
@@ -358,7 +398,6 @@ export function Sidebar({
const confirmDeleteSession = async () => { const confirmDeleteSession = async () => {
if (!deletingSession) return; if (!deletingSession) return;
setDeletingBusy(true); setDeletingBusy(true);
setDeleteError(null);
const target = deletingSession; const target = deletingSession;
try { try {
await api.deleteSession(target.sessionId); 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). // 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)); if (user) clearDraft(sessionDraftKey(user.userId, target.sessionId));
setDeletingSession(null); setDeletingSession(null);
// The deleted session was the one open: jump to another **unarchived** Session in the same // The deleted session was the one open: jump to this Agent's next conversation, otherwise
// group, otherwise fall back to the chat home page (never jump into an archived session — // fall back to the chat home page. Auto-opened conversations are never archived (hidden by
// it's hidden by default, so landing there would look like the chat vanished into thin air). // 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) { if (activeSessionId === target.sessionId) {
const rest = (byAgent.get(target.agentId) ?? []).filter( const rest = (byAgent.get(target.agentId) ?? []).filter((s) => {
(s) => s.sessionId !== target.sessionId && !s.archived, const category = sessionCategory(s);
); return (
s.sessionId !== target.sessionId && (category === "active" || category === "schedule")
);
});
navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat"); navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat");
} }
} catch (e) { } catch (e) {
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} finally { } finally {
setDeletingBusy(false); setDeletingBusy(false);
} }
@@ -440,10 +483,7 @@ export function Sidebar({
setRenameText(x.title ?? ""); setRenameText(x.title ?? "");
setRenamingSession(x); setRenamingSession(x);
}} }}
onDelete={(x) => { onDelete={(x) => setDeletingSession(x)}
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)} onToggleArchive={(x) => void toggleArchive(x)}
/> />
))} ))}
@@ -453,65 +493,102 @@ export function Sidebar({
const folderClass = 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"; "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, groupKey: string,
source: SessionSource, category: FolderCategory,
rows: SessionInfo[], parts: SessionPartition,
withAgentHint: boolean, 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 rows = parts[category];
const open = openSourceFolders.has(sourceFolderKey(groupKey, source)); // 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 ( return (
<div className="mt-1"> <div key={category} className="mt-1">
<button <button
type="button" type="button"
onClick={() => toggleSourceFolder(groupKey, source)} onClick={() => toggleFolder(groupKey, category, agentIds)}
className={folderClass} className={folderClass}
> >
<Chevron open={open} size={12} /> <Chevron open={open} size={12} />
{S.chat.sourceGroups[source](rows.length)} {S.chat.folderGroups[category](total)}
</button> </button>
{open && renderRows(rows, withAgentHint)} {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 && (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={pending}
onClick={() => trackedLoadMore(groupKey, category, agentIds)}
className={`${folderClass} disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{pending ? S.common.loading : S.chat.loadMore}
</button>
)}
</div> </div>
); );
}; };
/** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */ /** 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, moreAgents: string[]) => { const showMore = (groupKey: string, agentIds: string[]) => {
setGroupCaps((prev) => { setGroupCaps((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE); next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
return next; 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 * Expanded group body shared by both modes: active user rows (display-capped; "More"
* loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups * reveals and loads further **active-only** pages — the folders below never feed it) +
* (keyed by the group key; rendered uncapped over loaded data — they are collapsed by * the collapsed-by-default subagent / scheduled / archived folders, each loading on
* default and only ever hold what the pages brought in). * 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 = ( const renderGroupBody = (
groupKey: string, groupKey: string,
parts: SessionPartition, parts: SessionPartition,
withAgentHint: boolean, withAgentHint: boolean,
/** Agents contributing to this group that still have unfetched server pages. */ totals: SessionCategoryCounts | undefined,
moreAgents: string[], agentsFor: (category: SessionCategory) => string[],
) => { ) => {
const archivedOpen = openArchived.has(groupKey);
const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE; const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
const shownActive = parts.active.slice(0, cap); const shownActive = parts.active.slice(0, cap);
// "More" while hidden loaded rows exist OR any contributing Agent has server-side pages // Only the outer active rows drive the group's "More" — the folders never feed it:
// left; a fetched page can also land rows in the folders below, so one click may grow // hidden loaded rows exist, or the group's own active share isn't fully loaded yet.
// the visible list by fewer than a full page — the row simply stays until exhausted. const activeAgents = agentsFor("active");
const hasMore = parts.active.length > cap || moreAgents.length > 0; const activeTotal = Math.max(totals?.active ?? 0, parts.active.length);
const empty = const hasMore =
parts.active.length === 0 && parts.active.length > cap ||
parts.subagent.length === 0 && (parts.active.length < activeTotal && activeAgents.some((id) => hasMoreFor(id, "active")));
parts.schedule.length === 0 && const folders = FOLDER_CATEGORIES.map((category) =>
parts.archived.length === 0; 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 ( return (
<> <>
{empty ? ( {empty ? (
@@ -527,33 +604,19 @@ export function Sidebar({
<button <button
type="button" type="button"
aria-label={S.chat.loadMore} aria-label={S.chat.loadMore}
onClick={() => showMore(groupKey, moreAgents)} disabled={activePending}
className={`${folderClass} mt-0.5`} onClick={() => showMore(groupKey, activeAgents)}
className={`${folderClass} mt-0.5 disabled:opacity-60`}
> >
<span className="w-3" aria-hidden /> <span className="w-3" aria-hidden />
{S.chat.loadMore} {activePending ? S.common.loading : S.chat.loadMore}
</button> </button>
)} )}
{/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned {/* Folders (collapsed by default): subagent first — spawned from the conversations
from the conversations at hand — then scheduled background runs. */} at hand — then scheduled background runs, then archived (archived wins over the
{renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)} origin folders). */}
{renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)} {folders}
{/* Archived group (collapsed by default; archived wins over the per-origin folders) */}
{parts.archived.length > 0 && (
<div className="mt-1">
<button
type="button"
onClick={() => toggleArchivedGroup(groupKey)}
className={folderClass}
>
<Chevron open={archivedOpen} size={12} />
{S.chat.archivedGroup(parts.archived.length)}
</button>
{archivedOpen && renderRows(parts.archived, withAgentHint)}
</div>
)}
</> </>
); );
}; };
@@ -617,7 +680,7 @@ export function Sidebar({
{currentProject ? projectDisplayName(currentProject) : S.common.loading} {currentProject ? projectDisplayName(currentProject) : S.common.loading}
</span> </span>
<span className="text-gray-400"> <span className="text-gray-400">
<DropdownCaret /> <ChevronDown />
</span> </span>
</button> </button>
} }
@@ -803,7 +866,8 @@ export function Sidebar({
agent.agentId, agent.agentId,
parts, parts,
false, false,
hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [], countsByAgent.get(agent.agentId),
() => [agent.agentId],
)} )}
</div> </div>
); );
@@ -820,6 +884,12 @@ export function Sidebar({
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);
/** 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 ( return (
<div key={group.key} className="pt-2.5"> <div key={group.key} className="pt-2.5">
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace. {/* 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({
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400"> <span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
{group.temp ? S.chat.tempWorkspaces : group.label} {group.temp ? S.chat.tempWorkspaces : group.label}
</span> </span>
{/* 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. */}
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500"> <span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{parts.active.length + parts.subagent.length + parts.schedule.length} {Math.max(counts?.totals.active ?? 0, parts.active.length)}
</span> </span>
<Chevron open={!collapsed} size={12} className="text-gray-400" /> <Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" /> <span className="min-w-0 flex-1" />
@@ -862,15 +933,13 @@ export function Sidebar({
</button> </button>
</div> </div>
{/* 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 {collapsed
? null ? null
: renderGroupBody( : renderGroupBody(group.key, parts, true, counts?.totals, agentsFor)}
group.key,
parts,
true,
groupAgentsWithMore(group.sessions, hasMoreByAgent),
)}
</div> </div>
); );
}) })
@@ -1001,47 +1070,34 @@ export function Sidebar({
<Input <Input
label={S.chat.renameSessionLabel} label={S.chat.renameSessionLabel}
value={renameText} value={renameText}
error={renameError ?? undefined}
autoFocus autoFocus
maxLength={120} maxLength={120}
onChange={(e) => setRenameText(e.target.value)} onChange={(e) => {
setRenameText(e.target.value);
if (renameError) setRenameError(null);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename(); if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename();
}} }}
/> />
{renameError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{renameError}</p>
)}
</Modal> </Modal>
{/* Delete chat confirmation */} {/* Delete chat confirmation (shared ConfirmModal) */}
<Modal <ConfirmModal
open={deletingSession !== null} open={deletingSession !== null}
title={S.chat.deleteSession} title={S.chat.deleteSession}
confirmLabel={S.common.delete}
busy={deletingBusy}
onClose={() => (deletingBusy ? undefined : setDeletingSession(null))} onClose={() => (deletingBusy ? undefined : setDeletingSession(null))}
footer={ onConfirm={() => void confirmDeleteSession()}
<>
<Button onClick={() => setDeletingSession(null)} disabled={deletingBusy}>
{S.common.cancel}
</Button>
<Button
variant="danger"
disabled={deletingBusy}
onClick={() => void confirmDeleteSession()}
>
{S.common.delete}
</Button>
</>
}
> >
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
{deletingSession {deletingSession
? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle) ? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle)
: ""} : ""}
</p> </p>
{deleteError && ( </ConfirmModal>
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
)}
</Modal>
</div> </div>
); );
} }
@@ -1129,8 +1185,7 @@ function SessionRow({
: "text-gray-700 dark:text-gray-300" : "text-gray-700 dark:text-gray-300"
}`} }`}
/> />
{/* Source badge: schedule / sub-session (user-created sessions have no source and show nothing). */} {/* 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.source && <Badge tone="gray">{S.chat.sourceNames[s.source] ?? s.source}</Badge>}
<StatusDot session={s} /> <StatusDot session={s} />
{s.pendingApprovalCount > 0 && ( {s.pendingApprovalCount > 0 && (
<span title={S.chat.pendingApprovals(s.pendingApprovalCount)}> <span title={S.chat.pendingApprovals(s.pendingApprovalCount)}>
@@ -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 (
<span
aria-hidden
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
tone === "danger"
? "bg-red-100 text-red-600 dark:bg-red-950/60 dark:text-red-400"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
}`}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={glyph} />
</svg>
</span>
);
}
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 (
<Modal open={open} title={title} onClose={onClose} headerless widthClass="sm:max-w-sm">
<div className="flex items-start gap-3">
<ToneBadge tone={tone} />
<div className="min-w-0 flex-1 pt-1.5">{children}</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button size="sm" onClick={onClose} disabled={busy}>
{S.common.cancel}
</Button>
<Button size="sm" variant={tone} disabled={busy} onClick={onConfirm}>
{confirmLabel ?? S.common.confirm}
</Button>
</div>
</Modal>
);
}
/**
* 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 = (
<ConfirmModal
open={pending !== null}
title={S.common.confirmSaveTitle}
tone="primary"
confirmLabel={S.common.save}
onClose={() => setPending(null)}
onConfirm={() => {
pending?.();
setPending(null);
}}
>
<p className="text-sm text-gray-600 dark:text-gray-300">{S.common.confirmSaveBody}</p>
</ConfirmModal>
);
return { requestSave: (run: () => void) => setPending(() => run), element };
}
+2 -11
View File
@@ -4,7 +4,7 @@
*/ */
import { useEffect } from "react"; import { useEffect } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { S } from "../../lib/strings"; import { CloseButton } from "./icons";
export interface DrawerProps { export interface DrawerProps {
open: boolean; open: boolean;
@@ -39,16 +39,7 @@ export function Drawer({ open, side = "left", title, onClose, children, widthCla
> >
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"> <div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<span className="text-base font-semibold">{title ?? ""}</span> <span className="text-base font-semibold">{title ?? ""}</span>
<button <CloseButton onClose={onClose} />
type="button"
aria-label={S.common.close}
onClick={onClose}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"> <div className="min-h-0 flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]">
{children} {children}
+92
View File
@@ -0,0 +1,92 @@
/**
* Shared field scaffolding: the label / hint / error text and the wrapping
* <label>, plus the class strings for a control's "look" and a menu row. Every
* form control (Input, Textarea, Select, OptionMenu, PasswordInput) and every
* form that shows a field error builds on these, instead of the five-plus
* near-identical copies that used to live in each control and call site.
*/
import type { ReactNode } from "react";
/**
* The control "look" shared by Input, Select and OptionMenu: rounded box, gray
* border that darkens on hover, brand focus ring, dark-mode variants. Excludes
* layout (width/flex), text size, placeholder and disabled styling — each
* control appends what it needs.
*/
export const controlBase =
"rounded-md border border-gray-300 bg-white text-gray-900 " +
"transition-[border-color,box-shadow] duration-200 " +
"hover:border-gray-400 focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 " +
"dark:hover:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
/** Base padding/alignment/transition for a menu row (Select, OptionMenu, and the chat composer menus); callers add flex/block and the hover/selected colors. */
export const menuRowClass = "w-full px-3 py-1.5 text-left transition-colors duration-150";
export function FieldLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{children}
{required && (
<span className="ml-0.5 text-red-500 dark:text-red-400" aria-hidden>
*
</span>
)}
</span>
);
}
export function FieldHint({ children }: { children: ReactNode }) {
return <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{children}</span>;
}
/**
* Field-level error text: placed directly below the offending input (the input itself
* is highlighted red via the control's error/invalid state). role="alert" announces the
* message to assistive tech the moment it appears; `id` is the anchor the control's
* aria-describedby points at, so AT users can also find *what* is wrong from the input.
*/
export function FieldError({ id, children }: { id?: string; children: ReactNode }) {
return (
<span id={id} role="alert" className="mt-1 block text-xs text-red-600 dark:text-red-400">
{children}
</span>
);
}
/**
* Standard vertical field layout: an optional bold label above the control, and
* below it a red error if present, otherwise a gray hint. Renders the bare
* control (no wrapper) when there's no label/hint/error, so a caller can drop it
* into a toolbar or table cell unchanged.
*/
export function Field({
label,
hint,
error,
errorId,
required,
children,
}: {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
/** id for the error text — the control passes the same id in aria-describedby. */
errorId?: string;
/** Renders a red "*" after the label to mark the field as required. */
required?: boolean;
children: ReactNode;
}) {
if (!label && !hint && !error) return <>{children}</>;
return (
<label className="block">
{label != null && label !== "" && <FieldLabel required={required}>{label}</FieldLabel>}
{children}
{error ? (
<FieldError id={errorId}>{error}</FieldError>
) : hint ? (
<FieldHint>{hint}</FieldHint>
) : null}
</label>
);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Shared single-path icons and the dialog close button, replacing SVGs that were
* inlined identically at many call sites. Note `chevron.tsx` is a *different*
* glyph (the rotating right-caret used by collapsibles) and stays separate.
*/
import type { ButtonHTMLAttributes } from "react";
import { S } from "../../lib/strings";
/** Downward caret on Select / OptionMenu / composer dropdown triggers. Color follows currentColor (callers add text-gray-400). */
export function ChevronDown({ size = 12, className = "" }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
aria-hidden
className={`shrink-0 ${className}`}
>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Selected-row checkmark in the Select / OptionMenu menus. */
export function CheckIcon({ size = 13, className = "" }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden
className={`shrink-0 ${className}`}
>
<path d="M5 12l4 4L19 6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** "Add" plus glyph used by create buttons / new-row affordances. */
export function PlusIcon({
size = 14,
strokeWidth = 1.7,
className = "",
}: {
size?: number;
strokeWidth?: number;
className?: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden
className={`shrink-0 ${className}`}
>
<path
d="M12 5v14M5 12h14"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* The X close button shared by the Modal / Drawer / Sheet headers: same glyph,
* padding and hover treatment. Extra button props (e.g. Sheet's onPointerDown
* guard) pass through.
*/
export function CloseButton({
onClose,
className = "",
...rest
}: { onClose: () => void; className?: string } & Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"onClick"
>) {
return (
<button
type="button"
aria-label={S.common.close}
onClick={onClose}
className={`rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 ${className}`}
{...rest}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
);
}
+41 -50
View File
@@ -1,18 +1,17 @@
/** /**
* Text input component: optional label and hint/error text; rounded corners with * Text input component: optional label and hint/error text; rounded corners with
* a hover border darken and brand focus-ring transition. * a hover border darken and brand focus-ring transition. Shares the control look
* and the label/hint/error scaffolding with the other form controls via field.tsx.
*/ */
import { forwardRef } from "react"; import { forwardRef, useId } from "react";
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { Field, controlBase } from "./field";
// Excludes font size and padding: each of Input/Textarea appends its own (see their size). // Adds width, placeholder and disabled styling on top of the shared control look; each of Input/Textarea appends its own font size and padding (see their size).
const baseClass = const baseClass =
"w-full rounded-md border border-gray-300 bg-white text-gray-900 " + `w-full ${controlBase} ` +
"placeholder:text-gray-400 transition-[border-color,box-shadow] duration-200 " + "placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-60 " +
"hover:border-gray-400 focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " + "dark:placeholder:text-gray-500";
"disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 " +
"dark:hover:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
/** Size tier: base (form default) / sm (compact contexts like filter bars, keeps the toolbar from growing taller). */ /** Size tier: base (form default) / sm (compact contexts like filter bars, keeps the toolbar from growing taller). */
export type ControlSize = "base" | "sm"; export type ControlSize = "base" | "sm";
@@ -38,13 +37,14 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
/** /**
* Error state: red border + light red background (a failed field is visible at a * Error state: red border + light red background (a failed field is visible at a
* glance; the error text sits below the box). * glance; the error text sits below the box). Shared with Select/OptionMenu so a
* failed dropdown reads exactly like a failed input.
* Forced with `!` — baseClass's border-gray-300 / bg-white are the same kind of * Forced with `!` — baseClass's border-gray-300 / bg-white are the same kind of
* border/background utility classes, and which one wins depends on the order the * border/background utility classes, and which one wins depends on the order the
* CSS was generated in, not the order of classes in the string (without `!important` * CSS was generated in, not the order of classes in the string (without `!important`
* this would get overridden). * this would get overridden).
*/ */
const errorClass = export const errorClass =
"!border-red-400 !bg-red-50 hover:!border-red-500 focus:!border-red-500 focus:!ring-red-400/30 " + "!border-red-400 !bg-red-50 hover:!border-red-500 focus:!border-red-500 focus:!ring-red-400/30 " +
"dark:!border-red-800 dark:!bg-red-950/40 dark:hover:!border-red-700 dark:focus:!border-red-600"; "dark:!border-red-800 dark:!bg-red-950/40 dark:hover:!border-red-700 dark:focus:!border-red-600";
@@ -53,39 +53,35 @@ export function Input({
hint, hint,
error, error,
invalid, invalid,
required,
size = "base", size = "base",
className, className,
...rest ...rest
}: InputProps) { }: InputProps) {
const bad = Boolean(error) || Boolean(invalid); const bad = Boolean(error) || Boolean(invalid);
const control = ( const errorId = useId();
<input // `required` drives the label's asterisk + aria-required only; the native `required`
className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`} // attribute is intentionally not forwarded (the app validates on submit, so browser
aria-invalid={bad ? true : undefined} // validation bubbles would collide with the inline field errors).
{...rest}
/>
);
if (!label && !hint && !error) return control;
return ( return (
<label className="block"> <Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
{label && ( <input
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`}
{label} aria-invalid={bad ? true : undefined}
</span> aria-required={required || undefined}
)} aria-describedby={error ? errorId : undefined}
{control} {...rest}
{error ? ( />
<span className="mt-1 block text-xs text-red-600 dark:text-red-400">{error}</span> </Field>
) : hint ? (
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>
) : null}
</label>
); );
} }
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> { export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string; label?: string;
hint?: string; hint?: string;
error?: string;
/** Marks the field red without rendering error text (see Input.invalid). */
invalid?: boolean;
/** Monospace font (for editing Prompts/parameters). */ /** Monospace font (for editing Prompts/parameters). */
mono?: boolean; mono?: boolean;
/** Font size: base (default, matches body text) or sm (smaller, for editing long Prompts). */ /** Font size: base (default, matches body text) or sm (smaller, for editing long Prompts). */
@@ -93,26 +89,21 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
} }
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea( export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, hint, mono, size = "base", className, ...rest }, { label, hint, error, invalid, required, mono, size = "base", className, ...rest },
ref, ref,
) { ) {
const control = ( const bad = Boolean(error) || Boolean(invalid);
<textarea const errorId = useId();
ref={ref}
className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${className ?? ""}`}
{...rest}
/>
);
if (!label && !hint) return control;
return ( return (
<label className="block"> <Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
{label && ( <textarea
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> ref={ref}
{label} className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${bad ? errorClass : ""} ${className ?? ""}`}
</span> aria-invalid={bad ? true : undefined}
)} aria-required={required || undefined}
{control} aria-describedby={error ? errorId : undefined}
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>} {...rest}
</label> />
</Field>
); );
}); });
+20 -15
View File
@@ -14,16 +14,19 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { S } from "../../lib/strings"; import { CloseButton } from "./icons";
export interface ModalProps { export interface ModalProps {
open: boolean; open: boolean;
/** Dialog name: rendered as the header bar, or (headerless) exposed as the panel's aria-label only. */
title: string; title: string;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
/** Panel width class (defaults to sm:max-w-md). */ /** Panel width class (defaults to sm:max-w-md). */
widthClass?: string; widthClass?: string;
/** No header bar (no visible title, no close button): compact dialogs like confirmations — the title still names the dialog for assistive tech. */
headerless?: boolean;
} }
/** /**
@@ -36,7 +39,15 @@ export interface ModalProps {
*/ */
const modalStack: symbol[] = []; const modalStack: symbol[] = [];
export function Modal({ open, title, onClose, children, footer, widthClass }: ModalProps) { export function Modal({
open,
title,
onClose,
children,
footer,
widthClass,
headerless,
}: ModalProps) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const id = Symbol("modal"); const id = Symbol("modal");
@@ -61,21 +72,15 @@ export function Modal({ open, title, onClose, children, footer, widthClass }: Mo
}} }}
> >
<div <div
{...(headerless ? { role: "dialog", "aria-label": title } : {})}
className={`anim-pop w-full ${widthClass ?? "sm:max-w-md"} rounded-t-lg border border-gray-200 bg-white pb-[env(safe-area-inset-bottom)] shadow-xl sm:rounded-lg sm:pb-0 dark:border-gray-800 dark:bg-gray-900`} className={`anim-pop w-full ${widthClass ?? "sm:max-w-md"} rounded-t-lg border border-gray-200 bg-white pb-[env(safe-area-inset-bottom)] shadow-xl sm:rounded-lg sm:pb-0 dark:border-gray-800 dark:bg-gray-900`}
> >
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"> {!headerless && (
<h2 className="text-base font-semibold">{title}</h2> <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<button <h2 className="text-base font-semibold">{title}</h2>
type="button" <CloseButton onClose={onClose} />
aria-label={S.common.close} </div>
onClick={onClose} )}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-4 py-4">{children}</div> <div className="max-h-[70vh] overflow-y-auto px-4 py-4">{children}</div>
{footer && ( {footer && (
<div className="flex justify-end gap-2 border-t border-gray-200 px-4 py-3 dark:border-gray-800"> <div className="flex justify-end gap-2 border-t border-gray-200 px-4 py-3 dark:border-gray-800">
+20 -40
View File
@@ -25,10 +25,12 @@
* the root stacking context, and this component may also be used inside a Modal * the root stacking context, and this component may also be used inside a Modal
* form, where z-40 would get covered by the overlay. * form, where z-40 would get covered by the overlay.
*/ */
import { useState } from "react"; import { useId, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { sizeClass } from "./input"; import { errorClass, sizeClass } from "./input";
import type { ControlSize } from "./input"; import type { ControlSize } from "./input";
import { Field, controlBase, menuRowClass } from "./field";
import { CheckIcon, ChevronDown } from "./icons";
import { usePortalPanel } from "./use-portal-panel"; import { usePortalPanel } from "./use-portal-panel";
export interface OptionMenuChoice<T extends string> { export interface OptionMenuChoice<T extends string> {
@@ -55,6 +57,8 @@ export function OptionMenu<T extends string>({
placeholder, placeholder,
mono, mono,
label, label,
error,
required,
fullWidth, fullWidth,
size = "base", size = "base",
"aria-label": ariaLabel, "aria-label": ariaLabel,
@@ -69,6 +73,10 @@ export function OptionMenu<T extends string>({
mono?: boolean; mono?: boolean;
/** Field title above the control (same typography as Input); omit to render a bare trigger button (e.g. for table cell usage). */ /** Field title above the control (same typography as Input); omit to render a bare trigger button (e.g. for table cell usage). */
label?: string; label?: string;
/** Field-value error: red border + message below, exactly like Input. */
error?: string;
/** Renders a red "*" after the label to mark the field as required. */
required?: boolean;
/** Stretch the trigger button to fill the container width, as a replacement for native Select in dense form areas. */ /** Stretch the trigger button to fill the container width, as a replacement for native Select in dense form areas. */
fullWidth?: boolean; fullWidth?: boolean;
/** Same size tier as Input/Select (sizeClass), for pixel-perfect alignment when mixed together. */ /** Same size tier as Input/Select (sizeClass), for pixel-perfect alignment when mixed together. */
@@ -84,6 +92,7 @@ export function OptionMenu<T extends string>({
panelWidth: PANEL_WIDTH, panelWidth: PANEL_WIDTH,
}); });
const current = value != null ? options.find((o) => o.value === value) : undefined; const current = value != null ? options.find((o) => o.value === value) : undefined;
const errorId = useId();
const control = ( const control = (
<> <>
@@ -93,29 +102,19 @@ export function OptionMenu<T extends string>({
aria-label={ariaLabel} aria-label={ariaLabel}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-required={required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={ className={
"flex items-center gap-2 rounded-md border border-gray-300 bg-white text-gray-900 " + `flex items-center gap-2 ${controlBase} ${sizeClass[size]} ${error ? errorClass : ""}` +
"transition-[border-color,box-shadow] duration-200 hover:border-gray-400 " +
"focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30 " +
sizeClass[size] +
(fullWidth ? " w-full justify-between" : "") (fullWidth ? " w-full justify-between" : "")
} }
> >
<span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`}> <span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`}>
{current?.triggerLabel ?? placeholder ?? "—"} {current?.triggerLabel ?? placeholder ?? "—"}
</span> </span>
<svg <ChevronDown className="text-gray-400" />
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
aria-hidden
className="size-3 shrink-0 text-gray-400"
>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
{open && {open &&
position && position &&
@@ -142,7 +141,7 @@ export function OptionMenu<T extends string>({
onChange(opt.value); onChange(opt.value);
setOpen(false); setOpen(false);
}} }}
className={`block w-full px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${ className={`block ${menuRowClass} hover:bg-gray-100 dark:hover:bg-gray-800 ${
opt.value === value ? "bg-gray-100 dark:bg-gray-800" : "" opt.value === value ? "bg-gray-100 dark:bg-gray-800" : ""
}`} }`}
> >
@@ -157,22 +156,7 @@ export function OptionMenu<T extends string>({
{opt.label} {opt.label}
</span> </span>
{opt.value === value && ( {opt.value === value && (
<svg <CheckIcon className="text-gray-500 dark:text-gray-400" />
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden
>
<path
d="M5 12l4 4L19 6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)} )}
</span> </span>
<span <span
@@ -187,13 +171,9 @@ export function OptionMenu<T extends string>({
)} )}
</> </>
); );
if (!label) return control;
return ( return (
<label className="block"> <Field label={label} error={error} errorId={errorId} required={required}>
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
{control} {control}
</label> </Field>
); );
} }
@@ -7,8 +7,9 @@
* shift out of place. So error is only passed down as `invalid` (red border), and * shift out of place. So error is only passed down as `invalid` (red border), and
* the error text is rendered below the box by this component. * the error text is rendered below the box by this component.
*/ */
import { useState } from "react"; import { useId, useState } from "react";
import { Input, type InputProps } from "./input"; import { Input, type InputProps } from "./input";
import { Field } from "./field";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
/** Eye (click to show) and a crossed-out closed eye (click to hide). */ /** Eye (click to show) and a crossed-out closed eye (click to hide). */
@@ -21,25 +22,27 @@ export function PasswordInput({
hint, hint,
error, error,
invalid, invalid,
required,
size = "base", size = "base",
className, className,
...rest ...rest
}: Omit<InputProps, "type">) { }: Omit<InputProps, "type">) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const toggleLabel = visible ? S.auth.hidePassword : S.auth.showPassword; const toggleLabel = visible ? S.auth.hidePassword : S.auth.showPassword;
// The error text renders in THIS component's Field (see the header comment), so the
// association is wired here too: the inner Input only gets `invalid` and points its
// aria-describedby at the outer FieldError.
const errorId = useId();
return ( return (
<label className="block"> <Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
<div className="relative"> <div className="relative">
<Input <Input
{...rest} {...rest}
required={required}
size={size} size={size}
type={visible ? "text" : "password"} type={visible ? "text" : "password"}
invalid={Boolean(error) || Boolean(invalid)} invalid={Boolean(error) || Boolean(invalid)}
aria-describedby={error ? errorId : undefined}
className={`${size === "sm" ? "pr-8" : "pr-10"} ${className ?? ""}`} className={`${size === "sm" ? "pr-8" : "pr-10"} ${className ?? ""}`}
/> />
<button <button
@@ -68,11 +71,6 @@ export function PasswordInput({
</svg> </svg>
</button> </button>
</div> </div>
{error ? ( </Field>
<span className="mt-1 block text-xs text-red-600 dark:text-red-400">{error}</span>
) : hint ? (
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>
) : null}
</label>
); );
} }
+52 -126
View File
@@ -2,19 +2,24 @@
* Dropdown select component: **custom-drawn** (not the native browser select), * Dropdown select component: **custom-drawn** (not the native browser select),
* keeping the same API — parses `<option>` children and follows the * keeping the same API — parses `<option>` children and follows the
* `value` / `onChange(e.target.value)` convention. The menu is rendered via * `value` / `onChange(e.target.value)` convention. The menu is rendered via
* portal to body (fixed positioning, computed from the button's rect), so it * portal to body (fixed positioning from the shared usePortalPanel hook), so it
* is never clipped by a Modal or scroll container; it closes on outside click, * is never clipped by a Modal or scroll container; it closes on outside click,
* Esc, or scroll. Styling matches Input. * Esc, scroll, or resize. Styling matches Input.
*/ */
import { Children, isValidElement, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Children, isValidElement, useId, useState } from "react";
import type { ChangeEvent, ReactNode, SelectHTMLAttributes } from "react"; import type { ChangeEvent, ReactNode, SelectHTMLAttributes } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { sizeClass } from "./input"; import { errorClass, sizeClass } from "./input";
import type { ControlSize } from "./input"; import type { ControlSize } from "./input";
import { Field, controlBase, menuRowClass } from "./field";
import { CheckIcon, ChevronDown } from "./icons";
import { usePortalPanel } from "./use-portal-panel";
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> { export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
label?: string; label?: string;
hint?: string; hint?: string;
/** Field-value error: red border + message below, exactly like Input. */
error?: string;
/** Same size tier as Input: sm is for filter bars, keeps the toolbar from growing taller. */ /** Same size tier as Input: sm is for filter bars, keeps the toolbar from growing taller. */
size?: ControlSize; size?: ControlSize;
} }
@@ -40,13 +45,7 @@ function parseOptions(children: ReactNode): Opt[] {
return out; return out;
} }
const CONTROL_CLASS = const CONTROL_CLASS = `flex w-full items-center gap-2 text-left ${controlBase} disabled:cursor-not-allowed disabled:opacity-60`;
"flex w-full items-center gap-2 rounded-md border border-gray-300 bg-white text-left text-gray-900 " +
"transition-[border-color,box-shadow] duration-200 hover:border-gray-400 " +
"focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
/** Menu-row text follows the control's size tier, so the dropdown reads exactly like an Input of the same tier (review: dropdown text = input text). */ /** Menu-row text follows the control's size tier, so the dropdown reads exactly like an Input of the same tier (review: dropdown text = input text). */
const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "text-xs" }; const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "text-xs" };
@@ -54,6 +53,8 @@ const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "tex
export function Select({ export function Select({
label, label,
hint, hint,
error,
required,
size = "base", size = "base",
className, className,
children, children,
@@ -64,158 +65,83 @@ export function Select({
const options = parseOptions(children); const options = parseOptions(children);
const current = String(value ?? ""); const current = String(value ?? "");
const selected = options.find((o) => o.value === current); const selected = options.find((o) => o.value === current);
const errorId = useId();
const btnRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number; width: number; up: boolean } | null>( const { triggerRef, panelRef, position } = usePortalPanel({
null, open,
); onClose: () => setOpen(false),
// Row height is roughly 36px (px-3 py-1.5 + text) — only used to decide up vs down.
const place = () => { estimatedHeight: options.length * 36 + 8,
const el = btnRef.current; });
if (!el) return;
const r = el.getBoundingClientRect();
const below = window.innerHeight - r.bottom;
const up = below < 240 && r.top > below; // Not enough room below and more room above -> open upward
setPos({ left: r.left, top: up ? r.top : r.bottom, width: r.width, up });
};
useLayoutEffect(() => {
if (open) place();
}, [open]);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
const t = e.target as Node;
if (btnRef.current?.contains(t)) return;
if ((t as HTMLElement).closest?.("[data-select-menu]")) return;
setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
// Page scroll closes the menu (a fixed-positioned menu doesn't scroll with the
// page, so leaving it in place would misalign it). But **the menu's own internal
// scroll** (when there are more options than max-h-60) doesn't count: a
// capture-phase listener would catch that too, and closing on it would mean the
// menu shuts the moment the user scrolls, so they can never reach options below.
const onScroll = (e: Event) => {
const t = e.target;
if (t instanceof Element && t.closest("[data-select-menu]")) return;
setOpen(false);
};
const onResize = () => setOpen(false);
document.addEventListener("mousedown", onDocDown);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
document.removeEventListener("mousedown", onDocDown);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
const pick = (v: string) => { const pick = (v: string) => {
setOpen(false); setOpen(false);
// Synthesize a minimal event object, following the caller's onChange(e.target.value) convention. // Synthesize a minimal event object, following the caller's onChange(e.target.value) convention.
onChange?.({ target: { value: v } } as unknown as ChangeEvent<HTMLSelectElement>); onChange?.({ target: { value: v } } as unknown as ChangeEvent<HTMLSelectElement>);
btnRef.current?.focus(); triggerRef.current?.focus();
}; };
const control = ( const control = (
<> <>
<button <button
ref={btnRef} ref={triggerRef}
type="button" type="button"
disabled={disabled} disabled={disabled}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-required={required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={`${CONTROL_CLASS} ${sizeClass[size]} ${className ?? ""}`} className={`${CONTROL_CLASS} ${sizeClass[size]} ${error ? errorClass : ""} ${className ?? ""}`}
> >
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{selected?.label ?? options[0]?.label ?? ""} {selected?.label ?? options[0]?.label ?? ""}
</span> </span>
<svg <ChevronDown className="text-gray-400" />
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-400"
aria-hidden
>
<path d="M3 4.5l3 3 3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
{open && {open &&
pos && position &&
createPortal( createPortal(
<ul <div
data-select-menu ref={panelRef}
role="listbox" role="listbox"
className="anim-pop fixed z-[60] max-h-60 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900" className="anim-pop fixed z-[60] max-h-60 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
style={{ style={{
left: pos.left, left: position.left,
width: pos.width, width: position.triggerWidth,
...(pos.up ? { bottom: window.innerHeight - pos.top + 4 } : { top: pos.top + 4 }), top: position.topPx,
bottom: position.bottomPx,
}} }}
> >
{options.map((o, i) => ( {options.map((o, i) => (
<li key={`${o.value}-${i}`}> <button
<button key={`${o.value}-${i}`}
type="button" type="button"
role="option" role="option"
aria-selected={o.value === current} aria-selected={o.value === current}
disabled={o.disabled} disabled={o.disabled}
onClick={() => pick(o.value)} onClick={() => pick(o.value)}
className={`flex w-full items-center px-3 py-1.5 text-left ${menuTextClass[size]} transition-colors duration-150 disabled:opacity-50 ${ className={`flex items-center ${menuRowClass} ${menuTextClass[size]} disabled:opacity-50 ${
o.value === current o.value === current
? "bg-gray-100 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100" ? "bg-gray-100 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800" : "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`} }`}
> >
<span className="min-w-0 flex-1 truncate">{o.label}</span> <span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.value === current && ( {o.value === current && <CheckIcon className="text-gray-500 dark:text-gray-400" />}
<svg </button>
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden
>
<path
d="M5 12l4 4L19 6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
</li>
))} ))}
</ul>, </div>,
document.body, document.body,
)} )}
</> </>
); );
if (!label && !hint) return control;
return ( return (
<label className="block"> <Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control} {control}
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>} </Field>
</label>
); );
} }
+2 -12
View File
@@ -26,7 +26,7 @@
*/ */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { S } from "../../lib/strings"; import { CloseButton } from "./icons";
import { SPRING_DEFAULT, SPRING_MOMENTUM, createSpringDriver } from "../../lib/spring"; import { SPRING_DEFAULT, SPRING_MOMENTUM, createSpringDriver } from "../../lib/spring";
import type { SpringDriver } from "../../lib/spring"; import type { SpringDriver } from "../../lib/spring";
import { nearestSnap, project, rubberband } from "../../lib/sheet-physics"; import { nearestSnap, project, rubberband } from "../../lib/sheet-physics";
@@ -301,17 +301,7 @@ export function Sheet({ open, snap, onSnapChange, onClose, title, children }: Sh
<div className="mx-auto mt-2 h-1 w-9 rounded-full bg-gray-300 dark:bg-gray-600" /> <div className="mx-auto mt-2 h-1 w-9 rounded-full bg-gray-300 dark:bg-gray-600" />
<div className="flex items-center justify-between px-4 pb-2 pt-1.5"> <div className="flex items-center justify-between px-4 pb-2 pt-1.5">
<span className="text-base font-semibold">{title ?? ""}</span> <span className="text-base font-semibold">{title ?? ""}</span>
<button <CloseButton onClose={onClose} onPointerDown={(e) => e.stopPropagation()} />
type="button"
aria-label={S.common.close}
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
className="rounded-md p-1.5 text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div> </div>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]">
+9 -6
View File
@@ -8,14 +8,15 @@
* intercept mouse events (pointer-events-none) — only the toast itself is * intercept mouse events (pointer-events-none) — only the toast itself is
* interactive (clicking it dismisses immediately). * interactive (clicking it dismisses immediately).
* *
* Usage: call `toastSuccess("Saved")` / `toastError("Connection failed: ...")` * Usage: call `toastSuccess("Saved")` / `toastInfo("Already up to date")` /
* from anywhere, no context needed — a module-level subscriber list plus a * `toastError("Connection failed: ...")` from anywhere, no context needed — a
* single `<Toaster />` mounted at the app root is all it takes. * module-level subscriber list plus a single `<Toaster />` mounted at the app
* root is all it takes.
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
type ToastKind = "success" | "error"; type ToastKind = "success" | "error" | "info";
interface ToastItem { interface ToastItem {
id: number; id: number;
@@ -25,8 +26,8 @@ interface ToastItem {
leaving?: boolean; leaving?: boolean;
} }
/** Display duration: error messages are usually longer and more important to read, so give them more time. */ /** Display duration: error messages are usually longer and more important to read, so give them more time; info sits in between. */
const DURATION: Record<ToastKind, number> = { success: 2500, error: 6000 }; const DURATION: Record<ToastKind, number> = { success: 2500, info: 4000, error: 6000 };
let items: ToastItem[] = []; let items: ToastItem[] = [];
let nextId = 1; let nextId = 1;
@@ -61,12 +62,14 @@ function push(kind: ToastKind, text: string): void {
export const toastSuccess = (text: string): void => push("success", text); export const toastSuccess = (text: string): void => push("success", text);
export const toastError = (text: string): void => push("error", text); export const toastError = (text: string): void => push("error", text);
export const toastInfo = (text: string): void => push("info", text);
const KIND_CLASS: Record<ToastKind, string> = { const KIND_CLASS: Record<ToastKind, string> = {
success: success:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200", "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
error: error:
"border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200", "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200",
info: "border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200",
}; };
/** Toast container: mount once at the app root. */ /** Toast container: mount once at the app root. */
@@ -10,6 +10,7 @@ import type { UserInfo } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { USERNAME_PATTERN } from "../../lib/semantic-id"; import { USERNAME_PATTERN } from "../../lib/semantic-id";
import { formatDateTime } from "../../lib/format"; import { formatDateTime } from "../../lib/format";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
@@ -34,7 +35,7 @@ export function AdminUsersPage() {
setUsers((await api.adminListUsers()).users); setUsers((await api.adminListUsers()).users);
setListError(null); setListError(null);
} catch (e) { } catch (e) {
setListError(e instanceof ApiError ? e.message : S.common.unknownError); setListError(apiErrorText(e));
} }
}, []); }, []);
@@ -62,11 +63,11 @@ export function AdminUsersPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400"> <tr className="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.userId}</th> <th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.username}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.role}</th> <th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.role}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.createdAt}</th> <th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.created}</th>
<th className="whitespace-nowrap px-3 py-2 text-right font-medium"> <th className="whitespace-nowrap px-3 py-2 text-right font-medium">
{S.admin.actions} {S.common.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -93,7 +94,7 @@ export function AdminUsersPage() {
</Button> </Button>
{!u.isAdmin && ( {!u.isAdmin && (
<Button size="sm" variant="ghost" onClick={() => setDeleting(u)}> <Button size="sm" variant="ghost" onClick={() => setDeleting(u)}>
{S.admin.deleteUser} {S.common.delete}
</Button> </Button>
)} )}
</td> </td>
@@ -138,33 +139,40 @@ function CreateUserDialog({
}) { }) {
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [errors, setErrors] = useState<{ userId?: string; password?: string }>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const clearErrors = () => setErrors((p) => (p.userId || p.password ? {} : p));
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setUserId(""); setUserId("");
setPassword(""); setPassword("");
setError(null); setErrors({});
}, [open]); }, [open]);
const submit = async () => { const submit = async () => {
const id = userId.trim(); const id = userId.trim();
if (!id || !password) { const next: { userId?: string; password?: string } = {};
setError(S.common.requiredField); if (!id) next.userId = S.common.requiredField;
return; else if (!USERNAME_PATTERN.test(id)) next.userId = S.auth.usernameHint;
} if (!password) next.password = S.common.requiredField;
if (!USERNAME_PATTERN.test(id)) { if (next.userId || next.password) {
setError(S.auth.usernameHint); setErrors(next);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setErrors({});
try { try {
await api.adminCreateUser({ userId: id, password }); await api.adminCreateUser({ userId: id, password });
onDone(); onDone();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); // Route by error code: invalid_password is about the password's strength;
// user_exists (and anything unrecognized) is about the id.
if (e instanceof ApiError && e.code === "invalid_password") {
setErrors({ password: apiErrorText(e) });
} else {
setErrors({ userId: apiErrorText(e) });
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -188,18 +196,28 @@ function CreateUserDialog({
> >
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
label={S.admin.userId} label={S.common.username}
required
size="sm" size="sm"
value={userId} value={userId}
onChange={(e) => setUserId(e.target.value)} onChange={(e) => {
setUserId(e.target.value);
clearErrors();
}}
error={errors.userId}
hint={S.auth.usernameHint} hint={S.auth.usernameHint}
autoFocus autoFocus
/> />
<PasswordInput <PasswordInput
label={S.admin.initialPassword} label={S.admin.initialPassword}
required
size="sm" size="sm"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value);
clearErrors();
}}
error={errors.password}
autoComplete="new-password" autoComplete="new-password"
hint={S.auth.passwordHint} hint={S.auth.passwordHint}
/> />
@@ -208,7 +226,6 @@ function CreateUserDialog({
{S.admin.defaultProjectNote(`${userId.trim()}-default_project`)} {S.admin.defaultProjectNote(`${userId.trim()}-default_project`)}
</p> </p>
)} )}
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
</Modal> </Modal>
); );
@@ -216,28 +233,28 @@ function CreateUserDialog({
function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose: () => void }) { function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose: () => void }) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
setPassword(""); setPassword("");
setError(null); setPasswordError(undefined);
}, [user]); }, [user]);
const submit = async () => { const submit = async () => {
if (!user) return; if (!user) return;
if (!password) { if (!password) {
setError(S.common.requiredField); setPasswordError(S.common.requiredField);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setPasswordError(undefined);
try { try {
await api.adminResetPassword(user.userId, { password }); await api.adminResetPassword(user.userId, { password });
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setPasswordError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -262,15 +279,19 @@ function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose
<div className="space-y-3"> <div className="space-y-3">
<PasswordInput <PasswordInput
label={S.admin.initialPassword} label={S.admin.initialPassword}
required
size="sm" size="sm"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value);
setPasswordError(undefined);
}}
error={passwordError}
autoComplete="new-password" autoComplete="new-password"
hint={S.auth.passwordHint} hint={S.auth.passwordHint}
autoFocus autoFocus
/> />
<p className="text-xs text-gray-400 dark:text-gray-500">{S.admin.resetPasswordNote}</p> <p className="text-xs text-gray-400 dark:text-gray-500">{S.admin.resetPasswordNote}</p>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
</Modal> </Modal>
); );
@@ -303,7 +324,7 @@ function DeleteUserDialog({
await api.adminDeleteUser(user.userId); await api.adminDeleteUser(user.userId);
onDone(); onDone();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -21,13 +21,15 @@ import type { ToolDefinitionConfig, ToolPermission } from "@prismshadow/penguin-
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { Tabs } from "../../components/ui/tabs"; import { Tabs } from "../../components/ui/tabs";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
import { Input, Textarea } from "../../components/ui/input"; import { Input, Textarea } from "../../components/ui/input";
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu"; import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
import { Modal } from "../../components/ui/modal"; import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal";
import { Skeleton } from "../../components/ui/skeleton"; import { Skeleton } from "../../components/ui/skeleton";
import { VaultTab } from "./vault-tab"; import { VaultTab } from "./vault-tab";
import { SchedulesTab } from "./schedules-tab"; import { SchedulesTab } from "./schedules-tab";
@@ -56,50 +58,6 @@ export function optionRows(
})); }));
} }
/**
* OptionMenu whose pick can be explicitly rewound: the runtime menus no longer offer an
* inherit row, so without this an accidental pick could not be backed out before saving.
* The reset link (top-right of the label line, visible only while a value is picked) sets
* the LOCAL edit state back to "" — the save guard skips empty values, nothing is written.
* The unset trigger shows the (default) placeholder, matching the tools-table permission menu.
*/
function ResettableOptionMenu({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: ReadonlyArray<OptionMenuChoice<string>>;
}) {
return (
<div className="relative">
<OptionMenu
label={label}
fullWidth
size="sm"
placeholder={S.agent.defaultValue}
value={value}
onChange={onChange}
options={options}
/>
{value !== "" && (
<button
type="button"
title={S.agent.resetToDefault}
aria-label={S.agent.resetToDefault}
onClick={() => onChange("")}
className="absolute right-0 top-0 text-xs text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-300"
>
{S.agent.resetToDefault}
</button>
)}
</div>
);
}
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */ /** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
function parseNum(s: string): number | undefined { function parseNum(s: string): number | undefined {
const trimmed = s.trim(); const trimmed = s.trim();
@@ -131,8 +89,8 @@ export function AgentSettingsPage() {
const [data, setData] = useState<AgentConfigResponse | null>(null); const [data, setData] = useState<AgentConfigResponse | null>(null);
const [tab, setTab] = useState<TabKey>("overview"); const [tab, setTab] = useState<TabKey>("overview");
// Only the initial config load failure renders inline (the page can't show without it); saves/imports report via toast.
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
if (!projectId || !agentId) return; if (!projectId || !agentId) return;
@@ -141,7 +99,7 @@ export function AgentSettingsPage() {
api api
.getAgentConfig(projectId, agentId) .getAgentConfig(projectId, agentId)
.then(setData) .then(setData)
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)); .catch((e: unknown) => setError(apiErrorText(e)));
}, [projectId, agentId]); }, [projectId, agentId]);
useEffect(() => { useEffect(() => {
@@ -151,7 +109,7 @@ export function AgentSettingsPage() {
/** Snapshot import succeeded: show the new version and reload the whole config (import overwrites the entire Agent State, so every tab's data needs a refresh). */ /** Snapshot import succeeded: show the new version and reload the whole config (import overwrites the entire Agent State, so every tab's data needs a refresh). */
const onImported = useCallback( const onImported = useCallback(
(version: number) => { (version: number) => {
setNotice(S.agent.importDone(version)); toastSuccess(S.agent.importDone(version));
load(); load();
void reloadAgents(); void reloadAgents();
}, },
@@ -161,18 +119,16 @@ export function AgentSettingsPage() {
const save = useCallback( const save = useCallback(
async (update: AgentConfigUpdateRequest) => { async (update: AgentConfigUpdateRequest) => {
if (!projectId || !agentId) return; if (!projectId || !agentId) return;
setError(null);
setNotice(null);
try { try {
const res = await api.putAgentConfig(projectId, agentId, update); const res = await api.putAgentConfig(projectId, agentId, update);
setData(res); setData(res);
setNotice(S.common.saved); toastSuccess(S.common.saved);
// Name/description changes affect the breadcrumb and list display. // Name/description changes affect the breadcrumb and list display.
if (update.config?.name !== undefined || update.config?.description !== undefined) { if (update.config?.name !== undefined || update.config?.description !== undefined) {
void reloadAgents(); void reloadAgents();
} }
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} }
}, },
[projectId, agentId, reloadAgents], [projectId, agentId, reloadAgents],
@@ -229,8 +185,6 @@ export function AgentSettingsPage() {
{tab === "vault" && <VaultTab agentId={agentId} />} {tab === "vault" && <VaultTab agentId={agentId} />}
{tab === "schedules" && <SchedulesTab agentId={agentId} />} {tab === "schedules" && <SchedulesTab agentId={agentId} />}
</div> </div>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div> </div>
</div> </div>
); );
@@ -265,6 +219,7 @@ function OverviewTab({
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
// base64 of the snapshot package pending confirmation for a version conflict (409 version_conflict); non-null shows the confirm modal. // base64 of the snapshot package pending confirmation for a version conflict (409 version_conflict); non-null shows the confirm modal.
const [conflict, setConflict] = useState<string | null>(null); const [conflict, setConflict] = useState<string | null>(null);
const { requestSave, element: saveConfirm } = useSaveConfirm();
const submit = () => { const submit = () => {
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {}; const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
@@ -272,8 +227,11 @@ function OverviewTab({
if (description.trim() !== (data.config.description ?? "")) { if (description.trim() !== (data.config.description ?? "")) {
config.description = description.trim(); config.description = description.trim();
} }
if (Object.keys(config).length === 0) return; if (Object.keys(config).length === 0) {
void onSave({ config }); toastInfo(S.common.noChangesToSave);
return;
}
requestSave(() => void onSave({ config }));
}; };
const runImport = async (dataBase64: string, confirm: boolean) => { const runImport = async (dataBase64: string, confirm: boolean) => {
@@ -292,7 +250,7 @@ function OverviewTab({
setConflict(dataBase64); // resend with confirm: true after confirming setConflict(dataBase64); // resend with confirm: true after confirming
} else { } else {
setConflict(null); setConflict(null);
setImportError(e instanceof ApiError ? e.message : S.common.unknownError); setImportError(apiErrorText(e));
} }
} finally { } finally {
setImporting(false); setImporting(false);
@@ -317,7 +275,7 @@ function OverviewTab({
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
size="sm" size="sm"
label={S.agent.name} label={S.common.name}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
@@ -381,29 +339,20 @@ function OverviewTab({
<Button size="sm" variant="primary" onClick={submit}> <Button size="sm" variant="primary" onClick={submit}>
{S.common.save} {S.common.save}
</Button> </Button>
{saveConfirm}
{/* Version conflict confirmation: resend the same package with confirm: true after confirming. */} {/* Version conflict confirmation: resend the same package with confirm: true after confirming. */}
<Modal <ConfirmModal
open={conflict !== null} open={conflict !== null}
title={S.agent.importConflictTitle} title={S.agent.importConflictTitle}
busy={importing}
onClose={() => setConflict(null)} onClose={() => setConflict(null)}
footer={ onConfirm={() => {
<> if (conflict !== null) void runImport(conflict, true);
<Button onClick={() => setConflict(null)}>{S.common.cancel}</Button> }}
<Button
variant="danger"
disabled={importing}
onClick={() => {
if (conflict !== null) void runImport(conflict, true);
}}
>
{S.common.confirm}
</Button>
</>
}
> >
<p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p> <p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p>
</Modal> </ConfirmModal>
</div> </div>
); );
} }
@@ -412,13 +361,17 @@ function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
const [agentsMd, setAgentsMd] = useState(data.agentsMd); const [agentsMd, setAgentsMd] = useState(data.agentsMd);
const [systemPrompt, setSystemPrompt] = useState(data.config.systemPrompt); const [systemPrompt, setSystemPrompt] = useState(data.config.systemPrompt);
const promptRef = useRef<HTMLTextAreaElement>(null); const promptRef = useRef<HTMLTextAreaElement>(null);
const { requestSave, element: saveConfirm } = useSaveConfirm();
const submit = () => { const submit = () => {
const update: AgentConfigUpdateRequest = {}; const update: AgentConfigUpdateRequest = {};
if (agentsMd !== data.agentsMd) update.agentsMd = agentsMd; if (agentsMd !== data.agentsMd) update.agentsMd = agentsMd;
if (systemPrompt !== data.config.systemPrompt) update.config = { systemPrompt }; if (systemPrompt !== data.config.systemPrompt) update.config = { systemPrompt };
if (update.agentsMd === undefined && update.config === undefined) return; if (update.agentsMd === undefined && update.config === undefined) {
void onSave(update); toastInfo(S.common.noChangesToSave);
return;
}
requestSave(() => void onSave(update));
}; };
/** /**
@@ -487,6 +440,7 @@ function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
<Button size="sm" variant="primary" onClick={submit}> <Button size="sm" variant="primary" onClick={submit}>
{S.common.save} {S.common.save}
</Button> </Button>
{saveConfirm}
</div> </div>
); );
} }
@@ -503,16 +457,18 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
const [maxSessionTurns, setMaxSessionTurns] = useState(numToStr(cfg.compaction?.maxSessionTurns)); const [maxSessionTurns, setMaxSessionTurns] = useState(numToStr(cfg.compaction?.maxSessionTurns));
const [mode, setMode] = useState(cfg.compaction?.mode ?? ""); const [mode, setMode] = useState(cfg.compaction?.mode ?? "");
const [prompt, setPrompt] = useState(cfg.compaction?.prompt ?? ""); const [prompt, setPrompt] = useState(cfg.compaction?.prompt ?? "");
const [localError, setLocalError] = useState<string | null>(null); const [fieldErrors, setFieldErrors] = useState<{ maxTurns?: string; timeoutMs?: string }>({});
const clearFieldErrors = () => setFieldErrors((p) => (p.maxTurns || p.timeoutMs ? {} : p));
const { requestSave, element: saveConfirm } = useSaveConfirm();
const submit = () => { const submit = () => {
setLocalError(null); setFieldErrors({});
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {}; const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
const mt = parseNum(maxTurns); const mt = parseNum(maxTurns);
if (mt !== undefined && mt !== cfg.maxTurns) { if (mt !== undefined && mt !== cfg.maxTurns) {
if (mt <= 0 && mt !== -1) { if (mt <= 0 && mt !== -1) {
setLocalError(S.agent.maxTurnsInvalid); setFieldErrors({ maxTurns: S.agent.maxTurnsInvalid });
return; return;
} }
config.maxTurns = mt; config.maxTurns = mt;
@@ -527,7 +483,7 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
const tmo = parseNum(timeoutMs); const tmo = parseNum(timeoutMs);
if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) { if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) {
if (tmo <= 0 && tmo !== -1) { if (tmo <= 0 && tmo !== -1) {
setLocalError(S.agent.timeoutInvalid); setFieldErrors({ timeoutMs: S.agent.timeoutInvalid });
return; return;
} }
model.timeoutMs = tmo; model.timeoutMs = tmo;
@@ -549,8 +505,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
if (prompt !== (cfg.compaction?.prompt ?? "")) compaction.prompt = prompt; if (prompt !== (cfg.compaction?.prompt ?? "")) compaction.prompt = prompt;
if (Object.keys(compaction).length > 0) config.compaction = compaction; if (Object.keys(compaction).length > 0) config.compaction = compaction;
if (Object.keys(config).length === 0) return; if (Object.keys(config).length === 0) {
void onSave({ config }); toastInfo(S.common.noChangesToSave);
return;
}
requestSave(() => void onSave({ config }));
}; };
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant. // S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
@@ -576,7 +535,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
label={S.agent.maxTurns} label={S.agent.maxTurns}
size="sm" size="sm"
value={maxTurns} value={maxTurns}
onChange={(e) => setMaxTurns(e.target.value)} error={fieldErrors.maxTurns}
onChange={(e) => {
setMaxTurns(e.target.value);
clearFieldErrors();
}}
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
/> />
@@ -588,8 +551,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
/> />
<ResettableOptionMenu <OptionMenu
label={S.agent.thinkingLevel} label={S.agent.thinkingLevel}
fullWidth
size="sm"
placeholder={S.agent.defaultValue}
value={thinkingLevel} value={thinkingLevel}
onChange={setThinkingLevel} onChange={setThinkingLevel}
options={thinkingLevelOptions} options={thinkingLevelOptions}
@@ -599,7 +565,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
hint={S.agent.timeoutMsHint} hint={S.agent.timeoutMsHint}
size="sm" size="sm"
value={timeoutMs} value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)} error={fieldErrors.timeoutMs}
onChange={(e) => {
setTimeoutMs(e.target.value);
clearFieldErrors();
}}
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
/> />
@@ -629,8 +599,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
/> />
<ResettableOptionMenu <OptionMenu
label={S.agent.compactionMode} label={S.agent.compactionMode}
fullWidth
size="sm"
placeholder={S.agent.defaultValue}
value={mode} value={mode}
onChange={setMode} onChange={setMode}
options={compactionModeOptions} options={compactionModeOptions}
@@ -653,10 +626,10 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
</div> </div>
</div> </div>
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
<Button size="sm" variant="primary" onClick={submit}> <Button size="sm" variant="primary" onClick={submit}>
{S.common.save} {S.common.save}
</Button> </Button>
{saveConfirm}
</div> </div>
); );
} }
@@ -691,17 +664,28 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
maxOutputLength: numToStr(t.maxOutputLength), maxOutputLength: numToStr(t.maxOutputLength),
})), })),
); );
const [localError, setLocalError] = useState<string | null>(null); // Per-cell validation errors, keyed `${rowIndex}-${column}`, shown red under the offending numeric input.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { requestSave, element: saveConfirm } = useSaveConfirm();
const update = (index: number, patch: Partial<ToolRowState>) => { const update = (index: number, patch: Partial<ToolRowState>) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r))); setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
setFieldErrors((p) => {
const kt = `${index}-timeoutMs`;
const km = `${index}-maxOutputLength`;
if (!p[kt] && !p[km]) return p;
const next = { ...p };
delete next[kt];
delete next[km];
return next;
});
}; };
const submit = () => { const submit = () => {
setLocalError(null);
// toolsBuiltin is submitted as a full table: an empty string omits that key (revert to default); non-empty values are validated per the server's rules. // toolsBuiltin is submitted as a full table: an empty string omits that key (revert to default); non-empty values are validated per the server's rules.
const errs: Record<string, string> = {};
const tools: ToolDefinitionConfig[] = []; const tools: ToolDefinitionConfig[] = [];
for (const row of rows) { for (const [i, row] of rows.entries()) {
const tool: ToolDefinitionConfig = { ...row.base }; const tool: ToolDefinitionConfig = { ...row.base };
delete tool.timeoutMs; delete tool.timeoutMs;
delete tool.maxOutputLength; delete tool.maxOutputLength;
@@ -709,23 +693,41 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
if (timeout) { if (timeout) {
const n = Number(timeout); const n = Number(timeout);
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) { if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
setLocalError(S.agent.toolFieldInvalid(row.base.name, "timeoutMs")); errs[`${i}-timeoutMs`] = S.agent.toolFieldInvalid(row.base.name, "timeoutMs");
return; } else tool.timeoutMs = n;
}
tool.timeoutMs = n;
} }
const maxOutput = row.maxOutputLength.trim(); const maxOutput = row.maxOutputLength.trim();
if (maxOutput) { if (maxOutput) {
const n = Number(maxOutput); const n = Number(maxOutput);
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) { if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
setLocalError(S.agent.toolFieldInvalid(row.base.name, "maxOutputLength")); errs[`${i}-maxOutputLength`] = S.agent.toolFieldInvalid(row.base.name, "maxOutputLength");
return; } else tool.maxOutputLength = n;
}
tool.maxOutputLength = n;
} }
tools.push(tool); tools.push(tool);
} }
void onSave({ config: { toolsBuiltin: tools } }); if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return;
}
setFieldErrors({});
// The table is submitted whole, so compare the editable columns against the loaded
// config to detect a no-op save (row order is stable — both sides map the same list).
const orig = data.config.toolsBuiltin;
const dirty =
tools.length !== orig.length ||
tools.some((t, i) => {
const o = orig[i]!;
return (
t.permission !== o.permission ||
t.timeoutMs !== o.timeoutMs ||
t.maxOutputLength !== o.maxOutputLength
);
});
if (!dirty) {
toastInfo(S.common.noChangesToSave);
return;
}
requestSave(() => void onSave({ config: { toolsBuiltin: tools } }));
}; };
return ( return (
@@ -734,7 +736,7 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
<table className="w-full min-w-[520px] text-left text-sm"> <table className="w-full min-w-[520px] text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900"> <tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2">{S.agent.toolName}</th> <th className="px-3 py-2">{S.common.name}</th>
<th className="px-3 py-2">{S.agent.toolPermission}</th> <th className="px-3 py-2">{S.agent.toolPermission}</th>
<th className="px-3 py-2">{S.agent.toolTimeout}</th> <th className="px-3 py-2">{S.agent.toolTimeout}</th>
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th> <th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
@@ -743,8 +745,8 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
<tbody> <tbody>
{rows.map((row, i) => ( {rows.map((row, i) => (
<tr key={row.base.name} className="border-b border-gray-100 dark:border-gray-800/60"> <tr key={row.base.name} className="border-b border-gray-100 dark:border-gray-800/60">
<td className="px-3 py-2 font-mono text-xs">{row.base.name}</td> <td className="px-3 py-2 align-top font-mono text-xs">{row.base.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2 align-top">
<OptionMenu <OptionMenu
mono mono
size="sm" size="sm"
@@ -755,19 +757,21 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
onChange={(v) => update(i, { base: { ...row.base, permission: v } })} onChange={(v) => update(i, { base: { ...row.base, permission: v } })}
/> />
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 align-top">
<Input <Input
size="sm" size="sm"
value={row.timeoutMs} value={row.timeoutMs}
error={fieldErrors[`${i}-timeoutMs`]}
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
onChange={(e) => update(i, { timeoutMs: e.target.value })} onChange={(e) => update(i, { timeoutMs: e.target.value })}
/> />
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 align-top">
<Input <Input
size="sm" size="sm"
value={row.maxOutputLength} value={row.maxOutputLength}
error={fieldErrors[`${i}-maxOutputLength`]}
inputMode="numeric" inputMode="numeric"
className="font-mono" className="font-mono"
onChange={(e) => update(i, { maxOutputLength: e.target.value })} onChange={(e) => update(i, { maxOutputLength: e.target.value })}
@@ -779,8 +783,6 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
</table> </table>
</div> </div>
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
<div> <div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p> <p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
<pre className="max-h-64 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-900"> <pre className="max-h-64 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-900">
@@ -791,6 +793,7 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
<Button size="sm" variant="primary" onClick={submit}> <Button size="sm" variant="primary" onClick={submit}>
{S.common.save} {S.common.save}
</Button> </Button>
{saveConfirm}
</div> </div>
); );
} }
@@ -15,8 +15,8 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id"; import { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id";
import { formatDateTime, formatRelativeDays } from "../../lib/format"; import { formatDateTime, formatRelativeDays } from "../../lib/format";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
@@ -25,6 +25,7 @@ import { agentDisplayName, useProject } from "../../state/project";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input, Textarea } from "../../components/ui/input"; import { Input, Textarea } from "../../components/ui/input";
import { Modal } from "../../components/ui/modal"; import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton"; import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
import { EmptyState } from "../../components/ui/empty-state"; import { EmptyState } from "../../components/ui/empty-state";
@@ -71,7 +72,8 @@ export function AgentsPage() {
const [agentId, setAgentId] = useState(""); const [agentId, setAgentId] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null); // The id is the only validated create field; format problems and the server's duplicate-id rejection land beside it.
const [idError, setIdError] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
/** Open the create dialog: don't keep the previous draft, always start from an empty form. */ /** Open the create dialog: don't keep the previous draft, always start from an empty form. */
@@ -79,7 +81,7 @@ export function AgentsPage() {
setAgentId(""); setAgentId("");
setName(""); setName("");
setDescription(""); setDescription("");
setError(null); setIdError(undefined);
setCreateOpen(true); setCreateOpen(true);
}; };
/** Agent pending delete confirmation (null = none). */ /** Agent pending delete confirmation (null = none). */
@@ -92,15 +94,15 @@ export function AgentsPage() {
if (!projectId) return; if (!projectId) return;
const id = agentId.trim(); const id = agentId.trim();
if (!id) { if (!id) {
setError(S.common.requiredField); setIdError(S.common.requiredField);
return; return;
} }
if (!SEMANTIC_ID_PATTERN.test(id)) { if (!SEMANTIC_ID_PATTERN.test(id)) {
setError(S.agent.idHint); setIdError(S.agent.idHint);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setIdError(undefined);
try { try {
// Name defaults to the id (leave blank to let the server fill it in from the id). // Name defaults to the id (leave blank to let the server fill it in from the id).
const body: { agentId: string; name?: string; description?: string } = { agentId: id }; const body: { agentId: string; name?: string; description?: string } = { agentId: id };
@@ -112,7 +114,7 @@ export function AgentsPage() {
setCurrentAgentId(res.agent.agentId); setCurrentAgentId(res.agent.agentId);
navigate(`/agents/${res.agent.agentId}`); navigate(`/agents/${res.agent.agentId}`);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setIdError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -139,7 +141,7 @@ export function AgentsPage() {
setDeleting(null); setDeleting(null);
await reloadAgents(); await reloadAgents();
} catch (e) { } catch (e) {
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError); setDeleteError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -370,14 +372,19 @@ export function AgentsPage() {
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
label={S.agent.id} label={S.agent.id}
required
size="sm" size="sm"
value={agentId} value={agentId}
onChange={(e) => setAgentId(e.target.value)} onChange={(e) => {
setAgentId(e.target.value);
setIdError(undefined);
}}
error={idError}
hint={S.agent.idHint} hint={S.agent.idHint}
autoFocus autoFocus
/> />
<Input <Input
label={S.agent.name} label={S.common.name}
size="sm" size="sm"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -390,33 +397,19 @@ export function AgentsPage() {
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div> </div>
</Modal> </Modal>
{/* Delete confirmation */} {/* Delete confirmation (shared ConfirmModal) */}
<Modal <ConfirmModal
open={deleting !== null} open={deleting !== null}
title={S.agent.deleteAgent} title={S.agent.deleteAgent}
busy={busy}
onClose={() => { onClose={() => {
setDeleting(null); setDeleting(null);
setDeleteError(null); setDeleteError(null);
}} }}
footer={ onConfirm={() => void doDelete()}
<>
<Button
onClick={() => {
setDeleting(null);
setDeleteError(null);
}}
>
{S.common.cancel}
</Button>
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
{S.common.confirm}
</Button>
</>
}
> >
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
{deleting ? S.agent.deleteConfirm(deleting.name) : ""} {deleting ? S.agent.deleteConfirm(deleting.name) : ""}
@@ -424,7 +417,7 @@ export function AgentsPage() {
{deleteError && ( {deleteError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p> <p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
)} )}
</Modal> </ConfirmModal>
</div> </div>
); );
} }
@@ -19,8 +19,8 @@ import type {
ScheduleUpsertRequest, ScheduleUpsertRequest,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatDateTime } from "../../lib/format"; import { formatDateTime } from "../../lib/format";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog"; import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
@@ -29,7 +29,9 @@ import { Button } from "../../components/ui/button";
import { Input, Textarea } from "../../components/ui/input"; import { Input, Textarea } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { Modal } from "../../components/ui/modal"; import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { SkeletonList } from "../../components/ui/skeleton"; import { SkeletonList } from "../../components/ui/skeleton";
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
/** Display status → badge tone. */ /** Display status → badge tone. */
const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = { const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = {
@@ -117,11 +119,20 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
const isOwner = currentProject?.role === "owner"; const isOwner = currentProject?.role === "owner";
const [data, setData] = useState<SchedulesResponse | null>(null); const [data, setData] = useState<SchedulesResponse | null>(null);
// Tab-level error is only the initial list load failure; row/edit actions report via toast.
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Modal form: non-null means open (EMPTY_FORM for create / prefilled row for edit). // Modal form: non-null means open (EMPTY_FORM for create / prefilled row for edit).
const [form, setForm] = useState<FormState | null>(null); const [form, setForm] = useState<FormState | null>(null);
// The form as opened — an edit submit with nothing changed reports "no changes" instead of rewriting the file.
const [initialForm, setInitialForm] = useState<FormState | null>(null);
// Per-field required errors sit next to their input; formError holds a submit rejection that isn't attributable to one field.
const [fieldErrors, setFieldErrors] = useState<{
name?: string;
prompt?: string;
startAt?: string;
sessionId?: string;
}>({});
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
// Name of the task pending deletion confirmation (non-null shows the confirm modal). // Name of the task pending deletion confirmation (non-null shows the confirm modal).
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
@@ -135,7 +146,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
try { try {
setData(await api.listSchedules(projectId, agentId)); setData(await api.listSchedules(projectId, agentId));
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setError(apiErrorText(e));
} }
}, [projectId, agentId]); }, [projectId, agentId]);
@@ -151,27 +162,39 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
.catch(() => setModels([])); .catch(() => setModels([]));
}, [projectId, isOwner]); }, [projectId, isOwner]);
const set = (patch: Partial<FormState>) => const set = (patch: Partial<FormState>) => {
setFieldErrors((p) => (p.name || p.prompt || p.startAt || p.sessionId ? {} : p));
setFormError((p) => (p ? null : p));
setForm((prev) => (prev === null ? prev : { ...prev, ...patch })); setForm((prev) => (prev === null ? prev : { ...prev, ...patch }));
};
const openForm = (next: FormState) => { const openForm = (next: FormState) => {
setFieldErrors({});
setFormError(null); setFormError(null);
setForm(next); setForm(next);
setInitialForm(next);
}; };
const submit = async () => { const submit = async () => {
if (!projectId || form === null) return; if (!projectId || form === null) return;
setFormError(null); setFormError(null);
setNotice(null);
const name = form.editing ?? form.name.trim(); const name = form.editing ?? form.name.trim();
// sessionId is required in bind-to-Session mode — leaving it blank would silently downgrade to "new Session", changing the user's intended choice. // sessionId is required in bind-to-Session mode — leaving it blank would silently downgrade to "new Session", changing the user's intended choice.
if ( const next: { name?: string; prompt?: string; startAt?: string; sessionId?: string } = {};
!name || if (!name) next.name = S.common.requiredField;
!form.prompt.trim() || if (!form.prompt.trim()) next.prompt = S.common.requiredField;
!form.startAt || if (!form.startAt) next.startAt = S.common.requiredField;
(form.target === "session" && !form.sessionId.trim()) if (form.target === "session" && !form.sessionId.trim())
) { next.sessionId = S.common.requiredField;
setFormError(S.common.requiredField); if (next.name || next.prompt || next.startAt || next.sessionId) {
setFieldErrors(next);
return;
}
setFieldErrors({});
// Editing with nothing changed: report it instead of rewriting the same file (both
// sides are FormState built by openForm, so a field-wise JSON compare is exact).
if (form.editing !== null && JSON.stringify(form) === JSON.stringify(initialForm)) {
toastInfo(S.common.noChangesToSave);
return; return;
} }
// Empty-string keys are always omitted; target is one of two choices — sessionId is // Empty-string keys are always omitted; target is one of two choices — sessionId is
@@ -198,11 +221,11 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
if (form.editing !== null) await api.updateSchedule(projectId, agentId, form.editing, body); if (form.editing !== null) await api.updateSchedule(projectId, agentId, form.editing, body);
else await api.createSchedule(projectId, agentId, { name, ...body }); else await api.createSchedule(projectId, agentId, { name, ...body });
setForm(null); setForm(null);
setNotice(S.common.saved); toastSuccess(S.common.saved);
await load(); await load();
} catch (e) { } catch (e) {
// Errors like 400 (validated with the same rules as hand-written files) land under the modal form. // A 400 (validated with the same rules as hand-written files) isn't tied to one field — show it under the modal form.
setFormError(e instanceof ApiError ? e.message : S.common.unknownError); setFormError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -212,8 +235,6 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
const toggle = async (item: ScheduleItem) => { const toggle = async (item: ScheduleItem) => {
if (!projectId) return; if (!projectId) return;
setBusy(true); setBusy(true);
setError(null);
setNotice(null);
const model = itemModelRef(item); const model = itemModelRef(item);
try { try {
await api.updateSchedule(projectId, agentId, item.name, { await api.updateSchedule(projectId, agentId, item.name, {
@@ -227,10 +248,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
// Model reference is resent as a whole pair or not at all — never half of one. // Model reference is resent as a whole pair or not at all — never half of one.
...(model ? { modelId: model.modelId, provider: model.provider } : {}), ...(model ? { modelId: model.modelId, provider: model.provider } : {}),
}); });
setNotice(S.common.saved); toastSuccess(S.common.saved);
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -257,14 +278,12 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
const confirmRemove = async () => { const confirmRemove = async () => {
if (!projectId || deleting === null) return; if (!projectId || deleting === null) return;
setBusy(true); setBusy(true);
setError(null);
setNotice(null);
try { try {
await api.deleteSchedule(projectId, agentId, deleting); await api.deleteSchedule(projectId, agentId, deleting);
if (form?.editing === deleting) setForm(null); if (form?.editing === deleting) setForm(null);
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
} finally { } finally {
setBusy(false); setBusy(false);
setDeleting(null); setDeleting(null);
@@ -295,7 +314,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
<table className="w-full min-w-[640px] text-left text-sm"> <table className="w-full min-w-[640px] text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900"> <tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2.5">{S.schedule.colName}</th> <th className="px-3 py-2.5">{S.common.name}</th>
<th className="px-3 py-2.5">{S.schedule.colStatus}</th> <th className="px-3 py-2.5">{S.schedule.colStatus}</th>
<th className="px-3 py-2.5">{S.schedule.colPeriod}</th> <th className="px-3 py-2.5">{S.schedule.colPeriod}</th>
<th className="px-3 py-2.5">{S.schedule.colTarget}</th> <th className="px-3 py-2.5">{S.schedule.colTarget}</th>
@@ -423,8 +442,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Input <Input
size="sm" size="sm"
label={S.schedule.name} label={S.common.name}
required
hint={S.schedule.nameHint} hint={S.schedule.nameHint}
error={fieldErrors.name}
value={form.name} value={form.name}
disabled={form.editing !== null} disabled={form.editing !== null}
onChange={(e) => set({ name: e.target.value })} onChange={(e) => set({ name: e.target.value })}
@@ -444,7 +465,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
<Input <Input
size="sm" size="sm"
label={S.schedule.startAt} label={S.schedule.startAt}
required
type="datetime-local" type="datetime-local"
error={fieldErrors.startAt}
value={form.startAt} value={form.startAt}
onChange={(e) => set({ startAt: e.target.value })} onChange={(e) => set({ startAt: e.target.value })}
className="font-mono" className="font-mono"
@@ -470,6 +493,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
<Input <Input
size="sm" size="sm"
label={S.schedule.sessionId} label={S.schedule.sessionId}
required
error={fieldErrors.sessionId}
value={form.sessionId} value={form.sessionId}
onChange={(e) => set({ sessionId: e.target.value })} onChange={(e) => set({ sessionId: e.target.value })}
className="font-mono" className="font-mono"
@@ -518,8 +543,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
</div> </div>
<Textarea <Textarea
label={S.schedule.prompt} label={S.schedule.prompt}
required
size="sm" size="sm"
rows={4} rows={4}
error={fieldErrors.prompt}
value={form.prompt} value={form.prompt}
onChange={(e) => set({ prompt: e.target.value })} onChange={(e) => set({ prompt: e.target.value })}
/> />
@@ -536,27 +563,20 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
)} )}
</Modal> </Modal>
{/* Delete confirmation (same pattern as Vault / Agent deletion: Modal + cancel/danger-confirm). */} {/* Delete confirmation (shared ConfirmModal, same pattern as Vault / Agent deletion). */}
<Modal <ConfirmModal
open={deleting !== null} open={deleting !== null}
title={S.schedule.deleteTitle} title={S.schedule.deleteTitle}
busy={busy}
onClose={() => setDeleting(null)} onClose={() => setDeleting(null)}
footer={ onConfirm={() => void confirmRemove()}
<>
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
{S.common.confirm}
</Button>
</>
}
> >
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
{deleting !== null ? S.schedule.deleteConfirm(deleting) : ""} {deleting !== null ? S.schedule.deleteConfirm(deleting) : ""}
</p> </p>
</Modal> </ConfirmModal>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div> </div>
); );
} }
+60 -37
View File
@@ -12,14 +12,16 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api"; import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { PasswordInput } from "../../components/ui/password-input"; import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal"; import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { SkeletonList } from "../../components/ui/skeleton"; import { SkeletonList } from "../../components/ui/skeleton";
import { toastError, toastSuccess } from "../../components/ui/toast";
/** Vault key naming rule (consistent with core/server): shell environment variable name. */ /** Vault key naming rule (consistent with core/server): shell environment variable name. */
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -30,16 +32,19 @@ export function VaultTab({ agentId }: { agentId: string }) {
const isOwner = currentProject?.role === "owner"; const isOwner = currentProject?.role === "owner";
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null); const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
// Tab-level error is only the initial load failure; saves/deletes report via toast.
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Add modal: both form state and errors travel with the modal (tab-level error would be hidden behind the modal). // Add modal: form state and per-field errors travel with the modal (a tab-level error would be hidden behind it).
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [keyInput, setKeyInput] = useState(""); const [keyInput, setKeyInput] = useState("");
const [valueInput, setValueInput] = useState(""); const [valueInput, setValueInput] = useState("");
const [addError, setAddError] = useState<string | null>(null); const [addErrors, setAddErrors] = useState<{ key?: string; value?: string }>({});
const clearAddErrors = () => setAddErrors((p) => (p.key || p.value ? {} : p));
// Key pending deletion confirmation (non-null shows the confirm modal). // Key pending deletion confirmation (non-null shows the confirm modal).
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
// Existing key pending overwrite confirmation (adding a key that's already configured replaces its value).
const [overwriting, setOverwriting] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!projectId || !agentId) return; if (!projectId || !agentId) return;
@@ -49,7 +54,7 @@ export function VaultTab({ agentId }: { agentId: string }) {
const res = await api.getVault(projectId, agentId); const res = await api.getVault(projectId, agentId);
setEntries(res.entries); setEntries(res.entries);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setError(apiErrorText(e));
} }
}, [projectId, agentId]); }, [projectId, agentId]);
@@ -61,14 +66,13 @@ export function VaultTab({ agentId }: { agentId: string }) {
const persist = async (body: VaultUpdateRequest): Promise<string | null> => { const persist = async (body: VaultUpdateRequest): Promise<string | null> => {
if (!projectId || !agentId) return S.common.unknownError; if (!projectId || !agentId) return S.common.unknownError;
setBusy(true); setBusy(true);
setNotice(null);
try { try {
const res = await api.putVault(projectId, agentId, body); const res = await api.putVault(projectId, agentId, body);
setEntries(res.entries); setEntries(res.entries);
setNotice(S.common.saved); toastSuccess(S.common.saved);
return null; return null;
} catch (e) { } catch (e) {
return e instanceof ApiError ? e.message : S.common.unknownError; return apiErrorText(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -84,40 +88,42 @@ export function VaultTab({ agentId }: { agentId: string }) {
const openAdd = () => { const openAdd = () => {
setKeyInput(""); setKeyInput("");
setValueInput(""); setValueInput("");
setAddError(null); setAddErrors({});
setAdding(true); setAdding(true);
}; };
const addEntry = async () => { const addEntry = async () => {
const key = keyInput.trim(); const key = keyInput.trim();
if (!key) { const next: { key?: string; value?: string } = {};
setAddError(S.common.requiredField); if (!key) next.key = S.common.requiredField;
else if (!VAULT_KEY_PATTERN.test(key)) next.key = S.vault.keyInvalid;
if (!valueInput) next.value = S.vault.valueRequired;
if (next.key || next.value) {
setAddErrors(next);
return; return;
} }
if (!VAULT_KEY_PATTERN.test(key)) { setAddErrors({});
setAddError(S.vault.keyInvalid); // Submitting an already-configured key overwrites its value (unrecoverable): confirm first.
if (overwriting !== key && (entries ?? []).some((e) => e.key === key)) {
setOverwriting(key);
return; return;
} }
if (!valueInput) { setOverwriting(null);
setAddError(S.vault.valueRequired);
return;
}
setAddError(null);
// Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation. // Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation.
const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] }); const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] });
if (err !== null) { if (err !== null) {
setAddError(err); // Server rejection (e.g. duplicate key) — surface it on the key field.
setAddErrors({ key: err });
return; return;
} }
setAdding(false); setAdding(false);
}; };
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */ /** Confirm modal's "Confirm": closes the modal after deletion; a failure pops a toast. */
const confirmRemove = async () => { const confirmRemove = async () => {
if (deleting === null) return; if (deleting === null) return;
setError(null);
const err = await persist({ entries: keepEntries(deleting) }); const err = await persist({ entries: keepEntries(deleting) });
if (err !== null) setError(err); if (err !== null) toastError(err);
setDeleting(null); setDeleting(null);
}; };
@@ -200,9 +206,14 @@ export function VaultTab({ agentId }: { agentId: string }) {
<Input <Input
size="sm" size="sm"
label={S.vault.key} label={S.vault.key}
required
hint={S.vault.keyHint} hint={S.vault.keyHint}
error={addErrors.key}
value={keyInput} value={keyInput}
onChange={(e) => setKeyInput(e.target.value)} onChange={(e) => {
setKeyInput(e.target.value);
clearAddErrors();
}}
className="font-mono" className="font-mono"
placeholder="OPENAI_API_KEY" placeholder="OPENAI_API_KEY"
autoComplete="off" autoComplete="off"
@@ -210,39 +221,51 @@ export function VaultTab({ agentId }: { agentId: string }) {
<PasswordInput <PasswordInput
size="sm" size="sm"
label={S.vault.value} label={S.vault.value}
required
error={addErrors.value}
value={valueInput} value={valueInput}
onChange={(e) => setValueInput(e.target.value)} onChange={(e) => {
setValueInput(e.target.value);
clearAddErrors();
}}
className="font-mono" className="font-mono"
autoComplete="off" autoComplete="off"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !busy) void addEntry(); if (e.key === "Enter" && !busy) void addEntry();
}} }}
/> />
{addError && <p className="text-xs text-red-600 dark:text-red-400">{addError}</p>}
</div> </div>
</Modal> </Modal>
{/* Delete confirmation (same pattern as Agent / Session deletion: Modal + cancel/danger-confirm). */} {/* Overwrite confirmation: the add modal stays underneath, so cancel returns to the form. */}
<Modal <ConfirmModal
open={overwriting !== null}
title={S.vault.overwriteTitle}
tone="primary"
confirmLabel={S.common.save}
busy={busy}
onClose={() => setOverwriting(null)}
onConfirm={() => void addEntry()}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{overwriting !== null ? S.vault.overwriteConfirm(overwriting) : ""}
</p>
</ConfirmModal>
{/* Delete confirmation (shared ConfirmModal, same pattern as Agent / Session deletion). */}
<ConfirmModal
open={deleting !== null} open={deleting !== null}
title={S.vault.deleteTitle} title={S.vault.deleteTitle}
busy={busy}
onClose={() => setDeleting(null)} onClose={() => setDeleting(null)}
footer={ onConfirm={() => void confirmRemove()}
<>
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
{S.common.confirm}
</Button>
</>
}
> >
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""} {deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
</p> </p>
</Modal> </ConfirmModal>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div> </div>
); );
} }
@@ -20,8 +20,8 @@ import type {
BenchmarkSummary, BenchmarkSummary,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, formatScore, humanizeDuration } from "../../lib/format"; import { formatDateTime, formatMoney, formatScore, humanizeDuration } from "../../lib/format";
import { agentDisplayName, useProject } from "../../state/project"; import { agentDisplayName, useProject } from "../../state/project";
@@ -75,7 +75,7 @@ function AgentNode({
api api
.listBenchmarks(projectId, agentId) .listBenchmarks(projectId, agentId)
.then((data) => setBenchmarks(data.benchmarks)) .then((data) => setBenchmarks(data.benchmarks))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)); .catch((e: unknown) => setError(apiErrorText(e)));
}, [open, benchmarks, projectId, agentId]); }, [open, benchmarks, projectId, agentId]);
return ( return (
@@ -147,7 +147,7 @@ function metricLabel(metric: BenchmarkMetric): string {
return metric === "score" return metric === "score"
? S.benchmark.colScore ? S.benchmark.colScore
: metric === "cost" : metric === "cost"
? S.benchmark.colCost ? S.common.cost
: S.benchmark.colDuration; : S.benchmark.colDuration;
} }
@@ -399,7 +399,7 @@ function EvaluationRow({
<tr className="text-xs text-gray-500"> <tr className="text-xs text-gray-500">
<th className="px-2 py-1 font-medium">{S.benchmark.colCase}</th> <th className="px-2 py-1 font-medium">{S.benchmark.colCase}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colScore}</th> <th className="px-2 py-1 font-medium">{S.benchmark.colScore}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colCost}</th> <th className="px-2 py-1 font-medium">{S.common.cost}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colDuration}</th> <th className="px-2 py-1 font-medium">{S.benchmark.colDuration}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colSession}</th> <th className="px-2 py-1 font-medium">{S.benchmark.colSession}</th>
</tr> </tr>
@@ -565,11 +565,11 @@ export function BenchmarkPage() {
<table className="w-full min-w-[600px] text-left text-sm"> <table className="w-full min-w-[600px] text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900"> <tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2.5">{S.benchmark.colTime}</th> <th className="px-3 py-2.5">{S.common.time}</th>
<th className="px-3 py-2.5">{S.benchmark.colVersion}</th> <th className="px-3 py-2.5">{S.benchmark.colVersion}</th>
<th className="px-3 py-2.5">{S.benchmark.colModel}</th> <th className="px-3 py-2.5">{S.benchmark.colModel}</th>
<th className="px-3 py-2.5">{S.benchmark.colScore}</th> <th className="px-3 py-2.5">{S.benchmark.colScore}</th>
<th className="px-3 py-2.5">{S.benchmark.colCost}</th> <th className="px-3 py-2.5">{S.common.cost}</th>
<th className="px-3 py-2.5">{S.benchmark.colDuration}</th> <th className="px-3 py-2.5">{S.benchmark.colDuration}</th>
</tr> </tr>
</thead> </thead>
+10 -6
View File
@@ -26,6 +26,7 @@ import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error"; import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format"; import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
import { latestConversation } from "../../lib/session-grouping";
import { approvalKey } from "../../lib/omni/stream-model"; import { approvalKey } from "../../lib/omni/stream-model";
import { useTheme } from "../../state/theme"; import { useTheme } from "../../state/theme";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
@@ -203,14 +204,17 @@ export function ChatPage() {
}; };
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]); }, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one). // Auto-select the most recent conversation when the route doesn't select one (newest loaded
// active/schedule Session — archived rows are hidden by choice and subagent Sessions belong
// to their parent, so neither is auto-opened); if there is none, fall back to draft state
// (instead of auto-creating one).
useEffect(() => { useEffect(() => {
if (sessionsLoading || draft) return; if (sessionsLoading || draft) return;
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return; if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
// A routed id missing from the paged list isn't gone until the direct lookup fails. // A routed id missing from the paged list isn't gone until the direct lookup fails.
if (routeSessionId && probeFailedId !== routeSessionId) return; if (routeSessionId && probeFailedId !== routeSessionId) return;
const first = sessions[0]; const last = latestConversation(sessions);
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true }); navigate(last ? `/chat/${last.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]); }, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
// Sync task_state to the sidebar list badge. // Sync task_state to the sidebar list badge.
@@ -574,7 +578,7 @@ export function ChatPage() {
<StatChip <StatChip
icon={STAT_ICONS.cost} icon={STAT_ICONS.cost}
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`} value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
label={`${S.chat.statCost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`} label={`${S.common.cost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
/> />
)} )}
<StatChip <StatChip
@@ -660,7 +664,7 @@ export function ChatPage() {
</div> </div>
<div> <div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400"> <p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.models.createdAt} {S.common.created}
</p> </p>
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p> <p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
</div> </div>
@@ -672,7 +676,7 @@ export function ChatPage() {
<p className="font-mono text-xs"> <p className="font-mono text-xs">
{S.chat.statTokens} {humanizeTokens(totalTokens)} {S.chat.statTokens} {humanizeTokens(totalTokens)}
{sessionCost != null && {sessionCost != null &&
` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "} ` · ${S.common.cost} ${formatMoney(sessionCost, currency)}`}{" "}
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)} · {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
</p> </p>
</div> </div>
@@ -39,7 +39,6 @@ import type {
TaskInputPart, TaskInputPart,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error"; import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
@@ -245,7 +244,7 @@ export function DraftView({
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
setThinkingLevel(rollback); setThinkingLevel(rollback);
toastError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
}); });
}, },
[projectId, agentId], [projectId, agentId],
@@ -810,7 +809,7 @@ function WorkspaceSelect({
api api
.listDirs(projectId, abs) .listDirs(projectId, abs)
.then(setDir) .then(setDir)
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)) .catch((e: unknown) => setError(apiErrorText(e)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, },
[projectId], [projectId],
@@ -69,7 +69,7 @@ function MessageMeta({
{text !== undefined && ( {text !== undefined && (
<button <button
type="button" type="button"
title={copied ? S.chat.copied : S.chat.copyMessage} title={copied ? S.common.copied : S.chat.copyMessage}
aria-label={S.chat.copyMessage} aria-label={S.chat.copyMessage}
onClick={copy} onClick={copy}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300" className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -118,7 +118,7 @@ export function TaskStatsLine({
<StatChip <StatChip
icon={STAT_ICONS.cost} icon={STAT_ICONS.cost}
value={formatMoney(cost, currency)} value={formatMoney(cost, currency)}
label={`${S.chat.statCost}(${currency})`} label={`${S.common.cost}(${currency})`}
/> />
)} )}
<StatChip <StatChip
@@ -130,7 +130,7 @@ export function TaskStatsLine({
)} )}
<button <button
type="button" type="button"
title={copied ? S.chat.copied : S.chat.copyReply} title={copied ? S.common.copied : S.chat.copyReply}
aria-label={S.chat.copyReply} aria-label={S.chat.copyReply}
onClick={copy} onClick={copy}
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300" className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -21,8 +21,11 @@ import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatBytes, formatDateTime } from "../../lib/format"; import { formatBytes, formatDateTime } from "../../lib/format";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { Dropdown } from "../../components/ui/dropdown"; import { Dropdown } from "../../components/ui/dropdown";
import { SkeletonList } from "../../components/ui/skeleton"; import { SkeletonList } from "../../components/ui/skeleton";
import { CodeBlock } from "./code-block"; import { CodeBlock } from "./code-block";
@@ -195,8 +198,11 @@ export function WorkspaceBrowser({
const [data, setData] = useState<WorkspaceFilesResponse | null>(null); const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
/** Picked files whose names collide with the loaded listing (non-null shows the overwrite confirm). */
const [pendingUpload, setPendingUpload] = useState<{ files: File[]; clashes: string[] } | null>(
null,
);
const [reloadTick, setReloadTick] = useState(0); const [reloadTick, setReloadTick] = useState(0);
const [showPath, setShowPath] = useState(false); const [showPath, setShowPath] = useState(false);
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */ /** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
@@ -223,7 +229,6 @@ export function WorkspaceBrowser({
setPath(""); setPath("");
setPreview(null); setPreview(null);
setData(null); setData(null);
setNotice(null);
}, [session.sessionId]); }, [session.sessionId]);
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once). // Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
@@ -307,11 +312,8 @@ export function WorkspaceBrowser({
void previewPath(joinPath(path, name)); void previewPath(joinPath(path, name));
}; };
const onUpload = (e: ChangeEvent<HTMLInputElement>) => { const doUpload = (files: File[]) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true); setUploading(true);
setNotice(null);
setError(null); setError(null);
void (async () => { void (async () => {
try { try {
@@ -327,15 +329,26 @@ export function WorkspaceBrowser({
}); });
await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64); await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64);
} }
setNotice(S.files.uploaded); toastSuccess(S.files.uploaded);
setReloadTick((t) => t + 1); setReloadTick((t) => t + 1);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : S.common.unknownError); toastError(apiErrorText(err));
} finally { } finally {
setUploading(false); setUploading(false);
} }
})(); })();
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? [...e.target.files] : [];
e.target.value = ""; e.target.value = "";
if (files.length === 0) return;
// Uploads overwrite same-name files: names already present in the loaded listing
// confirm first (the picker is stashed — confirm continues, cancel drops it).
const existing = new Set((data?.entries ?? []).map((entry) => entry.name));
const clashes = files.filter((f) => existing.has(f.name)).map((f) => f.name);
if (clashes.length > 0) setPendingUpload({ files, clashes });
else doUpload(files);
}; };
const crumbs = path === "" ? [] : path.split("/"); const crumbs = path === "" ? [] : path.split("/");
@@ -693,10 +706,33 @@ export function WorkspaceBrowser({
))} ))}
</ul> </ul>
)} )}
{notice && (
<p className="px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>
)}
</div> </div>
{/* Upload-overwrite confirmation: same-name files in this directory get replaced. */}
<ConfirmModal
open={pendingUpload !== null}
title={S.files.overwriteTitle}
tone="primary"
confirmLabel={S.files.upload}
onClose={() => setPendingUpload(null)}
onConfirm={() => {
if (pendingUpload) doUpload(pendingUpload.files);
setPendingUpload(null);
}}
>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.files.overwriteConfirm(pendingUpload?.clashes.length ?? 0)}
</p>
<ul className="max-h-40 overflow-y-auto rounded-md border border-gray-200 px-3 py-1.5 dark:border-gray-800">
{(pendingUpload?.clashes ?? []).map((name) => (
<li key={name} className="truncate py-0.5 font-mono text-xs" title={name}>
{name}
</li>
))}
</ul>
</div>
</ConfirmModal>
</div> </div>
); );
} }
@@ -39,8 +39,8 @@ import type {
ModelUpdateEntry, ModelUpdateEntry,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
@@ -48,11 +48,13 @@ import { USD_TO_CNY, useTheme } from "../../state/theme";
import type { Currency } from "../../state/theme"; import type { Currency } from "../../state/theme";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { FieldError, FieldLabel } from "../../components/ui/field";
import { PasswordInput } from "../../components/ui/password-input"; import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal"; import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { toastError, toastSuccess } from "../../components/ui/toast"; import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron"; import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon"; import { GlyphIcon } from "../../components/ui/glyph-icon";
@@ -233,11 +235,6 @@ export function nextPointers(args: {
}; };
} }
/** Field-level error text: placed directly below the offending input (the input itself is highlighted red via Input's invalid prop). */
function FieldError({ text }: { text: string }) {
return <span className="mt-1 block text-xs text-red-600 dark:text-red-400">{text}</span>;
}
/** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */ /** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */
type FieldErrors = Partial< type FieldErrors = Partial<
Record< Record<
@@ -383,7 +380,7 @@ export function ModelsPage() {
setDefaultModel(res.defaultModel); setDefaultModel(res.defaultModel);
setVisionModel(res.visionModel); setVisionModel(res.visionModel);
} catch (e) { } catch (e) {
setLoadError(e instanceof ApiError ? e.message : S.common.unknownError); setLoadError(apiErrorText(e));
} }
}, [projectId]); }, [projectId]);
@@ -431,7 +428,7 @@ export function ModelsPage() {
setRows(nextRows); setRows(nextRows);
if (nextDefault !== undefined) setDefaultModel(nextDefault); if (nextDefault !== undefined) setDefaultModel(nextDefault);
if (effectiveVision !== undefined) setVisionModel(effectiveVision); if (effectiveVision !== undefined) setVisionModel(effectiveVision);
toastError(e instanceof ApiError ? e.message : S.common.unknownError); toastError(apiErrorText(e));
return false; return false;
} finally { } finally {
setBusy(false); setBusy(false);
@@ -449,7 +446,7 @@ export function ModelsPage() {
if (!rows) return; if (!rows) return;
const merged = syncRowsWithCatalog(rows); const merged = syncRowsWithCatalog(rows);
if (merged.added === 0 && merged.updated === 0) { if (merged.added === 0 && merged.updated === 0) {
toastSuccess(S.models.syncUpToDate); toastInfo(S.models.syncUpToDate);
return; return;
} }
await persist( await persist(
@@ -484,7 +481,7 @@ export function ModelsPage() {
setSpeedResults((prev) => setSpeedResults((prev) =>
new Map(prev).set(key, { new Map(prev).set(key, {
ok: false, ok: false,
message: e instanceof ApiError ? e.message : S.common.unknownError, message: apiErrorText(e),
}), }),
); );
} }
@@ -813,6 +810,7 @@ export function ModelsPage() {
<Input <Input
size="sm" size="sm"
label={S.models.groupNameLabel} label={S.models.groupNameLabel}
required
value={groupName} value={groupName}
invalid={Boolean(groupNameError)} invalid={Boolean(groupNameError)}
onChange={(e) => { onChange={(e) => {
@@ -826,7 +824,7 @@ export function ModelsPage() {
className="font-mono" className="font-mono"
autoFocus autoFocus
/> />
{groupNameError && <FieldError text={groupNameError} />} {groupNameError && <FieldError>{groupNameError}</FieldError>}
</label> </label>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{S.models.addGroupDesc}</p> <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{S.models.addGroupDesc}</p>
</Modal> </Modal>
@@ -1121,7 +1119,7 @@ function ModelDialog({
if (res.ok) toastSuccess(S.models.testOk(res.latencyMs ?? 0)); if (res.ok) toastSuccess(S.models.testOk(res.latencyMs ?? 0));
else toastError(S.models.testFailed(res.message ?? "")); else toastError(S.models.testFailed(res.message ?? ""));
} catch (e) { } catch (e) {
toastError(S.models.testFailed(e instanceof ApiError ? e.message : S.common.unknownError)); toastError(S.models.testFailed(apiErrorText(e)));
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -1133,6 +1131,17 @@ function ModelDialog({
* top-level banner: it's too far from the error site, and with three price fields it's * top-level banner: it's too far from the error site, and with three price fields it's
* hard to tell which one is wrong). * hard to tell which one is wrong).
*/ */
// base URL required-field policy: an OpenAI-protocol endpoint can't be
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint). Shared by validation and the label's required "*" mark.
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
const baseUrlRequired = !preset && openAiLike;
const validated = (): RowState | null => { const validated = (): RowState | null => {
const modelId = form.modelId.trim(); const modelId = form.modelId.trim();
const ref: ModelRefDto = { provider: form.provider, modelId }; const ref: ModelRefDto = { provider: form.provider, modelId };
@@ -1143,16 +1152,7 @@ function ModelDialog({
else if (!sameModelRef(ref, form.original) && existingRefs.some((r) => sameModelRef(r, ref))) { else if (!sameModelRef(ref, form.original) && existingRefs.some((r) => sameModelRef(r, ref))) {
errs.modelId = S.models.modelIdExists; errs.modelId = S.models.modelIdExists;
} }
// base URL required-field policy: an OpenAI-protocol endpoint can't be if (baseUrlRequired && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint).
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
if (!preset && openAiLike && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
// Under PUT full-table replace semantics, omitting pricing means deleting it: all three // Under PUT full-table replace semantics, omitting pricing means deleting it: all three
// prices must be either all empty or all filled, to avoid a partial entry silently // prices must be either all empty or all filled, to avoid a partial entry silently
@@ -1241,6 +1241,10 @@ function ModelDialog({
<span className="mb-1 flex items-baseline justify-between gap-2"> <span className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400"> <span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.modelId} {S.models.modelId}
{/* Required mark, hand-placed: this label row is custom (link on the right), so FieldLabel's asterisk doesn't apply. */}
<span className="ml-0.5 text-red-500 dark:text-red-400" aria-hidden>
*
</span>
</span> </span>
<span className="flex shrink-0 items-baseline gap-2.5"> <span className="flex shrink-0 items-baseline gap-2.5">
{/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */} {/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */}
@@ -1258,6 +1262,7 @@ function ModelDialog({
</span> </span>
<Input <Input
size="sm" size="sm"
required
value={form.modelId} value={form.modelId}
disabled={!canEdit} disabled={!canEdit}
invalid={Boolean(fieldErrors.modelId)} invalid={Boolean(fieldErrors.modelId)}
@@ -1266,7 +1271,7 @@ function ModelDialog({
autoFocus={isNew} autoFocus={isNew}
placeholder={S.models.modelIdHint} placeholder={S.models.modelIdHint}
/> />
{fieldErrors.modelId && <FieldError text={fieldErrors.modelId} />} {fieldErrors.modelId && <FieldError>{fieldErrors.modelId}</FieldError>}
{/* Hint when auto-routing misses (first-party provider group, adding): warns without blocking. */} {/* Hint when auto-routing misses (first-party provider group, adding): warns without blocking. */}
{autoRouteMiss && ( {autoRouteMiss && (
<span className="mt-1 block text-xs text-amber-600 dark:text-amber-400"> <span className="mt-1 block text-xs text-amber-600 dark:text-amber-400">
@@ -1346,10 +1351,26 @@ function ModelDialog({
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
// Validate first: if validation fails, show it via the top-level hint right away without popping the confirm dialog. // Validate first: if validation fails, the inline field errors show right away without popping the confirm dialog.
if (!validated()) return; if (!validated()) return;
if (isNew) submit("save"); if (isNew || row === null) {
else setConfirming("save"); submit("save");
return;
}
// Nothing changed: report it instead of confirming a no-op write (the
// baseline is rebuilt exactly like the form's initial state, so a plain
// JSON compare is field-exact).
const initial: RowState = {
...row,
cacheRead: usdToInput(row.cacheRead, currency),
cacheWrite: usdToInput(row.cacheWrite, currency),
output: usdToInput(row.output, currency),
};
if (JSON.stringify(form) === JSON.stringify(initial)) {
toastInfo(S.common.noChangesToSave);
return;
}
setConfirming("save");
}} }}
> >
{S.common.confirm} {S.common.confirm}
@@ -1498,7 +1519,7 @@ function ModelDialog({
<span className="font-mono">{form.credential.apiKeyMasked}</span> <span className="font-mono">{form.credential.apiKeyMasked}</span>
{form.credential.createdAt && ( {form.credential.createdAt && (
<span className="text-gray-400"> <span className="text-gray-400">
{S.models.createdAt} {formatDateTime(form.credential.createdAt)} {S.common.created} {formatDateTime(form.credential.createdAt)}
</span> </span>
)} )}
{canEdit && ( {canEdit && (
@@ -1514,16 +1535,20 @@ function ModelDialog({
</div> </div>
)} )}
{/* 2) base URL */} {/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
baseUrlRequired). Official-protocol entries (everything except the OpenAI-protocol path)
carry a caution: a custom endpoint must still speak the vendor's official protocol. */}
<Input <Input
size="sm" size="sm"
label={S.models.baseUrl} label={S.models.baseUrl}
required={baseUrlRequired}
value={form.baseUrl} value={form.baseUrl}
disabled={!canEdit} disabled={!canEdit}
onChange={(e) => set({ baseUrl: e.target.value })} onChange={(e) => set({ baseUrl: e.target.value })}
className="font-mono" className="font-mono"
placeholder={preset ? S.models.baseUrlHint : "https://…"} placeholder={preset ? S.models.baseUrlHint : "https://…"}
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})} {...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
{...(!openAiLike ? { hint: S.models.baseUrlOfficialNote } : {})}
/> />
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit {/* 3) Context window + max output tokens side by side (one row): the "Token" unit
@@ -1536,9 +1561,7 @@ function ModelDialog({
model stay under its window). */} model stay under its window). */}
<div className="grid grid-cols-2 items-start gap-2"> <div className="grid grid-cols-2 items-start gap-2">
<label className="block"> <label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> <FieldLabel>{S.models.contextWindow}</FieldLabel>
{S.models.contextWindow}
</span>
<span className="relative block"> <span className="relative block">
<Input <Input
size="sm" size="sm"
@@ -1564,12 +1587,10 @@ function ModelDialog({
{S.models.tokenUnit} {S.models.tokenUnit}
</span> </span>
</span> </span>
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />} {fieldErrors.contextWindow && <FieldError>{fieldErrors.contextWindow}</FieldError>}
</label> </label>
<label className="block"> <label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> <FieldLabel>{S.models.maxTokens}</FieldLabel>
{S.models.maxTokens}
</span>
<span className="relative block"> <span className="relative block">
<Input <Input
size="sm" size="sm"
@@ -1587,7 +1608,7 @@ function ModelDialog({
{S.models.tokenUnit} {S.models.tokenUnit}
</span> </span>
</span> </span>
{fieldErrors.maxTokens && <FieldError text={fieldErrors.maxTokens} />} {fieldErrors.maxTokens && <FieldError>{fieldErrors.maxTokens}</FieldError>}
</label> </label>
</div> </div>
@@ -1604,9 +1625,7 @@ function ModelDialog({
] as Array<[keyof FieldErrors & keyof RowState, string, string]> ] as Array<[keyof FieldErrors & keyof RowState, string, string]>
).map(([key, label, value]) => ( ).map(([key, label, value]) => (
<label key={key} className="block"> <label key={key} className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400"> <FieldLabel>{label}</FieldLabel>
{label}
</span>
<span className="relative block"> <span className="relative block">
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400"> <span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
{CURRENCY_SYMBOL[currency]} {CURRENCY_SYMBOL[currency]}
@@ -1624,7 +1643,7 @@ function ModelDialog({
{S.models.priceUnitShort} {S.models.priceUnitShort}
</span> </span>
</span> </span>
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />} {fieldErrors[key] && <FieldError>{fieldErrors[key]}</FieldError>}
</label> </label>
))} ))}
</div> </div>
@@ -1669,30 +1688,21 @@ function ModelDialog({
{/* Confirmation before writing config (save / set default / set as vision proxy model / remove): stacked on top of the config dialog. */} {/* Confirmation before writing config (save / set default / set as vision proxy model / remove): stacked on top of the config dialog. */}
{confirming && ( {confirming && (
<Modal <ConfirmModal
open open
title={CONFIRM_TITLE[confirming]()} title={CONFIRM_TITLE[confirming]()}
tone={confirming === "remove" ? "danger" : "primary"}
onClose={() => setConfirming(null)} onClose={() => setConfirming(null)}
footer={ onConfirm={() => {
<> const action = confirming;
<Button onClick={() => setConfirming(null)}>{S.common.cancel}</Button> setConfirming(null);
<Button submit(action);
variant={confirming === "remove" ? "danger" : "primary"} }}
onClick={() => {
const action = confirming;
setConfirming(null);
submit(action);
}}
>
{S.common.confirm}
</Button>
</>
}
> >
<p className="text-sm text-gray-700 dark:text-gray-300"> <p className="text-sm text-gray-700 dark:text-gray-300">
{CONFIRM_BODY[confirming](form.displayName ?? form.modelId)} {CONFIRM_BODY[confirming](form.displayName ?? form.modelId)}
</p> </p>
</Modal> </ConfirmModal>
)} )}
</Modal> </Modal>
); );
@@ -1733,6 +1743,7 @@ function GroupKeyDialog({
<Input <Input
size="sm" size="sm"
label={S.models.apiKey} label={S.models.apiKey}
required
type="password" type="password"
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
@@ -17,10 +17,12 @@
* localizedShortText), and groups have no description. Icon buttons for * localizedShortText), and groups have no description. Icon buttons for
* actions (copy goes into aria-label and title) — * actions (copy goes into aria-label and title) —
* - Rotate "update installs" (shown only when some Agent's installed copy has * - Rotate "update installs" (shown only when some Agent's installed copy has
* a lower version than the library): one click reinstalls the current * a lower version than the library): opens a confirm dialog (lists each
* library copy on every outdated Agent (install-again-is-update semantics), * Agent's v_old → v_new and warns the overwriting reinstall drops local
* with a single success toast; the manage-installs Modal marks outdated * edits), then reinstalls the current library copy on every outdated Agent
* rows with an accent "更新"/"Update" button doing the same per Agent; * (install-again-is-update semantics), with a single success toast; the
* manage-installs Modal marks outdated rows with an accent "更新"/"Update"
* button doing the same per Agent (through the same confirm);
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent, * - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
* pre-selects the skill, and pre-fills the invocation text per UI language * pre-selects the skill, and pre-fills the invocation text per UI language
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body); * (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
@@ -47,6 +49,7 @@ import { Button } from "../../components/ui/button";
import { Chevron } from "../../components/ui/chevron"; import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon"; import { GlyphIcon } from "../../components/ui/glyph-icon";
import { Modal } from "../../components/ui/modal"; import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton"; import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
import { toastError, toastSuccess } from "../../components/ui/toast"; import { toastError, toastSuccess } from "../../components/ui/toast";
import { DRAFT_SESSION_ID } from "../chat/chat-page"; import { DRAFT_SESSION_ID } from "../chat/chat-page";
@@ -353,6 +356,25 @@ function SkillCard({
const { locale } = useLocale(); const { locale } = useLocale();
const { agents } = useProject(); const { agents } = useProject();
const [installOpen, setInstallOpen] = useState(false); const [installOpen, setInstallOpen] = useState(false);
// Agents pending an update confirmation (null = none): an update is an overwriting reinstall, so it needs a confirm + a per-agent version list before it runs.
const [pendingUpdate, setPendingUpdate] = useState<string[] | null>(null);
const [updating, setUpdating] = useState(false);
// Agent pending an uninstall confirmation (null = none): uninstalling deletes the installed files, local edits included.
const [pendingUninstall, setPendingUninstall] = useState<string | null>(null);
const confirmUpdate = async () => {
if (!pendingUpdate) return;
setUpdating(true);
await onUpdateOutdated(skill.name, pendingUpdate);
setUpdating(false);
setPendingUpdate(null);
};
/** Display name of the Agent pending uninstall (falls back to the raw id below). */
const uninstallAgent =
pendingUninstall !== null ? agents.find((a) => a.agentId === pendingUninstall) : undefined;
const uninstallAgentName = uninstallAgent ? agentDisplayName(uninstallAgent) : undefined;
let installedCount = 0; let installedCount = 0;
for (const m of installed.values()) if (m.has(skill.name)) installedCount += 1; for (const m of installed.values()) if (m.has(skill.name)) installedCount += 1;
const outdated = outdatedAgentIds( const outdated = outdatedAgentIds(
@@ -416,7 +438,7 @@ function SkillCard({
className="h-8 w-8 shrink-0 justify-center p-0" className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.updateOutdated(outdated.length)} ${skill.name}`} aria-label={`${S.skills.updateOutdated(outdated.length)} ${skill.name}`}
title={S.skills.updateOutdated(outdated.length)} title={S.skills.updateOutdated(outdated.length)}
onClick={() => void onUpdateOutdated(skill.name, outdated)} onClick={() => setPendingUpdate(outdated)}
> >
<GlyphIcon d={UPDATE_ICON} size={13} /> <GlyphIcon d={UPDATE_ICON} size={13} />
</Button> </Button>
@@ -457,13 +479,71 @@ function SkillCard({
name={agentDisplayName(a)} name={agentDisplayName(a)}
installed={installed.get(a.agentId)?.has(skill.name) ?? false} installed={installed.get(a.agentId)?.has(skill.name) ?? false}
outdated={outdated.includes(a.agentId)} outdated={outdated.includes(a.agentId)}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on, skill.version)} onToggle={(on) => {
onUpdate={() => void onUpdateOutdated(skill.name, [a.agentId])} // Install runs directly; uninstall deletes the installed files, so it confirms first.
if (on) void onToggleInstall(a.agentId, skill.name, true, skill.version);
else setPendingUninstall(a.agentId);
}}
onUpdate={() => setPendingUpdate([a.agentId])}
/> />
))} ))}
</div> </div>
</Modal> </Modal>
)} )}
{pendingUpdate && (
<ConfirmModal
open
title={S.skills.updateConfirmTitle(skill.name)}
tone="primary"
confirmLabel={S.skills.updateAction}
busy={updating}
onClose={() => setPendingUpdate(null)}
onConfirm={() => void confirmUpdate()}
>
<div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.skills.updateConfirmWarning(skill.name)}
</p>
{/* Per-agent old → new version, so it's clear exactly which installs get overwritten. */}
<ul className="divide-y divide-gray-100 overflow-hidden rounded-md border border-gray-200 dark:divide-gray-800 dark:border-gray-800">
{pendingUpdate.map((agentId) => {
const oldVersion = installed.get(agentId)?.get(skill.name);
const target = agents.find((a) => a.agentId === agentId);
return (
<li
key={agentId}
className="flex items-center justify-between gap-3 px-3 py-1.5 text-xs"
>
<span className="min-w-0 truncate">
{target ? agentDisplayName(target) : agentId}
</span>
<span className="shrink-0 font-mono text-gray-500 dark:text-gray-400">
v{oldVersion ?? "?"} → v{skill.version}
</span>
</li>
);
})}
</ul>
</div>
</ConfirmModal>
)}
{pendingUninstall !== null && (
<ConfirmModal
open
title={S.skills.uninstallConfirmTitle(skill.name)}
confirmLabel={S.skills.uninstall}
onClose={() => setPendingUninstall(null)}
onConfirm={() => {
const agentId = pendingUninstall;
setPendingUninstall(null);
void onToggleInstall(agentId, skill.name, false, skill.version);
}}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.skills.uninstallConfirmBody(skill.name, uninstallAgentName ?? pendingUninstall)}
</p>
</ConfirmModal>
)}
</div> </div>
); );
} }
@@ -31,8 +31,8 @@ import type {
TraceToolSpan, TraceToolSpan,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { import {
cacheHitRate, cacheHitRate,
computeTps, computeTps,
@@ -209,7 +209,7 @@ export function TraceFileView({
setModels(m); setModels(m);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : S.common.unknownError); if (!cancelled) setError(apiErrorText(err));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -425,7 +425,7 @@ export function TraceFileView({
{/* Duration · cost · TPS (cost above duration, same order as the conversation page's stats row). */} {/* Duration · cost · TPS (cost above duration, same order as the conversation page's stats row). */}
<div> <div>
<SummaryRow <SummaryRow
label={S.chat.statCost} label={S.common.cost}
value={formatMoney(costOf(global.buckets), currency)} value={formatMoney(costOf(global.buckets), currency)}
/> />
<SummaryRow <SummaryRow
@@ -497,7 +497,7 @@ export function TraceFileView({
<StatChip <StatChip
icon={STAT_ICONS.cost} icon={STAT_ICONS.cost}
value={formatMoney(costOf(tokens), currency)} value={formatMoney(costOf(tokens), currency)}
label={`${S.chat.statCost}(${currency})`} label={`${S.common.cost}(${currency})`}
/> />
<StatChip <StatChip
icon={STAT_ICONS.elapsed} icon={STAT_ICONS.elapsed}
@@ -9,8 +9,8 @@ import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import type { AgentTracesResponse } from "@prismshadow/penguin-server/api"; import type { AgentTracesResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
import { agentDisplayName, useProject } from "../../state/project"; import { agentDisplayName, useProject } from "../../state/project";
@@ -91,7 +91,7 @@ function AgentNode({
api api
.getAgentTraces(projectId, agentId) .getAgentTraces(projectId, agentId)
.then((data) => setGroups(flattenSessions(data))) .then((data) => setGroups(flattenSessions(data)))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)); .catch((e: unknown) => setError(apiErrorText(e)));
}, [open, groups, projectId, agentId]); }, [open, groups, projectId, agentId]);
// The Session deep link is applied only once: it selects the target as // The Session deep link is applied only once: it selects the target as
@@ -9,6 +9,7 @@
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray. * exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts). * The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
*/ */
import { useState } from "react";
import type { UsageErrors } from "@prismshadow/penguin-server/api"; import type { UsageErrors } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format"; import { formatDateTime } from "../../lib/format";
@@ -60,10 +61,23 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
/** /**
* Error panel: stats + a recent-errors table (the server already takes the top N, newest first). * Error panel: stats + a recent-errors table (the server already takes the top N, newest first).
* The table is table-fixed with in-cell truncation: a long message doesn't break the layout, and the full text goes into title. * The message column shows **one line per error by default** (kept compact — an error storm can
* fill the table); clicking a message expands it in place to the full text (wrapping, newlines
* preserved — the upstream detail after the code, e.g. a provider's 402 body, is what matters),
* and clicking again collapses it. The full text is also in the hover title. Cells align to the
* top so an expanded multi-line message keeps the row tidy; the table scrolls past max height.
*/ */
export function ErrorsPanel({ errors }: { errors: UsageErrors }) { export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
const { total, unexpected, topCode, recent } = errors; const { total, unexpected, topCode, recent } = errors;
// Message rows expanded to their full text (index into `recent`); one line each by default.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
return ( return (
<div> <div>
@@ -93,7 +107,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
<table className="w-full table-fixed text-xs"> <table className="w-full table-fixed text-xs">
<thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500"> <thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500">
<tr> <tr>
<Th className="w-32">{S.usage.errorsColTime}</Th> <Th className="w-32">{S.common.time}</Th>
{/* Wide enough to fully fit the longest error code: a tool {/* Wide enough to fully fit the longest error code: a tool
failure's code carries the tool name (e.g. environment · failure's code carries the tool name (e.g. environment ·
tool_failed:exec_command), and truncating it would hide which tool failed. */} tool_failed:exec_command), and truncating it would hide which tool failed. */}
@@ -110,21 +124,29 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
key={`${e.ts}-${i}`} key={`${e.ts}-${i}`}
className="border-t border-gray-100 dark:border-gray-800/60" className="border-t border-gray-100 dark:border-gray-800/60"
> >
<td className="py-1.5 pr-2 font-mono tabular-nums text-gray-400"> <td className="py-1.5 pr-2 align-top font-mono tabular-nums text-gray-400">
{formatDateTime(e.ts)} {formatDateTime(e.ts)}
</td> </td>
<td className="py-1.5 pr-2 font-mono text-gray-500 dark:text-gray-400"> <td className="py-1.5 pr-2 align-top font-mono text-gray-500 dark:text-gray-400">
<span className="block truncate" title={`${e.source} · ${e.code}`}> <span className="block break-words">
{e.source} · {e.code} {e.source} · {e.code}
</span> </span>
</td> </td>
<td className="py-1.5 pr-2"> <td className="py-1.5 pr-2 align-top">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge> <Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
</td> </td>
<td className="py-1.5 text-gray-500 dark:text-gray-400"> <td className="py-1.5 align-top text-gray-500 dark:text-gray-400">
<span className="block truncate" title={e.message}> {/* One line by default; click to expand to the full message (wrapping), click again to collapse. */}
<button
type="button"
title={e.message}
onClick={() => toggle(i)}
className={`block w-full cursor-pointer text-left transition-colors hover:text-gray-700 dark:hover:text-gray-300 ${
expanded.has(i) ? "whitespace-pre-wrap break-words" : "truncate"
}`}
>
{e.message} {e.message}
</span> </button>
</td> </td>
</tr> </tr>
); );
@@ -16,8 +16,8 @@ import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api"; import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints"; import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title"; import { useDocumentTitle } from "../../lib/use-document-title";
import { formatMoney, humanizeTokens } from "../../lib/format"; import { formatMoney, humanizeTokens } from "../../lib/format";
import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog"; import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog";
@@ -81,7 +81,7 @@ function SummaryCard({
<SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted /> <SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted />
{/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */} {/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */}
<SummaryRow <SummaryRow
label={S.usage.colCost} label={S.common.cost}
value={formatMoney(bucket.cost, currency)} value={formatMoney(bucket.cost, currency)}
sup={bucket.hasUncosted} sup={bucket.hasUncosted}
/> />
@@ -159,7 +159,7 @@ export function UsagePage() {
}); });
setData(res); setData(res);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setError(apiErrorText(e));
} }
}, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]); }, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]);
+12 -11
View File
@@ -1,8 +1,9 @@
/** /**
* API error → display text: the server's message is hardcoded in Chinese * API error → display text: the server's `{error:{code,message}}` message is
* (`{error:{code,message}}`), but the UI language may be English — so for any * **English-only**, but the UI language may be Chinese — so for any error the
* error the user might actually hit, we derive localized text from **code**; * user might actually hit, we derive localized text from the **code** (the two
* the server message is only a fallback for unknown codes. * context-dependent cases below, then the S.errors.byCode table); the raw
* server message is only a fallback for codes we haven't mapped yet.
*/ */
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { S } from "./strings"; import { S } from "./strings";
@@ -14,12 +15,12 @@ import { S } from "./strings";
*/ */
export function apiErrorText(err: unknown, ctx?: { modelId?: string }): string { export function apiErrorText(err: unknown, ctx?: { modelId?: string }): string {
if (!(err instanceof ApiError)) return S.common.unknownError; if (!(err instanceof ApiError)) return S.common.unknownError;
switch (err.code) { // These two need context the error body doesn't carry (the model id) / their own phrasing.
case "model_credential_missing": if (err.code === "model_credential_missing") {
return ctx?.modelId ? S.errors.modelCredentialMissing(ctx.modelId) : err.message; return ctx?.modelId ? S.errors.modelCredentialMissing(ctx.modelId) : err.message;
case "no_default_model":
return S.errors.noDefaultModel;
default:
return err.message;
} }
if (err.code === "no_default_model") return S.errors.noDefaultModel;
// Code → localized text; the map is a plain object, indexed by the runtime code string.
const byCode = S.errors.byCode as Record<string, string | undefined>;
return byCode[err.code] ?? err.message;
} }
+99 -41
View File
@@ -9,7 +9,11 @@
* those is single-use, so per-path groups would be one-session noise — they are all * those is single-use, so per-path groups would be one-session noise — they are all
* merged into ONE trailing "temp workspaces" group instead. * merged into ONE trailing "temp workspaces" group instead.
*/ */
import type { SessionInfo } from "@prismshadow/penguin-server/api"; import type {
SessionCategory,
SessionCategoryCounts,
SessionInfo,
} from "@prismshadow/penguin-server/api";
/** Group key of the merged auto-temp group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */ /** Group key of the merged auto-temp group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */
export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces"; export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces";
@@ -42,11 +46,13 @@ export function workspaceLabel(workspace: string): string {
} }
/** /**
* Sidebar page size: sessions fetched per Agent per page, and the per-group display cap * Sidebar page size: sessions fetched per (Agent, category) per page, and the per-group
* step for active rows. Fetches use limit = SIDEBAR_PAGE_SIZE + 1 (see splitPage) so one * display cap step for active rows — 10 conversations show by default and every "More"
* request both fills a page and answers "is there more" without a response-envelope change. * click reveals/loads 10 more. Fetches use limit = SIDEBAR_PAGE_SIZE + 1 (see splitPage)
* so one request both fills a page and answers "is there more" without a
* response-envelope change.
*/ */
export const SIDEBAR_PAGE_SIZE = 20; export const SIDEBAR_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`;
@@ -60,51 +66,103 @@ export function splitPage<T>(fetched: T[], pageSize: number): { items: T[]; hasM
} }
/** /**
* The distinct Agents contributing to a group that still have unfetched server pages — * The sidebar category a Session renders under — the same precedence the server's
* a workspace-mode group can span Agents, so "load more" for the group fans out to every * `category` list filter applies, so filtered fetching and client rendering can never
* contributing Agent with more (agent-mode groups are single-Agent and get 0..1 entries). * disagree: archived wins regardless of `source` (archiving is an explicit user action,
* so the Archived folder must show everything the user put there); otherwise a Session
* goes to its origin's bucket, and no (or an unrecognized future) source falls through
* to the active user rows (visible) rather than vanishing into the wrong folder.
*/ */
export function groupAgentsWithMore( export function sessionCategory(s: SessionInfo): SessionCategory {
sessions: SessionInfo[], if (s.archived) return "archived";
hasMoreByAgent: ReadonlyMap<string, boolean>, return s.source === "subagent" || s.source === "schedule" ? s.source : "active";
): string[] { }
const out: string[] = [];
for (const s of sessions) { /** The collapsed-folder categories of a group, in render order (below the active user rows). */
if (hasMoreByAgent.get(s.agentId) === true && !out.includes(s.agentId)) out.push(s.agentId); export const FOLDER_CATEGORIES = ["subagent", "schedule", "archived"] as const;
export type FolderCategory = (typeof FOLDER_CATEGORIES)[number];
/**
* Four-way split of one sidebar group's Sessions by sessionCategory (rendered top to
* bottom in this order): active user rows in the group body, then the collapsed
* Subagents / Scheduled / Archived folders.
*/
export type SessionPartition = Record<SessionCategory, SessionInfo[]>;
/** Partitions a group's Sessions for rendering. Input order is preserved within each part. */
export function partitionSessions(sessions: SessionInfo[]): SessionPartition {
const parts: SessionPartition = { active: [], subagent: [], schedule: [], archived: [] };
for (const s of sessions) parts[sessionCategory(s)].push(s);
return parts;
}
const ALL_CATEGORIES: readonly SessionCategory[] = ["active", ...FOLDER_CATEGORIES];
/** One workspace-mode group's aggregated server counts: exact totals plus which Agents hold rows of each category. */
export interface GroupCounts {
totals: SessionCategoryCounts;
/** Per category, the Agents whose share of this group is non-zero — the fetch fan-out set for the group's folders and "More". */
agents: Record<SessionCategory, string[]>;
}
/**
* Folds the per-Agent per-Workspace-path category counts (SessionsResponse.workspaceCounts)
* into workspace-mode groups, keyed like groupSessionsByWorkspace (exact path; auto-temp
* paths merged into the temp group). The sidebar labels a group's folders and decides its
* "More" from its own share — never from an Agent's other Workspaces, which would
* advertise folders whose content lives in other groups.
*/
export function aggregateWorkspaceCounts(
byAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>,
): Map<string, GroupCounts> {
const out = new Map<string, GroupCounts>();
for (const [agentId, byWorkspace] of byAgent) {
for (const [workspace, counts] of Object.entries(byWorkspace)) {
const key = workspaceGroupKey(workspace);
let group = out.get(key);
if (!group) {
group = {
totals: { active: 0, subagent: 0, schedule: 0, archived: 0 },
agents: { active: [], subagent: [], schedule: [], archived: [] },
};
out.set(key, group);
}
for (const category of ALL_CATEGORIES) {
const n = counts[category];
// !(n > 0) rather than n <= 0: a missing key (undefined) must not slip through and poison the totals with NaN.
if (!(n > 0)) continue;
group.totals[category] += n;
// Several temp paths of one Agent fold into the temp group: dedupe.
if (!group.agents[category].includes(agentId)) group.agents[category].push(agentId);
}
}
} }
return out; return out;
} }
/** Four-way split of one sidebar group's Sessions (rendered top to bottom in this order). */
export interface SessionPartition {
/** User-created, not archived: rendered directly in the group body. */
active: SessionInfo[];
/** Subagent-created (`source === "subagent"`), not archived: the collapsed "Subagents" folder. */
subagent: SessionInfo[];
/** Schedule-created (`source === "schedule"`), not archived: the collapsed "Scheduled" folder. */
schedule: SessionInfo[];
/** Archived: the collapsed "Archived" folder. Archived wins — an archived Session with a `source` goes here only. */
archived: SessionInfo[];
}
/** /**
* Partitions a group's Sessions for rendering. Classification precedence: archived wins * The Session the UI opens as "the last conversation" (the chat home's auto-select and
* regardless of `source` (archiving is an explicit user action, so the Archived folder * the collapsed rail's entry): the newest loaded row that is a conversation of the
* must show everything the user put there); otherwise a Session goes to its origin's * user's own. Archived rows are hidden by choice and a subagent Session is a child of
* bucket, and an unrecognized future source falls through to the user rows (visible, * some other conversation, so neither is ever auto-opened; schedule-created runs are
* with its badge) rather than vanishing into the wrong folder. The sidebar renders the * the user's conversations and qualify. Newest by createdAt (uniform ISO-8601 UTC, so
* parts top to bottom in the interface's field order — user rows, Subagents folder, * string comparison is chronological), ties broken by sessionId — the list's ordering
* Scheduled folder, Archived folder. Input order is preserved within each part. * convention. Input order doesn't matter.
*/ */
export function partitionSessions(sessions: SessionInfo[]): SessionPartition { export function latestConversation(sessions: readonly SessionInfo[]): SessionInfo | null {
const parts: SessionPartition = { active: [], subagent: [], schedule: [], archived: [] }; let best: SessionInfo | null = null;
for (const s of sessions) { for (const s of sessions) {
if (s.archived) parts.archived.push(s); const category = sessionCategory(s);
else if (s.source === "subagent") parts.subagent.push(s); if (category !== "active" && category !== "schedule") continue;
else if (s.source === "schedule") parts.schedule.push(s); if (
else parts.active.push(s); !best ||
s.createdAt > best.createdAt ||
(s.createdAt === best.createdAt && s.sessionId > best.sessionId)
) {
best = s;
}
} }
return parts; return best;
} }
export interface WorkspaceGroup { export interface WorkspaceGroup {
+80 -87
View File
@@ -1,7 +1,9 @@
/** /**
* English dictionary (constrained by the `Strings` type to the same shape as zh): * English dictionary (constrained by the `Strings` type to the same shape as zh):
* locale switching goes through state/locale.tsx. * locale switching goes through state/locale.tsx.
* Keep domain term capitalization consistent with zh — Agent, Workspace, Token, Task, etc. * Keep domain terms capitalized — Workspace, Token, Task, Session, Project, Trace.
* "agent" is a common noun: lowercase mid-sentence, capitalized only at the start
* of a label/sentence or in a proper name (Agent State, AgentHub).
*/ */
import type { Strings } from "./strings"; import type { Strings } from "./strings";
@@ -62,20 +64,29 @@ export const en: Strings = {
confirm: "Confirm", confirm: "Confirm",
close: "Close", close: "Close",
loading: "Loading…", loading: "Loading…",
loadMore: "Load more",
saved: "Saved", saved: "Saved",
saving: "Saving…", saving: "Saving…",
/** Clicking save with nothing changed: an info toast instead of a silent no-op. */
noChangesToSave: "No changes to save",
/** Confirm-before-save dialog shared by the settings forms (writes go to server-side config files). */
confirmSaveTitle: "Save changes",
confirmSaveBody:
"Save these changes? They will be written to the configuration files on the server.",
none: "(none)", none: "(none)",
retry: "Retry", retry: "Retry",
unknownError: "Request failed, please try again later", unknownError: "Request failed, please try again later",
requiredField: "This field is required", requiredField: "This field is required",
copied: "Copied", copied: "Copied",
ownerOnly: "Only the owner can modify this", name: "Name",
username: "Username",
role: "Role",
actions: "Actions",
created: "Created",
cost: "Cost",
time: "Time",
}, },
auth: { auth: {
loginTitle: "Sign in to PenguinHarness",
username: "Username",
usernameHint: usernameHint:
"2–32 chars: starts with a lowercase letter; lowercase letters, digits and underscores only", "2–32 chars: starts with a lowercase letter; lowercase letters, digits and underscores only",
password: "Password", password: "Password",
@@ -102,12 +113,8 @@ export const en: Strings = {
admin: { admin: {
users: "Users", users: "Users",
userId: "Username",
role: "Role",
roleAdmin: "Admin", roleAdmin: "Admin",
roleUser: "User", roleUser: "User",
createdAt: "Created",
actions: "Actions",
createUser: "Add user", createUser: "Add user",
initialPassword: "Initial password", initialPassword: "Initial password",
initialPasswordFlag: "initial password", initialPasswordFlag: "initial password",
@@ -116,7 +123,6 @@ export const en: Strings = {
resetPasswordTitle: (u: string): string => `Reset password for ${u}`, resetPasswordTitle: (u: string): string => `Reset password for ${u}`,
resetPasswordNote: resetPasswordNote:
"All sign-in sessions of this user will be revoked; they must sign in with the new password", "All sign-in sessions of this user will be revoked; they must sign in with the new password",
deleteUser: "Delete",
deleteUserTitle: (u: string): string => `Delete user ${u}`, deleteUserTitle: (u: string): string => `Delete user ${u}`,
deleteUserConfirm: (u: string): string => deleteUserConfirm: (u: string): string =>
`This deletes user ${u} and every Project they own (including data directories). This cannot be undone.`, `This deletes user ${u} and every Project they own (including data directories). This cannot be undone.`,
@@ -136,12 +142,7 @@ export const en: Strings = {
settingsTitle: "Project settings", settingsTitle: "Project settings",
members: "Members", members: "Members",
addMember: "Add member", addMember: "Add member",
memberUsername: "Username",
memberRole: "Role",
memberActions: "Actions",
removeMember: "Remove", removeMember: "Remove",
owner: "owner",
member: "member",
deleteProject: "Delete Project", deleteProject: "Delete Project",
deleteConfirm: deleteConfirm:
"Delete this Project? Its directory will be removed recursively and cannot be recovered.", "Delete this Project? Its directory will be removed recursively and cannot be recovered.",
@@ -158,13 +159,12 @@ export const en: Strings = {
agent: { agent: {
listTitle: "Agents", listTitle: "Agents",
create: "Create Agent", create: "Create agent",
createTitle: "Create Agent", createTitle: "Create agent",
id: "Agent id", id: "Agent id",
idHint: idHint:
"2–64 chars: starts with a lowercase letter; lowercase letters, digits and underscores only. Cannot be changed later.", "2–64 chars: starts with a lowercase letter; lowercase letters, digits and underscores only. Cannot be changed later.",
name: "Name", nameHint: "Leave empty to use the agent id as the name",
nameHint: "Leave empty to use the Agent id as the name",
description: "Description", description: "Description",
activeSessions: "Active Sessions", activeSessions: "Active Sessions",
sessionCount: (n: number): string => `${n} session${n === 1 ? "" : "s"}`, sessionCount: (n: number): string => `${n} session${n === 1 ? "" : "s"}`,
@@ -194,7 +194,7 @@ export const en: Strings = {
["{{OS_VERSION}}", "Operating system version"], ["{{OS_VERSION}}", "Operating system version"],
["{{DATE}}", "Current date"], ["{{DATE}}", "Current date"],
["{{CWD}}", "Absolute Workspace path"], ["{{CWD}}", "Absolute Workspace path"],
["{{AGENT_ID}}", "Current Agent id"], ["{{AGENT_ID}}", "Current agent id"],
["{{PROJECT_DIR}}", "Absolute Project directory (Agent State/scratchpad derive from it)"], ["{{PROJECT_DIR}}", "Absolute Project directory (Agent State/scratchpad derive from it)"],
["{{SESSION_ID}}", "Current Session id"], ["{{SESSION_ID}}", "Current Session id"],
] as ReadonlyArray<readonly [string, string]>, ] as ReadonlyArray<readonly [string, string]>,
@@ -240,8 +240,6 @@ export const en: Strings = {
timeoutInvalid: "timeoutMs must be > 0 or -1", timeoutInvalid: "timeoutMs must be > 0 or -1",
toolFieldInvalid: (name: string, field: string) => toolFieldInvalid: (name: string, field: string) =>
`${name}: ${field} must be a positive integer or -1`, `${name}: ${field} must be a positive integer or -1`,
builtinTools: "Built-in tools",
toolName: "Name",
toolPermission: "permission", toolPermission: "permission",
permissionReadLabel: "Read-only", permissionReadLabel: "Read-only",
permissionReadDescription: "Only reads. Auto-approved when the approval mode is read-only.", permissionReadDescription: "Only reads. Auto-approved when the approval mode is read-only.",
@@ -252,11 +250,10 @@ export const en: Strings = {
toolMaxOutput: "maxOutputLength", toolMaxOutput: "maxOutputLength",
mcpServers: "MCP Servers (read-only)", mcpServers: "MCP Servers (read-only)",
defaultValue: "(default)", defaultValue: "(default)",
resetToDefault: "Reset to default", deleteAgent: "Delete agent",
deleteAgent: "Delete Agent", builtinUndeletable: "Built-in agents cannot be deleted",
builtinUndeletable: "Built-in Agents cannot be deleted",
deleteConfirm: (name: string): string => deleteConfirm: (name: string): string =>
`Delete Agent "${name}"? Its directory (including all Traces) will be removed recursively and cannot be recovered.`, `Delete agent "${name}"? Its directory (including all Traces) will be removed recursively and cannot be recovered.`,
stateVersion: "Agent State version", stateVersion: "Agent State version",
transferTitle: "Export / import", transferTitle: "Export / import",
transferDesc: transferDesc:
@@ -276,11 +273,14 @@ export const en: Strings = {
addToGroup: "Add model", addToGroup: "Add model",
editTitle: "Model settings", editTitle: "Model settings",
addTitle: "Add model (OpenAI protocol)", addTitle: "Add model (OpenAI protocol)",
addTitleVendor: "Add model (auto-routed by id)", addTitleVendor: "Add model",
addProtocolHint: addProtocolHint:
"New models always use the OpenAI Chat Completions protocol (no auto-routing by model id); set the base URL to a compatible endpoint", "New models always use the OpenAI Chat Completions protocol (no auto-routing by model id); set the base URL to a compatible endpoint",
addAutoRouteHint: addAutoRouteHint:
"New models in this group are auto-routed by their upstream id to the vendor's official client: leave the base URL empty for the official endpoint, and an empty API key falls back to the resolved client's environment variable", "New models in this group are auto-routed by their upstream id to the vendor's official client: leave the base URL empty for the official endpoint, and an empty API key falls back to the resolved client's environment variable",
/** Caution beside the base URL when the entry uses a vendor's official protocol (everything except the OpenAI-protocol path). */
baseUrlOfficialNote:
"Note: this model uses the vendor's official protocol — a custom base URL must serve an endpoint compatible with it; it never switches the model to the OpenAI protocol",
autoRouteNone: autoRouteNone:
"AgentHub cannot auto-route this id: double-check it, or add the model under Custom / a user-defined group with the OpenAI protocol", "AgentHub cannot auto-route this id: double-check it, or add the model under Custom / a user-defined group with the OpenAI protocol",
addGroup: "Add group", addGroup: "Add group",
@@ -337,21 +337,15 @@ export const en: Strings = {
testing: "Testing…", testing: "Testing…",
testOk: (ms: number): string => `Connected (${ms} ms)`, testOk: (ms: number): string => `Connected (${ms} ms)`,
testFailed: (msg: string): string => `Failed: ${msg}`, testFailed: (msg: string): string => `Failed: ${msg}`,
modelIdRenameHint: "Renaming migrates the existing config and API key",
priceCacheRead: "Cache read price", priceCacheRead: "Cache read price",
priceCacheWrite: "Cache write price", priceCacheWrite: "Cache write price",
priceOutput: "Output price", priceOutput: "Output price",
priceUnit: "per 1M tokens",
currency: "Currency", currency: "Currency",
currencyUsd: "USD $", currencyUsd: "USD $",
currencyCny: "CNY ¥", currencyCny: "CNY ¥",
currencyHint: "Prices convert at 1 USD ≈ 7 CNY; always stored in USD",
firstModelDefault: "Set as the default model",
credential: "Credential",
apiKey: "API key", apiKey: "API key",
apiKeyKeepHint: "Leave empty to keep the current key", apiKeyKeepHint: "Leave empty to keep the current key",
apiKeyEnvHint: (envKey: string): string => `Leave empty to use the ${envKey} env var`, apiKeyEnvHint: (envKey: string): string => `Leave empty to use the ${envKey} env var`,
useEnvVar: (envKey: string): string => `env ${envKey}`,
keyConfigured: "Key configured", keyConfigured: "Key configured",
clearApiKey: "Clear stored API key", clearApiKey: "Clear stored API key",
baseUrl: "Custom base URL", baseUrl: "Custom base URL",
@@ -382,17 +376,14 @@ export const en: Strings = {
confirmDefaultTitle: "Set as default model", confirmDefaultTitle: "Set as default model",
confirmDefault: (name: string): string => confirmDefault: (name: string): string =>
`Make "${name}" the default model? New sessions will use it by default.`, `Make "${name}" the default model? New sessions will use it by default.`,
createdAt: "Created",
default: "Default", default: "Default",
setDefault: "Set as default model", setDefault: "Set as default model",
remove: "Delete model", remove: "Delete model",
saveAll: "Save all",
readOnlyHint: "Members have read-only access; only owners can change models and credentials", readOnlyHint: "Members have read-only access; only owners can change models and credentials",
empty: "No models configured yet", empty: "No models configured yet",
noKey: "No key", noKey: "No key",
showModelsWithoutKey: (n: number): string => showModelsWithoutKey: (n: number): string =>
`Show model${n === 1 ? "" : "s"} without a key (${n})`, `Show model${n === 1 ? "" : "s"} without a key (${n})`,
pendingSave: "(pending save)",
modelIdExists: "This model id already exists", modelIdExists: "This model id already exists",
pricingAllOrNone: "Fill all three prices", pricingAllOrNone: "Fill all three prices",
pricingInvalid: "Must be a number", pricingInvalid: "Must be a number",
@@ -400,7 +391,7 @@ export const en: Strings = {
}, },
vault: { vault: {
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one. Saved changes take effect from the next task (a task already running is unaffected).", desc: "Environment variables owned by this agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one. Saved changes take effect from the next task (a task already running is unaffected).",
key: "Name", key: "Name",
value: "Value", value: "Value",
valueMasked: "Value (masked)", valueMasked: "Value (masked)",
@@ -410,6 +401,9 @@ export const en: Strings = {
deleteTitle: "Delete variable", deleteTitle: "Delete variable",
deleteConfirm: (key: string): string => deleteConfirm: (key: string): string =>
`Delete variable "${key}"? Its value cannot be recovered.`, `Delete variable "${key}"? Its value cannot be recovered.`,
overwriteTitle: "Overwrite existing variable",
overwriteConfirm: (key: string): string =>
`"${key}" already exists — saving will overwrite its value, which cannot be recovered.`,
empty: "No variables configured yet", empty: "No variables configured yet",
readOnlyHint: "Members are read-only; only the owner can edit the vault", readOnlyHint: "Members are read-only; only the owner can edit the vault",
keyHint: "Letters, digits and underscores; must not start with a digit", keyHint: "Letters, digits and underscores; must not start with a digit",
@@ -420,7 +414,6 @@ export const en: Strings = {
schedule: { schedule: {
desc: "Scheduled tasks (agent_state/schedule/*.toml): the prompt is sent to the target Session on schedule; files can also be edited by hand, and changes made here take effect immediately.", desc: "Scheduled tasks (agent_state/schedule/*.toml): the prompt is sent to the target Session on schedule; files can also be edited by hand, and changes made here take effect immediately.",
readOnlyHint: "Members are read-only; only the owner can modify schedules", readOnlyHint: "Members are read-only; only the owner can modify schedules",
colName: "Name",
colStatus: "Status", colStatus: "Status",
colPeriod: "Period", colPeriod: "Period",
colTarget: "Target", colTarget: "Target",
@@ -443,7 +436,6 @@ export const en: Strings = {
disable: "Disable", disable: "Disable",
addTitle: "New scheduled task", addTitle: "New scheduled task",
editTitle: (name: string): string => `Edit scheduled task "${name}"`, editTitle: (name: string): string => `Edit scheduled task "${name}"`,
name: "Name",
nameHint: "The file name (without .toml); cannot be changed later", nameHint: "The file name (without .toml); cannot be changed later",
prompt: "Prompt", prompt: "Prompt",
enabled: "Enabled", enabled: "Enabled",
@@ -476,12 +468,19 @@ export const en: Strings = {
usedByAgents: (n: number): string => usedByAgents: (n: number): string =>
n === 0 ? "not used yet" : n === 1 ? "used by 1 agent" : `used by ${n} agents`, n === 0 ? "not used yet" : n === 1 ? "used by 1 agent" : `used by ${n} agents`,
installedToast: (skill: string, agent: string): string => `Installed ${skill} to ${agent}`, installedToast: (skill: string, agent: string): string => `Installed ${skill} to ${agent}`,
updateOutdated: (n: number): string => `Update available: update ${n} Agent install(s)`, updateOutdated: (n: number): string => `Update available: update ${n} agent install(s)`,
updateAction: "Update", updateAction: "Update",
updateConfirmTitle: (name: string): string => `Update ${name}`,
updateConfirmWarning: (name: string): string =>
`Updating ${name} reinstalls the library copy over each agent's installed files — any local edits to the installed skill are lost. Export a backup first if you need them.`,
updatedToast: (skill: string, n: number): string => updatedToast: (skill: string, n: number): string =>
`Updated ${skill} to the latest version (${n} Agent(s))`, `Updated ${skill} to the latest version (${n} agent(s))`,
uninstalledToast: (skill: string, agent: string): string => uninstalledToast: (skill: string, agent: string): string =>
`Uninstalled ${skill} from ${agent}`, `Uninstalled ${skill} from ${agent}`,
/** Uninstall confirmation: removing the installed copy deletes its files (local edits included). */
uninstallConfirmTitle: (name: string): string => `Uninstall ${name}`,
uninstallConfirmBody: (skill: string, agent: string): string =>
`Uninstall ${skill} from ${agent}? Its installed files (local edits included) will be deleted.`,
}, },
chat: { chat: {
@@ -584,7 +583,6 @@ When done, open index.html in a browser and self-test once.`,
"read-only": "Approve read-only (read-only)", "read-only": "Approve read-only (read-only)",
"always-ask": "Ask every time (always-ask)", "always-ask": "Ask every time (always-ask)",
} as Record<string, string>, } as Record<string, string>,
statusIdle: "Idle",
statusRunning: "Running", statusRunning: "Running",
statusCompacting: "Compacting", statusCompacting: "Compacting",
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`, pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
@@ -593,7 +591,6 @@ When done, open index.html in a browser and self-test once.`,
inputPlaceholderShort: "Type a message…", inputPlaceholderShort: "Type a message…",
send: "Send", send: "Send",
stop: "Stop", stop: "Stop",
compactingHint: "Compacting context, input is temporarily disabled",
compact: "Compact context", compact: "Compact context",
approve: "Allow", approve: "Allow",
deny: "Deny", deny: "Deny",
@@ -627,7 +624,6 @@ When done, open index.html in a browser and self-test once.`,
infoPanel: "Session info", infoPanel: "Session info",
sessionStats: "Stats", sessionStats: "Stats",
statTokens: "Total Tokens", statTokens: "Total Tokens",
statCost: "Cost",
statElapsed: "Elapsed", statElapsed: "Elapsed",
statInput: "Input tokens", statInput: "Input tokens",
statCached: "cached", statCached: "cached",
@@ -639,7 +635,6 @@ When done, open index.html in a browser and self-test once.`,
noSessions: "No Sessions yet", noSessions: "No Sessions yet",
emptyStream: "Send a message to start the conversation", emptyStream: "Send a message to start the conversation",
historyLoadFailed: "Failed to load history", historyLoadFailed: "Failed to load history",
sessionHealed: "Session healed with a new id",
statsLabel: "Stats", statsLabel: "Stats",
removeImage: "Remove image", removeImage: "Remove image",
openWorkspace: "Open workspace", openWorkspace: "Open workspace",
@@ -663,12 +658,6 @@ When done, open index.html in a browser and self-test once.`,
handoffBack: (title?: string) => handoffBack: (title?: string) =>
title ? `Back to the original conversation: ${title}` : "Back to the original conversation", title ? `Back to the original conversation: ${title}` : "Back to the original conversation",
scheduledFrom: (name: string) => `Triggered by scheduled task "${name}"`, scheduledFrom: (name: string) => `Triggered by scheduled task "${name}"`,
/** Source badge on session list rows (user-created sessions have no source). */
sourceNames: {
schedule: "Scheduled",
subagent: "Sub",
} as Record<string, string>,
commandCompact: "/compact",
emptyGreeting: "Start a new conversation", emptyGreeting: "Start a new conversation",
compactionRunning: (mode: string) => `Compaction in progress (${mode})…`, compactionRunning: (mode: string) => `Compaction in progress (${mode})…`,
compactionDone: (mode: string) => compactionDone: (mode: string) =>
@@ -678,17 +667,13 @@ When done, open index.html in a browser and self-test once.`,
compactionFailed: (status: string) => compactionFailed: (status: string) =>
`[Compaction] ${status === "aborted" ? "aborted" : "failed"}, keeping current context`, `[Compaction] ${status === "aborted" ? "aborted" : "failed"}, keeping current context`,
unknownTool: "(unknown tool)", unknownTool: "(unknown tool)",
toolCall: "Tool call",
workGroupTitle: "Reasoning & tools",
workRunning: "Running", workRunning: "Running",
workDone: "Done", workDone: "Done",
workGroupSteps: (n: number) => `${n} ${n === 1 ? "step" : "steps"}`, workGroupSteps: (n: number) => `${n} ${n === 1 ? "step" : "steps"}`,
approvalWaiting: "awaiting approval", approvalWaiting: "awaiting approval",
copyCode: "Copy code", copyCode: "Copy code",
copyStats: "Copy stats",
copyReply: "Copy reply", copyReply: "Copy reply",
copyMessage: "Copy message", copyMessage: "Copy message",
copied: "Copied",
deleteSession: "Delete chat", deleteSession: "Delete chat",
renameSession: "Rename chat", renameSession: "Rename chat",
renameSessionLabel: "Title", renameSessionLabel: "Title",
@@ -696,13 +681,13 @@ When done, open index.html in a browser and self-test once.`,
`Delete "${title}"? Its messages and Trace will be removed permanently.`, `Delete "${title}"? Its messages and Trace will be removed permanently.`,
archiveSession: "Archive", archiveSession: "Archive",
unarchiveSession: "Unarchive", unarchiveSession: "Unarchive",
archivedGroup: (n: number) => `Archived (${n})`,
/** Sidebar group "reveal/load next page" row (display cap + server paging). */ /** Sidebar group "reveal/load next page" row (display cap + server paging). */
loadMore: "More", loadMore: "More",
/** Sidebar folders for automation-created sessions (one per origin), parallel to Archived; wording matches the sourceNames badges. */ /** Collapsed sidebar folders inside a group (lazy-loaded); the count is the group's exact server share. */
sourceGroups: { folderGroups: {
subagent: (n: number) => `Subagents (${n})`, subagent: (n: number) => `Subagents (${n})`,
schedule: (n: number) => `Scheduled (${n})`, schedule: (n: number) => `Scheduled (${n})`,
archived: (n: number) => `Archived (${n})`,
}, },
skillsBanner: (names: string[]): string => skillsBanner: (names: string[]): string =>
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`, `Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
@@ -718,11 +703,12 @@ When done, open index.html in a browser and self-test once.`,
refresh: "Refresh", refresh: "Refresh",
root: "Workspace root", root: "Workspace root",
empty: "Empty directory", empty: "Empty directory",
name: "Name",
size: "Size",
modified: "Modified",
previewUnsupported: "Preview not supported for this type; download instead", previewUnsupported: "Preview not supported for this type; download instead",
uploaded: "Uploaded", uploaded: "Uploaded",
/** Upload-overwrite confirmation: same-name files in the current directory will be replaced. */
overwriteTitle: "Overwrite existing files",
overwriteConfirm: (n: number): string =>
`The current directory already has ${n} file(s) with these names — uploading will overwrite:`,
loadFailed: "Failed to load", loadFailed: "Failed to load",
previewTruncated: "File too large; preview truncated, download for the full file", previewTruncated: "File too large; preview truncated, download for the full file",
details: "Details", details: "Details",
@@ -739,19 +725,16 @@ When done, open index.html in a browser and self-test once.`,
last7d: "Last 7 days", last7d: "Last 7 days",
total: "Total", total: "Total",
tokens: "Tokens", tokens: "Tokens",
cost: "Cost",
requests: "Requests", requests: "Requests",
from: "From", from: "From",
to: "To", to: "To",
colCacheRead: "cache_read", colCacheRead: "cache_read",
colCacheWrite: "cache_write", colCacheWrite: "cache_write",
colOutput: "output", colOutput: "output",
colCost: "Cost", uncostedNote: "* Only models with configured pricing count toward cost",
trendTitle: "30-day trend", filterAllAgents: "All agents",
uncostedNote: "* Includes records without pricing; cost is a partial figure",
filterAllAgents: "All Agents",
filterAllModels: "All models", filterAllModels: "All models",
chartAgentCalls: "Calls per Agent", chartAgentCalls: "Calls per agent",
chartSuccessRate: "Model success rate", chartSuccessRate: "Model success rate",
chartTokenTrend: "Daily token trend", chartTokenTrend: "Daily token trend",
chartCostTrend: "Daily cost trend", chartCostTrend: "Daily cost trend",
@@ -762,7 +745,6 @@ When done, open index.html in a browser and self-test once.`,
errorsUnexpected: "Unexpected", errorsUnexpected: "Unexpected",
errorsExpected: "Expected", errorsExpected: "Expected",
errorsTopCode: "Most common", errorsTopCode: "Most common",
errorsColTime: "Time",
errorsColCode: "Source · code", errorsColCode: "Source · code",
errorsColKind: "Type", errorsColKind: "Type",
errorsColMessage: "Message", errorsColMessage: "Message",
@@ -771,15 +753,8 @@ When done, open index.html in a browser and self-test once.`,
traces: { traces: {
title: "Traces", title: "Traces",
filterAll: "All",
filterModel: "model_msg",
filterEvent: "event_msg",
analysis: "Trajectory view",
timeline: "Execution timeline", timeline: "Execution timeline",
laneLLM: "Model", laneLLM: "Model",
laneTools: "Tools",
phaseApproval: "approval",
phaseExec: "exec",
kindThinking: "thinking", kindThinking: "thinking",
kindModelReply: "model reply", kindModelReply: "model reply",
kindToolGen: "tool call gen", kindToolGen: "tool call gen",
@@ -803,16 +778,9 @@ When done, open index.html in a browser and self-test once.`,
taskOutput: "Output tokens this turn", taskOutput: "Output tokens this turn",
cacheHit: "Cache hits", cacheHit: "Cache hits",
hitRate: "Hit rate", hitRate: "Hit rate",
toolFailRate: "failure rate",
compactions: "compactions", compactions: "compactions",
compactionRound: "Compaction", compactionRound: "Compaction",
usageTrend: "Token usage trend", empty: "No Traces for this agent",
selectFile: "Select a Trace file on the left",
empty: "No Traces for this Agent",
colTime: "Time",
colType: "Type",
colSummary: "Summary",
lines: (n: number) => `${n} lines`,
inProgress: "in progress", inProgress: "in progress",
systemPrompt: "System prompt", systemPrompt: "System prompt",
toolDefs: (n: number) => `Tool definitions (${n})`, toolDefs: (n: number) => `Tool definitions (${n})`,
@@ -821,18 +789,16 @@ When done, open index.html in a browser and self-test once.`,
benchmark: { benchmark: {
title: "Evaluation Center", title: "Evaluation Center",
selectBenchmark: "Select a Benchmark on the left", selectBenchmark: "Select a Benchmark on the left",
emptyAgent: "No Benchmarks for this Agent", emptyAgent: "No Benchmarks for this agent",
caseCount: (n: number): string => `${n} case${n === 1 ? "" : "s"}`, caseCount: (n: number): string => `${n} case${n === 1 ? "" : "s"}`,
trendTitle: (metric: string): string => `${metric} over time`, trendTitle: (metric: string): string => `${metric} over time`,
evaluations: "Evaluations", evaluations: "Evaluations",
noEvaluations: "No evaluations yet", noEvaluations: "No evaluations yet",
summaryLabel: "Summary", summaryLabel: "Summary",
legendUnlabeled: "unlabeled model", legendUnlabeled: "unlabeled model",
colTime: "Time",
colVersion: "Version", colVersion: "Version",
colModel: "Model", colModel: "Model",
colScore: "Score", colScore: "Score",
colCost: "Cost",
colDuration: "Duration", colDuration: "Duration",
colCase: "Case", colCase: "Case",
colRun: "Run", colRun: "Run",
@@ -840,10 +806,37 @@ When done, open index.html in a browser and self-test once.`,
}, },
errors: { errors: {
unauthorized: "Session expired, please sign in again",
networkError: "Network error, please check your connection", networkError: "Network error, please check your connection",
modelCredentialMissing: (modelId: string) => modelCredentialMissing: (modelId: string) =>
`Model ${modelId} has no API key yet — configure it on the Models page first`, `Model ${modelId} has no API key yet — configure it on the Models page first`,
noDefaultModel: "This project has no default model yet — add one on the Models page first", noDefaultModel: "This project has no default model yet — add one on the Models page first",
/** Localized text for the common server error codes (server error messages are English-only); looked up by ApiError.code in apiErrorText, falling back to the raw message for unmapped codes. */
byCode: {
invalid_credentials: "Incorrect username or password.",
password_mismatch: "The current password is incorrect.",
invalid_password: "Password must be at least 8 characters.",
admin_required: "Only an admin can perform this operation.",
not_found: "This resource does not exist, or you do not have access.",
agent_not_found: "This agent no longer exists.",
agent_exists: "This agent id is already taken.",
project_exists: "This Project id is already taken.",
user_exists: "This username is already taken.",
user_not_found: "This user no longer exists.",
cannot_delete_admin: "The built-in admin cannot be deleted.",
member_not_found: "This user is not a member of the Project.",
schedule_exists: "A scheduled task with this name already exists.",
schedule_not_found: "This scheduled task no longer exists.",
unknown_skill: "This skill is not in the library.",
file_not_found: "This file no longer exists.",
file_too_large: "The file is too large.",
payload_too_large: "The request is too large.",
dir_not_absolute: "The directory must be an absolute path.",
not_a_dir: "That path is not a directory.",
path_not_found: "That path does not exist.",
workspace_missing: "This Session's Workspace no longer exists.",
task_in_progress: "This Session already has a task running.",
version_conflict: "The snapshot's version is not newer than the current one.",
invalid_title: "The title is invalid.",
},
}, },
}; };
+64 -74
View File
@@ -5,7 +5,9 @@
* state/locale.tsx, which calls `setActiveStrings` to switch and remounts the whole * state/locale.tsx, which calls `setActiveStrings` to switch and remounts the whole
* tree keyed by locale, so `S.x` reads in components always reflect the current * tree keyed by locale, so `S.x` reads in components always reflect the current
* language (module-level constants do not update on switch — keep reads inside components). * language (module-level constants do not update on switch — keep reads inside components).
* Keep domain terms capitalized in English — Agent, Workspace, Token, Task, etc. * Keep domain terms capitalized in English — Workspace, Token, Task, Session, Project, Trace.
* "agent" is a common noun: lowercase mid-sentence, capitalized only at the start of a
* label/sentence or in a proper name (Agent State, AgentHub). zh keeps "Agent" as-is.
*/ */
export const zh = { export const zh = {
appName: "PenguinHarness", appName: "PenguinHarness",
@@ -63,20 +65,28 @@ export const zh = {
confirm: "确认", confirm: "确认",
close: "关闭", close: "关闭",
loading: "加载中…", loading: "加载中…",
loadMore: "加载更多",
saved: "已保存", saved: "已保存",
saving: "保存中…", saving: "保存中…",
/** Clicking save with nothing changed: an info toast instead of a silent no-op. */
noChangesToSave: "当前没有需要保存的修改",
/** Confirm-before-save dialog shared by the settings forms (writes go to server-side config files). */
confirmSaveTitle: "保存修改",
confirmSaveBody: "确定保存这些修改吗?修改将写入服务器上的配置文件。",
none: "(无)", none: "(无)",
retry: "重试", retry: "重试",
unknownError: "请求失败,请稍后重试", unknownError: "请求失败,请稍后重试",
requiredField: "此项必填", requiredField: "此项必填",
copied: "已复制", copied: "已复制",
ownerOnly: "仅 owner 可修改", name: "名称",
username: "用户名",
role: "角色",
actions: "操作",
created: "创建时间",
cost: "成本",
time: "时间",
}, },
auth: { auth: {
loginTitle: "登录 PenguinHarness",
username: "用户名",
usernameHint: "2~32 位:小写字母开头,仅小写字母、数字与下划线", usernameHint: "2~32 位:小写字母开头,仅小写字母、数字与下划线",
password: "密码", password: "密码",
passwordHint: "至少 8 个字符", passwordHint: "至少 8 个字符",
@@ -101,12 +111,8 @@ export const zh = {
admin: { admin: {
users: "用户管理", users: "用户管理",
userId: "用户名",
role: "角色",
roleAdmin: "管理员", roleAdmin: "管理员",
roleUser: "用户", roleUser: "用户",
createdAt: "创建时间",
actions: "操作",
createUser: "新增用户", createUser: "新增用户",
initialPassword: "初始密码", initialPassword: "初始密码",
initialPasswordFlag: "初始密码", initialPasswordFlag: "初始密码",
@@ -114,7 +120,6 @@ export const zh = {
resetPassword: "重置密码", resetPassword: "重置密码",
resetPasswordTitle: (u: string): string => `重置 ${u} 的密码`, resetPasswordTitle: (u: string): string => `重置 ${u} 的密码`,
resetPasswordNote: "重置后该用户的登录会话全部失效,需用新密码重新登录", resetPasswordNote: "重置后该用户的登录会话全部失效,需用新密码重新登录",
deleteUser: "删除",
deleteUserTitle: (u: string): string => `删除用户 ${u}`, deleteUserTitle: (u: string): string => `删除用户 ${u}`,
deleteUserConfirm: (u: string): string => deleteUserConfirm: (u: string): string =>
`将删除用户 ${u} 及其名下全部 Project(含数据目录),不可恢复。`, `将删除用户 ${u} 及其名下全部 Project(含数据目录),不可恢复。`,
@@ -132,12 +137,7 @@ export const zh = {
settingsTitle: "Project 设置", settingsTitle: "Project 设置",
members: "成员", members: "成员",
addMember: "添加成员", addMember: "添加成员",
memberUsername: "用户名",
memberRole: "角色",
memberActions: "操作",
removeMember: "移除", removeMember: "移除",
owner: "owner",
member: "member",
deleteProject: "删除 Project", deleteProject: "删除 Project",
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。", deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
deleteDefaultForbidden: "default_project 与 CLI 共用,不允许在 Web 端删除", deleteDefaultForbidden: "default_project 与 CLI 共用,不允许在 Web 端删除",
@@ -155,7 +155,6 @@ export const zh = {
createTitle: "创建 Agent", createTitle: "创建 Agent",
id: "Agent id", id: "Agent id",
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改", idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
name: "名称",
nameHint: "留空则使用 Agent id 作为名称", nameHint: "留空则使用 Agent id 作为名称",
description: "描述", description: "描述",
activeSessions: "活跃 Session", activeSessions: "活跃 Session",
@@ -220,8 +219,6 @@ export const zh = {
maxTurnsInvalid: "max_turns 必须 > 0 或为 -1", maxTurnsInvalid: "max_turns 必须 > 0 或为 -1",
timeoutInvalid: "timeoutMs 必须 > 0 或为 -1", timeoutInvalid: "timeoutMs 必须 > 0 或为 -1",
toolFieldInvalid: (name: string, field: string) => `${name}: ${field} 必须是 > 0 的整数或 -1`, toolFieldInvalid: (name: string, field: string) => `${name}: ${field} 必须是 > 0 的整数或 -1`,
builtinTools: "内置工具",
toolName: "名称",
toolPermission: "permission", toolPermission: "permission",
permissionReadLabel: "Read-only", permissionReadLabel: "Read-only",
permissionReadDescription: "仅读取。审批模式为 read-only 时自动放行,无需确认。", permissionReadDescription: "仅读取。审批模式为 read-only 时自动放行,无需确认。",
@@ -232,7 +229,6 @@ export const zh = {
mcpServers: "MCP Server(只读)", mcpServers: "MCP Server(只读)",
defaultValue: "(缺省)", defaultValue: "(缺省)",
/** Reset link next to the runtime dropdowns: rewinds the local pick back to "not overridden" (the menus offer no inherit row). */ /** Reset link next to the runtime dropdowns: rewinds the local pick back to "not overridden" (the menus offer no inherit row). */
resetToDefault: "恢复缺省",
deleteAgent: "删除 Agent", deleteAgent: "删除 Agent",
builtinUndeletable: "内置 Agent 不可被删除", builtinUndeletable: "内置 Agent 不可被删除",
deleteConfirm: (name: string): string => deleteConfirm: (name: string): string =>
@@ -254,11 +250,14 @@ export const zh = {
addToGroup: "新增模型", addToGroup: "新增模型",
editTitle: "模型配置", editTitle: "模型配置",
addTitle: "新增模型(OpenAI 协议)", addTitle: "新增模型(OpenAI 协议)",
addTitleVendor: "新增模型(按 id 自动路由)", addTitleVendor: "新增模型",
addProtocolHint: addProtocolHint:
"新增模型固定走 OpenAI Chat Completions 兼容协议(不按模型 id 自动路由),base URL 填其兼容端点", "新增模型固定走 OpenAI Chat Completions 兼容协议(不按模型 id 自动路由),base URL 填其兼容端点",
addAutoRouteHint: addAutoRouteHint:
"该分组的新模型按上游 id 由 AgentHub 自动路由到厂商官方客户端:base URL 留空即官方端点,API key 留空按解析出的客户端读取环境变量", "该分组的新模型按上游 id 由 AgentHub 自动路由到厂商官方客户端:base URL 留空即官方端点,API key 留空按解析出的客户端读取环境变量",
/** Caution beside the base URL when the entry uses a vendor's official protocol (everything except the OpenAI-protocol path). */
baseUrlOfficialNote:
"注意:该模型走厂商官方协议——自定义 base URL 必须是兼容官方协议的端点,不会因此切换为 OpenAI 协议",
autoRouteNone: autoRouteNone:
"该 id 无法被 AgentHub 自动路由:请核对 id,或改在 Custom / 自建分组下以 OpenAI 协议接入", "该 id 无法被 AgentHub 自动路由:请核对 id,或改在 Custom / 自建分组下以 OpenAI 协议接入",
addGroup: "新增分组", addGroup: "新增分组",
@@ -317,21 +316,15 @@ export const zh = {
testing: "测试中…", testing: "测试中…",
testOk: (ms: number): string => `连通正常(${ms} ms)`, testOk: (ms: number): string => `连通正常(${ms} ms)`,
testFailed: (msg: string): string => `连通失败:${msg}`, testFailed: (msg: string): string => `连通失败:${msg}`,
modelIdRenameHint: "改 id 后原配置与 API key 一并迁移",
priceCacheRead: "缓存读取价格", priceCacheRead: "缓存读取价格",
priceCacheWrite: "缓存写入价格", priceCacheWrite: "缓存写入价格",
priceOutput: "输出价格", priceOutput: "输出价格",
priceUnit: "每百万 Token",
currency: "币种", currency: "币种",
currencyUsd: "美元 $", currencyUsd: "美元 $",
currencyCny: "人民币 ¥", currencyCny: "人民币 ¥",
currencyHint: "价格按 1 美元 ≈ 7 人民币折算;存储始终为美元",
firstModelDefault: "已自动设为默认模型",
credential: "凭据",
apiKey: "API key", apiKey: "API key",
apiKeyKeepHint: "留空保留现有 key", apiKeyKeepHint: "留空保留现有 key",
apiKeyEnvHint: (envKey: string): string => `留空则使用环境变量 ${envKey}`, apiKeyEnvHint: (envKey: string): string => `留空则使用环境变量 ${envKey}`,
useEnvVar: (envKey: string): string => `环境变量 ${envKey}`,
keyConfigured: "已配置 key", keyConfigured: "已配置 key",
clearApiKey: "清除已存 API key", clearApiKey: "清除已存 API key",
baseUrl: "自定义 base URL", baseUrl: "自定义 base URL",
@@ -364,17 +357,14 @@ export const zh = {
confirmDefaultTitle: "设为默认模型", confirmDefaultTitle: "设为默认模型",
confirmDefault: (name: string): string => confirmDefault: (name: string): string =>
`确定把「${name}」设为默认模型?新建的 Session 将默认使用它。`, `确定把「${name}」设为默认模型?新建的 Session 将默认使用它。`,
createdAt: "创建时间",
default: "默认", default: "默认",
setDefault: "设为默认模型", setDefault: "设为默认模型",
remove: "删除模型", remove: "删除模型",
saveAll: "保存全部",
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行", readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
empty: "尚未配置任何模型", empty: "尚未配置任何模型",
noKey: "未配置 key", noKey: "未配置 key",
/** Chat model dropdown's bottom expander row: reveals the models hidden by the configured-key filter. */ /** Chat model dropdown's bottom expander row: reveals the models hidden by the configured-key filter. */
showModelsWithoutKey: (n: number): string => `显示未配置 key 的模型(${n} 个)`, showModelsWithoutKey: (n: number): string => `显示未配置 key 的模型(${n} 个)`,
pendingSave: "(待保存)",
modelIdExists: "该模型 id 已存在", modelIdExists: "该模型 id 已存在",
pricingAllOrNone: "三项价格需一并填写", pricingAllOrNone: "三项价格需一并填写",
pricingInvalid: "必须为数字", pricingInvalid: "必须为数字",
@@ -391,6 +381,8 @@ export const zh = {
remove: "删除", remove: "删除",
deleteTitle: "删除环境变量", deleteTitle: "删除环境变量",
deleteConfirm: (key: string): string => `确认删除环境变量「${key}」?值不可恢复。`, deleteConfirm: (key: string): string => `确认删除环境变量「${key}」?值不可恢复。`,
overwriteTitle: "覆盖已有环境变量",
overwriteConfirm: (key: string): string => `「${key}」已存在,保存将覆盖原值且不可恢复。`,
empty: "尚未配置任何环境变量", empty: "尚未配置任何环境变量",
readOnlyHint: "member 只读;Vault 修改仅 owner 可执行", readOnlyHint: "member 只读;Vault 修改仅 owner 可执行",
keyHint: "字母、数字与下划线,不能以数字开头", keyHint: "字母、数字与下划线,不能以数字开头",
@@ -401,7 +393,6 @@ export const zh = {
schedule: { schedule: {
desc: "定时任务(agent_state/schedule/*.toml):到点自动向目标 Session 发送 prompt;文件亦可手工编辑,Web 端修改后即时生效。", desc: "定时任务(agent_state/schedule/*.toml):到点自动向目标 Session 发送 prompt;文件亦可手工编辑,Web 端修改后即时生效。",
readOnlyHint: "member 只读;定时任务修改仅 owner 可执行", readOnlyHint: "member 只读;定时任务修改仅 owner 可执行",
colName: "名称",
colStatus: "状态", colStatus: "状态",
colPeriod: "周期", colPeriod: "周期",
colTarget: "目标", colTarget: "目标",
@@ -424,7 +415,6 @@ export const zh = {
disable: "停用", disable: "停用",
addTitle: "新建定时任务", addTitle: "新建定时任务",
editTitle: (name: string): string => `编辑定时任务「${name}」`, editTitle: (name: string): string => `编辑定时任务「${name}」`,
name: "名称",
nameHint: "即文件名(不含 .toml),创建后不可改", nameHint: "即文件名(不含 .toml),创建后不可改",
prompt: "Prompt", prompt: "Prompt",
enabled: "启用", enabled: "启用",
@@ -462,9 +452,16 @@ export const zh = {
installedToast: (skill: string, agent: string): string => `已将 ${skill} 安装到 ${agent}`, installedToast: (skill: string, agent: string): string => `已将 ${skill} 安装到 ${agent}`,
updateOutdated: (n: number): string => `有新版本:更新 ${n} 个 Agent 的安装`, updateOutdated: (n: number): string => `有新版本:更新 ${n} 个 Agent 的安装`,
updateAction: "更新", updateAction: "更新",
updateConfirmTitle: (name: string): string => `更新 ${name}`,
updateConfirmWarning: (name: string): string =>
`更新 ${name} 会把库内当前副本重装到各 Agent,覆盖其已安装的文件——对已装技能的本地改动会丢失,如有需要请先导出备份。`,
updatedToast: (skill: string, n: number): string => updatedToast: (skill: string, n: number): string =>
`已将 ${skill} 更新到最新版(${n} 个 Agent)`, `已将 ${skill} 更新到最新版(${n} 个 Agent)`,
uninstalledToast: (skill: string, agent: string): string => `已从 ${agent} 卸载 ${skill}`, uninstalledToast: (skill: string, agent: string): string => `已从 ${agent} 卸载 ${skill}`,
/** Uninstall confirmation: removing the installed copy deletes its files (local edits included). */
uninstallConfirmTitle: (name: string): string => `卸载 ${name}`,
uninstallConfirmBody: (skill: string, agent: string): string =>
`确定从 ${agent} 卸载 ${skill} 吗?已安装的技能文件(含本地改动)将被删除。`,
}, },
chat: { chat: {
@@ -494,7 +491,7 @@ export const zh = {
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent", draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/** /**
* Example task cards on the draft screen: one click auto-submits the canned prompt (game * Example task cards on the draft screen: one click auto-submits the canned prompt (game
* card first, RAG card below/after it). These are the FULL working prompts — the README and * card, then the LoL-player card, then the RAG card). These are the FULL working prompts — the README and
* landing page show a condensed one-sentence version of the RAG example for reading, and * landing page show a condensed one-sentence version of the RAG example for reading, and
* the cards' own desc lines stay short, but what actually gets submitted stays detailed: * the cards' own desc lines stay short, but what actually gets submitted stays detailed:
* build quality depends on it. * build quality depends on it.
@@ -571,7 +568,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
"read-only": "放行只读(read-only)", "read-only": "放行只读(read-only)",
"always-ask": "总是询问(always-ask)", "always-ask": "总是询问(always-ask)",
} as Record<string, string>, } as Record<string, string>,
statusIdle: "空闲",
statusRunning: "运行中", statusRunning: "运行中",
statusCompacting: "压缩中", statusCompacting: "压缩中",
pendingApprovals: (n: number) => `${n} 个待审批`, pendingApprovals: (n: number) => `${n} 个待审批`,
@@ -580,7 +576,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
inputPlaceholderShort: "输入消息…", inputPlaceholderShort: "输入消息…",
send: "发送", send: "发送",
stop: "停止", stop: "停止",
compactingHint: "上下文压缩中,暂不接受新输入",
compact: "压缩上下文", compact: "压缩上下文",
approve: "允许", approve: "允许",
deny: "拒绝", deny: "拒绝",
@@ -613,7 +608,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
infoPanel: "Session 信息", infoPanel: "Session 信息",
sessionStats: "统计", sessionStats: "统计",
statTokens: "Token 累计", statTokens: "Token 累计",
statCost: "成本",
statElapsed: "用时", statElapsed: "用时",
statInput: "输入 tokens", statInput: "输入 tokens",
statCached: "已缓存", statCached: "已缓存",
@@ -625,7 +619,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
noSessions: "还没有 Session", noSessions: "还没有 Session",
emptyStream: "发送一条消息开始对话", emptyStream: "发送一条消息开始对话",
historyLoadFailed: "历史消息加载失败", historyLoadFailed: "历史消息加载失败",
sessionHealed: "Session 已自愈为新 id",
statsLabel: "统计信息", statsLabel: "统计信息",
removeImage: "移除图片", removeImage: "移除图片",
openWorkspace: "打开工作区", openWorkspace: "打开工作区",
@@ -650,12 +643,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`, handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`,
handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"), handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"),
scheduledFrom: (name: string) => `由定时任务「${name}」触发`, scheduledFrom: (name: string) => `由定时任务「${name}」触发`,
/** Source badge on session list rows (user-created sessions have no source). */
sourceNames: {
schedule: "定时",
subagent: "子",
} as Record<string, string>,
commandCompact: "/compact",
emptyGreeting: "开始一段新对话", emptyGreeting: "开始一段新对话",
compactionRunning: (mode: string) => `压缩进行中(${mode})…`, compactionRunning: (mode: string) => `压缩进行中(${mode})…`,
compactionDone: (mode: string): string => compactionDone: (mode: string): string =>
@@ -663,17 +650,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
compactionFailed: (status: string) => compactionFailed: (status: string) =>
`[压缩] ${status === "aborted" ? "已中断" : "失败"},保留当前上下文`, `[压缩] ${status === "aborted" ? "已中断" : "失败"},保留当前上下文`,
unknownTool: "(未知工具)", unknownTool: "(未知工具)",
toolCall: "工具调用",
workGroupTitle: "推理与工具",
workRunning: "运行中", workRunning: "运行中",
workDone: "运行完毕", workDone: "运行完毕",
workGroupSteps: (n: number) => `${n} 步`, workGroupSteps: (n: number) => `${n} 步`,
approvalWaiting: "待审批", approvalWaiting: "待审批",
copyCode: "复制代码", copyCode: "复制代码",
copyStats: "复制统计",
copyReply: "复制回复", copyReply: "复制回复",
copyMessage: "复制消息", copyMessage: "复制消息",
copied: "已复制",
deleteSession: "删除对话", deleteSession: "删除对话",
renameSession: "重命名对话", renameSession: "重命名对话",
renameSessionLabel: "标题", renameSessionLabel: "标题",
@@ -681,13 +664,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
`确定删除「${title}」?该对话的消息与 Trace 将被移除,且不可恢复。`, `确定删除「${title}」?该对话的消息与 Trace 将被移除,且不可恢复。`,
archiveSession: "归档", archiveSession: "归档",
unarchiveSession: "取消归档", unarchiveSession: "取消归档",
archivedGroup: (n: number) => `已归档(${n})`,
/** Sidebar group "reveal/load next page" row (display cap + server paging). */ /** Sidebar group "reveal/load next page" row (display cap + server paging). */
loadMore: "更多", loadMore: "更多",
/** Sidebar folders for automation-created sessions (one per origin), parallel to 已归档; wording matches the sourceNames badges. */ /** Collapsed sidebar folders inside a group (lazy-loaded); the count is the group's exact server share. */
sourceGroups: { folderGroups: {
subagent: (n: number) => `子智能体(${n})`, subagent: (n: number) => `子智能体(${n})`,
schedule: (n: number) => `定时任务(${n})`, schedule: (n: number) => `定时任务(${n})`,
archived: (n: number) => `已归档(${n})`,
}, },
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`, skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
}, },
@@ -702,11 +685,11 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
refresh: "刷新", refresh: "刷新",
root: "根目录", root: "根目录",
empty: "空目录", empty: "空目录",
name: "名称",
size: "大小",
modified: "修改时间",
previewUnsupported: "该类型不支持预览,请下载查看", previewUnsupported: "该类型不支持预览,请下载查看",
uploaded: "已上传", uploaded: "已上传",
/** Upload-overwrite confirmation: same-name files in the current directory will be replaced. */
overwriteTitle: "覆盖同名文件",
overwriteConfirm: (n: number): string => `当前目录已存在以下 ${n} 个同名文件,上传将覆盖:`,
loadFailed: "加载失败", loadFailed: "加载失败",
previewTruncated: "内容过大,预览已截断,请下载查看完整文件", previewTruncated: "内容过大,预览已截断,请下载查看完整文件",
details: "详情", details: "详情",
@@ -723,16 +706,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
last7d: "近 7 天", last7d: "近 7 天",
total: "累计", total: "累计",
tokens: "Token", tokens: "Token",
cost: "成本",
requests: "Requests", requests: "Requests",
from: "起始日期", from: "起始日期",
to: "结束日期", to: "结束日期",
colCacheRead: "cache_read", colCacheRead: "cache_read",
colCacheWrite: "cache_write", colCacheWrite: "cache_write",
colOutput: "output", colOutput: "output",
colCost: "成本", uncostedNote: "* 只计入配置了价格的模型成本",
trendTitle: "近 30 天趋势",
uncostedNote: "* 含未配置 pricing 的记录,成本为不完全统计",
filterAllAgents: "全部 Agent", filterAllAgents: "全部 Agent",
filterAllModels: "全部模型", filterAllModels: "全部模型",
chartAgentCalls: "各 Agent 调用次数", chartAgentCalls: "各 Agent 调用次数",
@@ -746,7 +726,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
errorsUnexpected: "未预期", errorsUnexpected: "未预期",
errorsExpected: "预期内", errorsExpected: "预期内",
errorsTopCode: "最常见", errorsTopCode: "最常见",
errorsColTime: "时间",
errorsColCode: "来源 · 错误码", errorsColCode: "来源 · 错误码",
errorsColKind: "类型", errorsColKind: "类型",
errorsColMessage: "消息", errorsColMessage: "消息",
@@ -755,15 +734,8 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
traces: { traces: {
title: "轨迹观测", title: "轨迹观测",
filterAll: "全部",
filterModel: "model_msg",
filterEvent: "event_msg",
analysis: "轨迹观测",
timeline: "执行时间线", timeline: "执行时间线",
laneLLM: "模型", laneLLM: "模型",
laneTools: "工具",
phaseApproval: "审批",
phaseExec: "执行",
kindThinking: "思考", kindThinking: "思考",
kindModelReply: "模型回复", kindModelReply: "模型回复",
kindToolGen: "工具调用生成", kindToolGen: "工具调用生成",
@@ -787,16 +759,9 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
taskOutput: "本轮输出 tokens", taskOutput: "本轮输出 tokens",
cacheHit: "命中缓存", cacheHit: "命中缓存",
hitRate: "命中率", hitRate: "命中率",
toolFailRate: "失败率",
compactions: "压缩次数", compactions: "压缩次数",
compactionRound: "压缩", compactionRound: "压缩",
usageTrend: "Token 用量趋势",
selectFile: "在左侧选择一个 Trace 文件",
empty: "该 Agent 暂无 Trace", empty: "该 Agent 暂无 Trace",
colTime: "时间",
colType: "类型",
colSummary: "摘要",
lines: (n: number) => `${n} 行`,
inProgress: "进行中", inProgress: "进行中",
systemPrompt: "系统提示词", systemPrompt: "系统提示词",
toolDefs: (n: number) => `工具定义(${n})`, toolDefs: (n: number) => `工具定义(${n})`,
@@ -815,11 +780,9 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
summaryLabel: "评估说明", summaryLabel: "评估说明",
/** Chart legend: older evaluation records with no model label (gray series). */ /** Chart legend: older evaluation records with no model label (gray series). */
legendUnlabeled: "未标注模型", legendUnlabeled: "未标注模型",
colTime: "时间",
colVersion: "版本", colVersion: "版本",
colModel: "模型", colModel: "模型",
colScore: "总分", colScore: "总分",
colCost: "成本",
colDuration: "耗时", colDuration: "耗时",
colCase: "题目", colCase: "题目",
colRun: "运行", colRun: "运行",
@@ -828,11 +791,38 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
// Server error code → localized copy (the server's message is hardcoded Chinese; this is only a fallback for unknown codes). // Server error code → localized copy (the server's message is hardcoded Chinese; this is only a fallback for unknown codes).
errors: { errors: {
unauthorized: "登录已过期,请重新登录",
networkError: "网络错误,请检查连接", networkError: "网络错误,请检查连接",
modelCredentialMissing: (modelId: string) => modelCredentialMissing: (modelId: string) =>
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置`, `模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置`,
noDefaultModel: "该 Project 还没有默认模型,请先在「模型」页添加模型并设为默认", noDefaultModel: "该 Project 还没有默认模型,请先在「模型」页添加模型并设为默认",
/** Localized text for the common server error codes (server error messages are English-only); looked up by ApiError.code in apiErrorText, falling back to the raw message for unmapped codes. */
byCode: {
invalid_credentials: "用户名或密码错误。",
password_mismatch: "当前密码不正确。",
invalid_password: "密码至少 8 位。",
admin_required: "仅管理员可执行此操作。",
not_found: "资源不存在,或你没有访问权限。",
agent_not_found: "该 Agent 已不存在。",
agent_exists: "该 Agent id 已被占用。",
project_exists: "该 Project id 已被占用。",
user_exists: "该用户名已被占用。",
user_not_found: "该用户已不存在。",
cannot_delete_admin: "内置 admin 不可删除。",
member_not_found: "该用户不是本 Project 的成员。",
schedule_exists: "已存在同名定时任务。",
schedule_not_found: "该定时任务已不存在。",
unknown_skill: "该技能不在技能库中。",
file_not_found: "该文件已不存在。",
file_too_large: "文件过大。",
payload_too_large: "请求体过大。",
dir_not_absolute: "目录必须是绝对路径。",
not_a_dir: "该路径不是目录。",
path_not_found: "该路径不存在。",
workspace_missing: "该 Session 的 Workspace 已不存在。",
task_in_progress: "该 Session 已有任务在运行。",
version_conflict: "快照版本不高于当前版本。",
invalid_title: "标题无效。",
},
}, },
}; };
+26 -11
View File
@@ -8,13 +8,13 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { S } from "../lib/strings"; import { S } from "../lib/strings";
import { apiErrorText } from "../lib/api-error";
import { useDocumentTitle } from "../lib/use-document-title"; import { useDocumentTitle } from "../lib/use-document-title";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useLocale } from "../state/locale"; import { useLocale } from "../state/locale";
import type { LangPref } from "../state/locale"; import type { LangPref } from "../state/locale";
import { useTheme } from "../state/theme"; import { useTheme } from "../state/theme";
import type { ThemeMode } from "../state/theme"; import type { ThemeMode } from "../state/theme";
import { ApiError } from "../api/client";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { PasswordInput } from "../components/ui/password-input"; import { PasswordInput } from "../components/ui/password-input";
@@ -30,21 +30,26 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); // Per-field required errors sit next to their input; `form` holds the auth failure (wrong user/password), which isn't specific to one field.
const [errors, setErrors] = useState<{ userId?: string; password?: string; form?: string }>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const clearErrors = () => setErrors((p) => (p.userId || p.password || p.form ? {} : p));
const submit = async () => { const submit = async () => {
if (!userId.trim() || !password) { const next: { userId?: string; password?: string } = {};
setError(S.common.requiredField); if (!userId.trim()) next.userId = S.common.requiredField;
if (!password) next.password = S.common.requiredField;
if (next.userId || next.password) {
setErrors(next);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setErrors({});
try { try {
await login(userId.trim(), password); await login(userId.trim(), password);
navigate("/chat", { replace: true }); navigate("/chat", { replace: true });
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError); setErrors({ form: apiErrorText(e) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -90,21 +95,31 @@ export function LoginPage() {
}} }}
> >
<Input <Input
label={S.auth.username} label={S.common.username}
required
value={userId} value={userId}
onChange={(e) => setUserId(e.target.value)} onChange={(e) => {
setUserId(e.target.value);
clearErrors();
}}
error={errors.userId}
autoComplete="username" autoComplete="username"
autoFocus autoFocus
/> />
<PasswordInput <PasswordInput
label={S.auth.password} label={S.auth.password}
required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value);
clearErrors();
}}
error={errors.password}
autoComplete="current-password" autoComplete="current-password"
/> />
{error && ( {errors.form && (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"> <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300">
{error} {errors.form}
</p> </p>
)} )}
<Button <Button
+239 -54
View File
@@ -3,11 +3,15 @@
* the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel); * the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel);
* the chat page shares this same data for status sync / title events / self-healing reload. * the chat page shares this same data for status sync / title events / self-healing reload.
* *
* **Paged**: each Agent fetches SIDEBAR_PAGE_SIZE sessions per page (requesting one extra * **Paged per (Agent, category)**: the default load fetches only the **active** category
* to detect "has more" — see splitPage), so a Project with a long history doesn't pull * (user-created, non-archived) plus per-category totals — archived / subagent / schedule
* thousands of rows on every load; `loadMoreFor` appends the next page(s) on demand * Sessions are not loaded until their collapsed folder is opened. Each pair fetches
* (deduplicated by sessionId — new sessions shift server offsets). A reload resets every * SIDEBAR_PAGE_SIZE sessions per page (requesting one extra to detect "has more" — see
* Agent back to its first page. * splitPage); `loadMoreFor` fetches a pair's first page when unloaded and the next page
* otherwise (deduplicated by sessionId — new sessions shift server offsets), so every
* category's paging is independent of the others. A reload resets each **loaded** pair
* back to its first page (an open folder must not blank on an event-triggered refresh)
* and leaves unopened folders unloaded.
* *
* **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`), * **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`),
* and the Session is only actually created when the first message is sent — after landing, the user * and the Session is only actually created when the first message is sent — after landing, the user
@@ -24,23 +28,39 @@ import {
useState, useState,
} from "react"; } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api"; import type {
SessionCategory,
SessionCategoryCounts,
SessionInfo,
SessionStatus,
} from "@prismshadow/penguin-server/api";
import * as api from "../api/endpoints"; import * as api from "../api/endpoints";
import { openUserEvents } from "../api/sse"; import { openUserEvents } from "../api/sse";
import { SIDEBAR_PAGE_SIZE, splitPage } from "../lib/session-grouping"; import {
FOLDER_CATEGORIES,
SIDEBAR_PAGE_SIZE,
sessionCategory,
splitPage,
} from "../lib/session-grouping";
import { useProject } from "./project"; import { useProject } from "./project";
interface SessionsContextValue { interface SessionsContextValue {
/** Loaded list (paged per Agent; each Agent's entries newest first). */ /** Loaded list (paged per Agent and category; each Agent's entries newest first). */
sessions: SessionInfo[]; sessions: SessionInfo[];
/** agentId → that Agent's loaded Session list, newest first (empty array if none). */ /** agentId → that Agent's loaded Session list, newest first (empty array if none). */
byAgent: ReadonlyMap<string, SessionInfo[]>; byAgent: ReadonlyMap<string, SessionInfo[]>;
/** agentId → whether the server has more (unfetched) sessions for that Agent. */ /** agentId → per-category totals from the last list fetch (folder labels; kept in step locally on add / remove / archive toggles). */
hasMoreByAgent: ReadonlyMap<string, boolean>; countsByAgent: ReadonlyMap<string, SessionCategoryCounts>;
/** agentId → the same totals broken down by Workspace path (workspace-mode groups read their own share from it; maintained like countsByAgent). */
workspaceCountsByAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>;
/** Whether a pair's first page has been fetched (false = the folder shows nothing because nothing was asked for yet). */
isLoadedFor: (agentId: string, category: SessionCategory) => boolean;
/** Whether the server still holds unfetched Sessions of a category for an Agent — an unloaded pair answers from the counts. */
hasMoreFor: (agentId: string, category: SessionCategory) => boolean;
loading: boolean; loading: boolean;
reload: () => Promise<void>; reload: () => Promise<void>;
/** Fetches and appends the next page for each given Agent that still has more (no-op otherwise). */ /** Fetches a category's first page for each given unloaded Agent and the next page for each loaded one with more (no-op otherwise). */
loadMoreFor: (agentIds: string[]) => Promise<void>; loadMoreFor: (agentIds: string[], category: SessionCategory) => Promise<void>;
/** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */ /** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */
add: (session: SessionInfo) => void; add: (session: SessionInfo) => void;
/** Remove from the list in place after deletion. */ /** Remove from the list in place after deletion. */
@@ -55,6 +75,22 @@ interface SessionsContextValue {
const SessionsContext = createContext<SessionsContextValue | null>(null); const SessionsContext = createContext<SessionsContextValue | null>(null);
/** Page-state key of one (Agent, category) pair ("\0" never appears in Agent ids). */
const pageKey = (agentId: string, category: SessionCategory) => `${agentId}\0${category}`;
/** One pair's paging cursor. */
interface PagePosition {
/** Whether the server still has unfetched rows past `fetched`. */
hasMore: boolean;
/**
* Rows consumed from the server's category stream — the exact offset of the next
* page. Deliberately NOT derived from the loaded list: `add()` prepends rows that
* were never part of any page (deep-link self-heal), and counting those would skip
* a server row on the next fetch.
*/
fetched: number;
}
export function SessionsProvider({ children }: { children: ReactNode }) { export function SessionsProvider({ children }: { children: ReactNode }) {
const { currentProject, agents } = useProject(); const { currentProject, agents } = useProject();
const projectId = currentProject?.projectId ?? null; const projectId = currentProject?.projectId ?? null;
@@ -63,7 +99,14 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
const agentIdsKey = agents.map((a) => a.agentId).join(","); const agentIdsKey = agents.map((a) => a.agentId).join(",");
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [hasMoreByAgent, setHasMoreByAgent] = useState<ReadonlyMap<string, boolean>>(new Map()); /** pageKey → that pair's paging cursor; a key is present iff its first page has been fetched. */
const [pageState, setPageState] = useState<ReadonlyMap<string, PagePosition>>(new Map());
const [countsByAgent, setCountsByAgent] = useState<ReadonlyMap<string, SessionCategoryCounts>>(
new Map(),
);
const [workspaceCountsByAgent, setWorkspaceCountsByAgent] = useState<
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
>(new Map());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 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.
@@ -71,8 +114,10 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
// Current values for loadMoreFor (offsets are computed from what is actually loaded). // Current values for loadMoreFor (offsets are computed from what is actually loaded).
const sessionsRef = useRef<SessionInfo[]>([]); const sessionsRef = useRef<SessionInfo[]>([]);
sessionsRef.current = sessions; sessionsRef.current = sessions;
const hasMoreRef = useRef<ReadonlyMap<string, boolean>>(hasMoreByAgent); const pageStateRef = useRef<ReadonlyMap<string, PagePosition>>(pageState);
hasMoreRef.current = hasMoreByAgent; pageStateRef.current = pageState;
const countsRef = useRef<ReadonlyMap<string, SessionCategoryCounts>>(countsByAgent);
countsRef.current = countsByAgent;
const reload = useCallback(async () => { const reload = useCallback(async () => {
const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(","); const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(",");
@@ -82,24 +127,66 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
try { try {
const results = await Promise.all( const results = await Promise.all(
agentIds.map(async (agentId) => { agentIds.map(async (agentId) => {
// Active first page (with per-category totals) always; plus the first page of
// every folder category already on screen — a reload triggered by a server
// event must refresh, not blank, an open folder.
const categories: SessionCategory[] = [
"active",
...FOLDER_CATEGORIES.filter((cat) => pageStateRef.current.has(pageKey(agentId, cat))),
];
try { try {
// First page per Agent, requesting one extra row to learn whether more exist. const pages = await Promise.all(
const fetched = ( categories.map(async (category) => {
await api.listSessions(projectId, agentId, { const res = await api.listSessions(projectId, agentId, {
offset: 0, offset: 0,
limit: SIDEBAR_PAGE_SIZE + 1, limit: SIDEBAR_PAGE_SIZE + 1,
}) category,
).sessions; ...(category === "active" ? { withCounts: true } : {}),
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) }; });
return {
category,
counts: res.counts,
workspaceCounts: res.workspaceCounts,
...splitPage(res.sessions, SIDEBAR_PAGE_SIZE),
};
}),
);
return { agentId, pages };
} catch { } catch {
// A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally). // A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
return { agentId, items: [] as SessionInfo[], hasMore: false }; return { agentId, pages: [] };
} }
}), }),
); );
if (g !== gen.current) return; if (g !== gen.current) return;
setSessions(results.flatMap((r) => r.items)); const nextSessions: SessionInfo[] = [];
setHasMoreByAgent(new Map(results.map((r) => [r.agentId, r.hasMore]))); const seen = new Set<string>();
const nextPageState = new Map<string, PagePosition>();
const nextCounts = new Map<string, SessionCategoryCounts>();
const nextWorkspaceCounts = new Map<
string,
Readonly<Record<string, SessionCategoryCounts>>
>();
for (const r of results) {
for (const p of r.pages) {
nextPageState.set(pageKey(r.agentId, p.category), {
hasMore: p.hasMore,
fetched: p.items.length,
});
if (p.counts) nextCounts.set(r.agentId, p.counts);
if (p.workspaceCounts) nextWorkspaceCounts.set(r.agentId, p.workspaceCounts);
for (const s of p.items) {
if (!seen.has(s.sessionId)) {
seen.add(s.sessionId);
nextSessions.push(s);
}
}
}
}
setSessions(nextSessions);
setPageState(nextPageState);
setCountsByAgent(nextCounts);
setWorkspaceCountsByAgent(nextWorkspaceCounts);
} finally { } finally {
if (g === gen.current) setLoading(false); if (g === gen.current) setLoading(false);
} }
@@ -107,54 +194,117 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
setSessions([]); setSessions([]);
setHasMoreByAgent(new Map()); // reload() reads the ref synchronously to pick the categories to refetch — reset it
// in place so a Project switch can't carry folder page state across via shared Agent
// ids (default_agent exists in every Project).
pageStateRef.current = new Map();
setPageState(pageStateRef.current);
setCountsByAgent(new Map());
setWorkspaceCountsByAgent(new Map());
void reload(); void reload();
}, [reload]); }, [reload]);
/** /**
* Next page for each given Agent that still has more. Offsets are the currently loaded * Category page fetch for each given Agent: the first page when the pair is unloaded
* per-Agent counts; a session created since the last page shifts server offsets, so * (skipped unless the counts say the category holds anything), the next page when
* appended rows are deduplicated by sessionId (a short page is fine — `hasMore` comes * loaded with more. The offset is the pair's `fetched` cursor — rows actually
* from the server response, and the next click continues from the new count). * consumed from the server's stream, never rows `add()` slipped in. A session
* created since the last page still shifts server offsets, so appended rows are
* deduplicated by sessionId (a short page is fine — `hasMore` comes from the server
* response, and the next click continues from the advanced cursor).
*/ */
const loadMoreFor = useCallback( const loadMoreFor = useCallback(
async (agentIds: string[]) => { async (agentIds: string[], category: SessionCategory) => {
if (!projectId) return; if (!projectId) return;
const targets = [...new Set(agentIds)].filter((id) => hasMoreRef.current.get(id) === true); const targets = [...new Set(agentIds)].filter((agentId) => {
const position = pageStateRef.current.get(pageKey(agentId, category));
if (position === undefined) return (countsRef.current.get(agentId)?.[category] ?? 0) > 0;
return position.hasMore;
});
if (targets.length === 0) return; if (targets.length === 0) return;
const g = gen.current; const g = gen.current;
const results = await Promise.all( const results = await Promise.all(
targets.map(async (agentId) => { targets.map(async (agentId) => {
const offset = sessionsRef.current.filter((s) => s.agentId === agentId).length; const offset = pageStateRef.current.get(pageKey(agentId, category))?.fetched ?? 0;
try { try {
const fetched = ( const fetched = (
await api.listSessions(projectId, agentId, { await api.listSessions(projectId, agentId, {
offset, offset,
limit: SIDEBAR_PAGE_SIZE + 1, limit: SIDEBAR_PAGE_SIZE + 1,
category,
}) })
).sessions; ).sessions;
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) }; return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
} catch { } catch {
// Transient failure: keep "has more" so the affordance stays and the user can retry. // Transient failure: leave the pair's state untouched (still unloaded / still
return { agentId, items: [] as SessionInfo[], hasMore: true }; // has-more), so the affordance stays and the user can retry.
return null;
} }
}), }),
); );
if (g !== gen.current) return; // Project switch / reload raced this page: drop it. if (g !== gen.current) return; // Project switch / reload raced this page: drop it.
const ok = results.filter((r) => r !== null);
setSessions((prev) => { setSessions((prev) => {
const seen = new Set(prev.map((s) => s.sessionId)); const seen = new Set(prev.map((s) => s.sessionId));
const appended = results.flatMap((r) => r.items.filter((s) => !seen.has(s.sessionId))); const appended = ok.flatMap((r) => r.items.filter((s) => !seen.has(s.sessionId)));
return appended.length > 0 ? [...prev, ...appended] : prev; return appended.length > 0 ? [...prev, ...appended] : prev;
}); });
setHasMoreByAgent((prev) => { setPageState((prev) => {
const next = new Map(prev); const next = new Map(prev);
for (const r of results) next.set(r.agentId, r.hasMore); for (const r of ok) {
const key = pageKey(r.agentId, category);
next.set(key, {
hasMore: r.hasMore,
fetched: (prev.get(key)?.fetched ?? 0) + r.items.length,
});
}
return next; return next;
}); });
}, },
[projectId], [projectId],
); );
const isLoadedFor = useCallback(
(agentId: string, category: SessionCategory) => pageState.has(pageKey(agentId, category)),
[pageState],
);
const hasMoreFor = useCallback(
(agentId: string, category: SessionCategory) => {
const position = pageState.get(pageKey(agentId, category));
if (position !== undefined) return position.hasMore;
// Unloaded pair: anything the counts report is by definition still unfetched.
return (countsByAgent.get(agentId)?.[category] ?? 0) > 0;
},
[pageState, countsByAgent],
);
/** Keeps an Agent's category totals — overall and per Workspace — in step with a local list mutation of `session` (no-op while its counts are unknown). */
const adjustCount = useCallback(
(session: SessionInfo, category: SessionCategory, delta: number) => {
const { agentId, workspace } = session;
setCountsByAgent((prev) => {
const cur = prev.get(agentId);
if (!cur) return prev;
const next = new Map(prev);
next.set(agentId, { ...cur, [category]: Math.max(0, cur[category] + delta) });
return next;
});
setWorkspaceCountsByAgent((prev) => {
const cur = prev.get(agentId);
if (!cur) return prev;
const ws = cur[workspace] ?? { active: 0, subagent: 0, schedule: 0, archived: 0 };
const next = new Map(prev);
next.set(agentId, {
...cur,
[workspace]: { ...ws, [category]: Math.max(0, ws[category] + delta) },
});
return next;
});
},
[],
);
// User-level event stream (/api/events): a scheduled task firing may have created a new // User-level event stream (/api/events): a scheduled task firing may have created a new
// Session (new-session mode); reload the list so it appears immediately. schedule_queued // Session (new-session mode); reload the list so it appears immediately. schedule_queued
// doesn't change the list (the target Session already exists), so it's ignored. // doesn't change the list (the target Session already exists), so it's ignored.
@@ -176,21 +326,50 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
return () => conn.close(); return () => conn.close();
}, []); }, []);
const add = useCallback((session: SessionInfo) => { const add = useCallback(
// Invalidate any in-flight reload: the newly created entry mustn't be wiped by a stale snapshot. (session: SessionInfo) => {
gen.current += 1; // Invalidate any in-flight reload: the newly created entry mustn't be wiped by a stale snapshot.
setSessions((prev) => [session, ...prev.filter((s) => s.sessionId !== session.sessionId)]); gen.current += 1;
}, []); // Count the row only when the pair's fetched pages provably held its whole category
// (loaded, no more): the row is then genuinely new to the server totals. Otherwise
// (deep-link self-heal of an unfetched row) the counts already include it — a
// possible one-off drift self-heals on the next reload.
const existed = sessionsRef.current.some((s) => s.sessionId === session.sessionId);
if (
!existed &&
pageStateRef.current.get(pageKey(session.agentId, sessionCategory(session)))?.hasMore ===
false
) {
adjustCount(session, sessionCategory(session), 1);
}
setSessions((prev) => [session, ...prev.filter((s) => s.sessionId !== session.sessionId)]);
},
[adjustCount],
);
const remove = useCallback((sessionId: string) => { const remove = useCallback(
// Invalidate any in-flight reload: the deletion mustn't be undone by a stale snapshot. (sessionId: string) => {
gen.current += 1; // Invalidate any in-flight reload: the deletion mustn't be undone by a stale snapshot.
setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId)); gen.current += 1;
}, []); const row = sessionsRef.current.find((s) => s.sessionId === sessionId);
if (row) adjustCount(row, sessionCategory(row), -1);
setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId));
},
[adjustCount],
);
const replace = useCallback((session: SessionInfo) => { const replace = useCallback(
setSessions((prev) => prev.map((s) => (s.sessionId === session.sessionId ? session : s))); (session: SessionInfo) => {
}, []); // An archive toggle moves the row across categories: keep the folder totals in step.
const old = sessionsRef.current.find((s) => s.sessionId === session.sessionId);
if (old && sessionCategory(old) !== sessionCategory(session)) {
adjustCount(session, sessionCategory(old), -1);
adjustCount(session, sessionCategory(session), 1);
}
setSessions((prev) => prev.map((s) => (s.sessionId === session.sessionId ? session : s)));
},
[adjustCount],
);
const setStatus = useCallback((sessionId: string, status: SessionStatus) => { const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
setSessions((prev) => { setSessions((prev) => {
@@ -222,7 +401,10 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
return { return {
sessions, sessions,
byAgent, byAgent,
hasMoreByAgent, countsByAgent,
workspaceCountsByAgent,
isLoadedFor,
hasMoreFor,
loading, loading,
reload, reload,
loadMoreFor, loadMoreFor,
@@ -234,7 +416,10 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
}; };
}, [ }, [
sessions, sessions,
hasMoreByAgent, countsByAgent,
workspaceCountsByAgent,
isLoadedFor,
hasMoreFor,
loading, loading,
reload, reload,
loadMoreFor, loadMoreFor,
+90 -16
View File
@@ -9,15 +9,17 @@
* concatenates per-Agent server responses, so its order isn't globally chronological. * concatenates per-Agent server responses, so its order isn't globally chronological.
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { SessionInfo } from "@prismshadow/penguin-server/api"; import type { SessionCategoryCounts, SessionInfo } from "@prismshadow/penguin-server/api";
import { import {
SIDEBAR_PAGE_SIZE, SIDEBAR_PAGE_SIZE,
TEMP_WORKSPACE_GROUP_KEY, TEMP_WORKSPACE_GROUP_KEY,
groupAgentsWithMore, aggregateWorkspaceCounts,
groupSessionsByWorkspace, groupSessionsByWorkspace,
isTempWorkspace, isTempWorkspace,
latestConversation,
partitionSessions, partitionSessions,
pinnedFirst, pinnedFirst,
sessionCategory,
splitPage, splitPage,
workspaceGroupKey, workspaceGroupKey,
workspaceLabel, workspaceLabel,
@@ -148,6 +150,19 @@ describe("groupSessionsByWorkspace", () => {
}); });
}); });
describe("sessionCategory (the bucket a Session renders under = the server's list filter)", () => {
it("archived wins over source; a source names its bucket; no source is active", () => {
const at = "2026-07-01T10:00:00.000Z";
expect(sessionCategory(session("/srv/a", at))).toBe("active");
expect(sessionCategory(session("/srv/a", at, { source: "subagent" }))).toBe("subagent");
expect(sessionCategory(session("/srv/a", at, { source: "schedule" }))).toBe("schedule");
expect(sessionCategory(session("/srv/a", at, { archived: true }))).toBe("archived");
expect(sessionCategory(session("/srv/a", at, { source: "subagent", archived: true }))).toBe(
"archived",
);
});
});
describe("partitionSessions (per-group user / subagent / scheduled / archived split)", () => { describe("partitionSessions (per-group user / subagent / scheduled / archived split)", () => {
it("splits user rows and one bucket per origin, preserving order within each part", () => { it("splits user rows and one bucket per origin, preserving order within each part", () => {
const user1 = session("/srv/alpha", "2026-07-06T10:00:00.000Z"); const user1 = session("/srv/alpha", "2026-07-06T10:00:00.000Z");
@@ -189,6 +204,31 @@ describe("partitionSessions (per-group user / subagent / scheduled / archived sp
}); });
}); });
describe("latestConversation (the auto-opened 'last conversation')", () => {
it("picks the newest active/schedule row regardless of input order; archived and subagent rows never win", () => {
const oldActive = session("/srv/a", "2026-07-01T10:00:00.000Z");
const newActive = session("/srv/a", "2026-07-03T10:00:00.000Z");
// Newer than every conversation, but never auto-opened:
const newerSub = session("/srv/a", "2026-07-08T10:00:00.000Z", { source: "subagent" });
const newerGone = session("/srv/a", "2026-07-09T10:00:00.000Z", { archived: true });
expect(latestConversation([newerSub, oldActive, newerGone, newActive])).toBe(newActive);
// A schedule-created run is the user's conversation: the newest one qualifies.
const newerSched = session("/srv/a", "2026-07-05T10:00:00.000Z", { source: "schedule" });
expect(latestConversation([newActive, newerSched, newerSub])).toBe(newerSched);
});
it("ties on createdAt break by sessionId, and no qualifying row yields null", () => {
const at = "2026-07-02T10:00:00.000Z";
const a = session("/srv/a", at, { sessionId: "session-a" });
const b = session("/srv/a", at, { sessionId: "session-b" });
expect(latestConversation([a, b])).toBe(b);
expect(latestConversation([b, a])).toBe(b);
expect(latestConversation([])).toBeNull();
expect(latestConversation([session("/srv/a", at, { source: "subagent" })])).toBeNull();
});
});
describe("splitPage (limit+1 fetch trick)", () => { describe("splitPage (limit+1 fetch trick)", () => {
it("an overflow row proves the server has more and is never shown", () => { it("an overflow row proves the server has more and is never shown", () => {
const fetched = [1, 2, 3, 4]; const fetched = [1, 2, 3, 4];
@@ -202,21 +242,55 @@ describe("splitPage (limit+1 fetch trick)", () => {
}); });
}); });
describe("groupAgentsWithMore (workspace groups span Agents)", () => { describe("aggregateWorkspaceCounts (per-group exact server share)", () => {
it("returns each contributing Agent with unfetched pages once; others are skipped", () => { const zero = { active: 0, subagent: 0, schedule: 0, archived: 0 };
const rows = [
session("/srv/alpha", "2026-07-03T10:00:00.000Z", { agentId: "agent_a" }), it("sums each Workspace path across Agents and records which Agents hold each category", () => {
session("/srv/alpha", "2026-07-02T10:00:00.000Z", { agentId: "agent_b" }), const byAgent = new Map<string, Record<string, SessionCategoryCounts>>([
session("/srv/alpha", "2026-07-01T10:00:00.000Z", { agentId: "agent_a" }), [
]; "agent_a",
const hasMore = new Map([ {
["agent_a", true], "/srv/alpha": { ...zero, active: 2, subagent: 1 },
["agent_b", false], "/srv/beta": { ...zero, archived: 3 },
["agent_c", true], // not contributing to this group },
],
["agent_b", { "/srv/alpha": { ...zero, active: 1, schedule: 2 } }],
]); ]);
expect(groupAgentsWithMore(rows, hasMore)).toEqual(["agent_a"]); const groups = aggregateWorkspaceCounts(byAgent);
expect(groupAgentsWithMore(rows, new Map())).toEqual([]); expect(groups.get("/srv/alpha")).toEqual({
expect(groupAgentsWithMore([], hasMore)).toEqual([]); totals: { active: 3, subagent: 1, schedule: 2, archived: 0 },
agents: {
active: ["agent_a", "agent_b"],
subagent: ["agent_a"],
schedule: ["agent_b"],
archived: [],
},
});
expect(groups.get("/srv/beta")).toEqual({
totals: { ...zero, archived: 3 },
agents: { active: [], subagent: [], schedule: [], archived: ["agent_a"] },
});
// A group only in another Workspace never appears — its content can't surface elsewhere.
expect(groups.has("/srv/gamma")).toBe(false);
expect(aggregateWorkspaceCounts(new Map()).size).toBe(0);
});
it("folds every auto-temp path into the merged temp group, deduplicating agents", () => {
const byAgent = new Map([
[
"agent_a",
{
[TEMP_A]: { ...zero, active: 1 },
[TEMP_B]: { ...zero, active: 2, archived: 1 },
},
],
]);
const groups = aggregateWorkspaceCounts(byAgent);
expect(groups.size).toBe(1);
expect(groups.get(TEMP_WORKSPACE_GROUP_KEY)).toEqual({
totals: { ...zero, active: 3, archived: 1 },
agents: { active: ["agent_a"], subagent: [], schedule: [], archived: ["agent_a"] },
});
}); });
}); });