feat(web,server): unify nav naming, panel width and the update entry (#130)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 5 authored and GitHub committed 2026-07-30 17:25:50 +08:00
1 parent 97852f43b8
commit 385d506e77
32 files changed
+1101 -523

No files matched your search

@@ -0,0 +1,23 @@
# Backward compatibility in this batch
Per the repo rule, every compatibility decision of the batch is recorded here once; the feature entries reference this file instead of re-telling it.
## The two per-panel width preferences are adopted into one shared key
The Workspace files panel and the Agents panel each persisted their own dragged width — `penguin.filesPanelWidth` and `penguin.subagentsPanelWidth` in localStorage. They now share a single width under `penguin.panelWidth`, so a stored value has to be carried across or every user who had ever dragged a panel would silently snap back to the default on first load after upgrading.
**Old shape tolerated:** either legacy key. On the first read after upgrading, both are consulted and the **wider** of the two is adopted into the new key, then both legacy keys are deleted. Widest wins because the two panels were sized independently: taking whichever key happened to be read first could hand the merged panel the narrower of the user's two choices, which reads as a regression on whichever panel used to be wide.
**Scope:** browser localStorage only — no server-side data, no config file, no Trace. A user with neither key (or with storage unavailable) gets the proportional default, as before.
**User action:** none. The migration is silent and one-way; nothing needs re-dragging.
**Removal:** the block is `LEGACY_WIDTH_KEYS` and `storedWidth()` in `packages/web/src/features/chat/use-panel-width.ts`. It is self-cleaning — it deletes the legacy keys as it reads them — so it is dead code for anyone who has opened the Web App once since this release. **It should be deleted in the release after next** (i.e. two releases from the one shipping this batch), by whoever prepares that release; the comment at the site says the same. Nothing else in the repo reads those keys, so removing it is a pure deletion with no visible effect. Leaving it longer costs only the dead branch — there is no correctness reason to keep it.
## Panel visibility and the draft page's example layout are not compatibility surfaces
Both changed behavior in this batch, and neither reads persisted state: panel open/closed is in-memory per session (it was never stored), and the example folders' open state is component state that resets on mount. An upgrading user sees the new behavior immediately with nothing to migrate.
## The renamed navigation entries and removed strings need no handling
`nav.railAgents` and `benchmark.maxScore` are deleted from both dictionaries, and several `nav.*` values changed wording. UI copy is compiled into the bundle rather than persisted or referenced by stored data, so there is no old shape to tolerate. Recorded here only to state that the check was made.
@@ -0,0 +1,57 @@
# Web App: one name per navigation entry, one update row, one panel width, and a fixed-height example shelf
A pass over the surfaces you touch on every visit. Navigation entries settle on a single name each, the user menu's three update rows collapse into one, the two docked panels stop behaving like two different panels, Project display names become editable, and the draft page's examples become a fixed-height shelf that can keep growing.
## Navigation entries have one name each
The sidebar, the collapsed rail and the page titles had drifted apart — the rail said 智能体 where the pinned nav said 智能体仓库, and English mixed Costs with Trajectory. Each entry now has exactly one name per locale, used everywhere:
| | |
| --- | --- |
| 新建对话 / New chat | 智能体 / Agents |
| 技能库 / Skills | 模型库 / Models |
| 成本中心 / Cost Center | 轨迹观测 / Trajectories |
| 评估中心 / Evaluation Center | |
The rail-only `nav.railAgents` string is gone, since both dictionaries now word the entry the same way as the pinned nav.
## The sidebar's "New chat" stops colliding with the scrolled list
Scrolling the sidebar slid nav entries right up against the pinned "New chat" button, the two labels touching. The gap below the button was padding *inside* the scroll container, so it belonged to the scrollable content and travelled away with it. It now belongs to the pinned block, which keeps it at every scroll offset — the same text-to-text rhythm two adjacent nav rows have.
## One update row instead of three
At worst the user menu stacked a release-notes link, an admin "Update now" row and a "Check for updates" row on top of each other. There is now a single row with two states: it reads "Check for updates" and runs the manual check until a newer release is known, then names that version with a leading accent dot and opens the update dialog. The superscript badge beside the version number is gone — the label already names the new version.
The dialog absorbed what the rows carried: it now shows the release-notes link, and offers the self-update only to admins. Non-admins see the same version and link, read-only, instead of an action the endpoint would refuse.
## The two docked panels behave as one panel
The Workspace files panel and the Agents panel are mutually exclusive — opening one closes the other — so to the eye they are a single right-hand panel that swaps its content. They now behave like one:
- **One width.** Dragging either panel resizes the other immediately and persists as a single layout preference. A width stored under either previous per-panel key is adopted once, taking the wider of the two so the merged panel never comes out narrower than either panel had been.
- **A wider default**, ~40% of the window rather than ~1/3 (the cap is unchanged at half the window, 720px ceiling). One panel now has to hold a subagent transcript as well as a file tree, and the transcript is the demanding tenant — a third of the window renders it as a narrow column of wrapped tool output.
- **One visibility rule.** An open panel survives switching conversations; starting a **new chat** is the single point that closes both, so a Session created from the draft begins with neither open. This retires the Agents panel's task-scoped auto-close, which had been the one deliberate divergence between the two. The panel keeps its auto-open on the current Task's first live subagent spawn; task boundaries now only re-arm that attempt rather than closing anything.
## The Agents panel says what a subagent was sent to do
The call graph named each child's Agent but never its assignment, so a child transcript opened with no account of its own purpose. Each node in the graph is now two lines: the Agent name with its elapsed time and status, and beneath it the spawning `run_subagent` call's model-written `description`. The sentence is free-form and the box is a fixed size, so it is truncated to a single line with the full text in the node's tooltip. Node height stays uniform — a node without a description (the root, a standalone child, an omitted one) drops the second line and centers the first rather than shrinking, which would drag row placement and edge geometry along with it.
## Project display names are editable
The Project settings dialog showed the display name as static text; renaming meant recreating the Project. The owner can now edit it (`PATCH /api/projects/:projectId`, writing `name` into `project_config.toml` and preserving everything else in the file). The id stays immutable — it names the directory, the Workspace paths and every stored reference — and now sits below the field as a muted caption. Members see the name read-only.
## The draft page's examples become a fixed-height shelf
Three example cards took a fixed slice of the page and left nowhere to add a fourth. They are now two bookmark-style folders, **搭建网页应用 / Build web apps** and **搭建和优化智能体 / Build and optimize agents**, with exactly one open at all times: selecting a folder closes the previous one, and the open folder cannot be collapsed. The block is therefore a constant height — two folder rows plus one folder's rows — so nothing below it shifts as you switch folders, and no scrollbar is ever needed inside a six-line showcase.
Each example is a single-line title now, with its one-sentence description moved into the row tooltip and its icon moved onto the folder row, which is what you actually scan to pick a category.
Two additions to the catalogue:
- **A mini-game center built by multiple agents** — ten games with no two sharing a mechanic, each a single-file `games/<slug>/index.html`, built in parallel by subagents behind one index page.
- **The Claude Code docs RAG agent** example now calls out query/corpus language matching. The docs are English while questions are often Chinese, and with a lexical retriever such as BM25 a Chinese query must be bridged to English first — otherwise not a single term matches and retrieval silently degrades to nothing rather than failing. Its self-test now covers one Chinese and one English question.
## Evaluation Center
The case rows and the statement preview both printed "Max 100". The scale is not the UI's to assert — a benchmark's total is defined by its own scoring rubric — so the label is gone along with its string. "View task" also stops rendering in the accent link color: the row itself is the button, so an accent-colored label inside it read as a second, separately clickable target. It now matches the Workspace download link's quiet gray, with hover feedback left to the row.
+4
View File
@@ -2,6 +2,10 @@
Changes since v0.1.4. The version number is assigned at release, when this folder is renamed.
- [2026-07-30] Backward compatibility: the batch's compat decisions in one place — the two per-panel localStorage width keys adopted into one shared key (widest wins, self-cleaning, with its removal release named), plus the changes checked and found to need no handling. ([details](2026-07-30-backward-compatibility.md))
- [2026-07-30] Web App: navigation entries settle on one name each across the sidebar, the collapsed rail and page titles; the user menu's three update rows collapse into one; the Workspace and Agents panels share a single (wider) width and a single open/closed lifetime, closing only on a new chat; the Agents panel says what each subagent was sent to do; Project display names become editable; and the draft page's examples become a fixed-height, always-one-open folder shelf with a multi-agent game-center example added. ([details](2026-07-30-web-app-refinements.md))
- [2026-07-30] CLI: pasting CJK or emoji into `penguin chat` no longer corrupts a character wherever the terminal split its stdin blocks — each chunk was decoded on its own, so a character torn across two reads became three replacement characters instead of one, and about 1400 Chinese characters was enough to trigger it. ([details](2026-07-30-paste-filter-utf8.md))
- [2026-07-30] Docs: three reference blocks catch up with the code they document — `run_subagent`'s argument block lists the `provider` that `model_id` must be paired with, the provider credential table covers the three gateway groups it had been missing, and the Project model entry table documents `max_tokens`. ([details](2026-07-30-docs-tools-and-configuration-reference.md))
@@ -33,8 +33,19 @@ import { sessionShell } from "./shell.js";
/** Process-group semantics are available on POSIX; Windows falls back to signaling the child process directly. */
const SUPPORTS_PROCESS_GROUP = process.platform !== "win32";
/** Extra wait cap (ms) after the command exits to collect trailing output: enough to drain the last flush, without hanging. */
const POST_EXIT_DRAIN_MS = 50;
/**
* Extra wait cap (ms) after the command exits to collect trailing output: enough to drain the
* last flush, without hanging.
*
* Windows gets a far larger budget. `exit` fires on process termination without waiting for
* pipe EOF (see the listener below), so this window is the only thing standing between a
* fast-exiting command and losing its output — and Git-Bash pipe delivery on Windows routinely
* misses a 50ms window that POSIX pipes never come close to. Symptom when it is too tight: a
* command that ran fine reports empty or truncated output, intermittently and under load. The
* cost of the larger cap is bounded and only paid on Windows, and only when a command exits
* with its pipe still draining: the loop breaks as soon as the buffer goes quiet.
*/
const POST_EXIT_DRAIN_MS = process.platform === "win32" ? 500 : 50;
/** Capacity cap (characters) for a single session's unread output: prevents a chatty background process from blowing up memory. */
const OUTPUT_BUFFER_CAP = 1024 * 1024; // 1 MiB
/**
+27 -3
View File
@@ -130,6 +130,28 @@ async function collectRun(
return all;
}
/**
* Reads a file the shell just wrote, retrying briefly until it holds `expected`.
*
* A tool completes when its shell process exits, which does not promise the write is visible to
* this process yet — on Windows CI it intermittently is not, surfacing as ENOENT or stale
* content. Retrying asserts the same exact content; it only stops the assertion racing the
* filesystem, and a genuinely wrong write still fails one timeout later.
*/
async function readFileEventually(
file: string,
expected: string,
timeoutMs = 2000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let last = "";
for (;;) {
last = await readFile(file, "utf8").catch(() => "");
if (last === expected || Date.now() >= deadline) return last;
await new Promise((r) => setTimeout(r, 25));
}
}
describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
let workspace: string;
let traces: string;
@@ -167,7 +189,9 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
(m) => (m.payload as { type?: string }).type === "tool_call_output",
),
).toBe(true);
expect(await readFile(join(workspace, "hello.txt"), "utf8")).toBe("Hello, Penguin");
expect(await readFileEventually(join(workspace, "hello.txt"), "Hello, Penguin")).toBe(
"Hello, Penguin",
);
const types = collected.map((m) => (m.payload as { type?: string }).type);
expect(types).toContain("tool_call_output");
@@ -988,8 +1012,8 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", (
expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!);
}
expect(await readFile(join(workspace, "a.txt"), "utf8")).toBe("one");
expect(await readFile(join(workspace, "b.txt"), "utf8")).toBe("two");
expect(await readFileEventually(join(workspace, "a.txt"), "one")).toBe("one");
expect(await readFileEventually(join(workspace, "b.txt"), "two")).toBe("two");
// Both tool outputs are fed back into the second turn, producing the final reply.
expect(
all.some(
+24 -3
View File
@@ -45,6 +45,28 @@ async function collect(gen: AsyncGenerator<OmniMessage>): Promise<OmniMessage[]>
return out;
}
/**
* Reads a file the shell just wrote, retrying briefly until it holds `expected`.
*
* The tool completes when the shell process exits, which does not promise the write is visible
* to this process yet — on Windows CI it intermittently is not. Retrying asserts the same exact
* content, it just stops the assertion from racing the filesystem; a genuinely wrong write
* still fails, one timeout later, with the last value read.
*/
async function readFileEventually(
file: string,
expected: string,
timeoutMs = 2000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let last = "";
for (;;) {
last = await readFile(file, "utf8").catch(() => "");
if (last === expected || Date.now() >= deadline) return last;
await new Promise((r) => setTimeout(r, 25));
}
}
function payloadTypes(messages: OmniMessage[]): string[] {
return messages.map((m) => (m.payload as { type?: string }).type ?? "");
}
@@ -152,7 +174,7 @@ describe("Environment.executeTool — basic file write", () => {
expect(outPayload.tool_call_id).toBe("call_write");
expect(outPayload.stop_reason).toBe("completed");
const written = await readFile(path.join(tmp, "note.txt"), "utf8");
const written = await readFileEventually(path.join(tmp, "note.txt"), "Hello, Penguin");
expect(written).toBe("Hello, Penguin");
});
});
@@ -224,8 +246,7 @@ describe("Environment.executeTool — edit file", () => {
}),
);
const written = await readFile(path.join(tmp, "note.txt"), "utf8");
expect(written).toBe("Hello!");
expect(await readFileEventually(path.join(tmp, "note.txt"), "Hello!")).toBe("Hello!");
});
});
+1 -1
View File
@@ -169,7 +169,7 @@ export const zh = {
webCmd: "penguin web # 打开 http://127.0.0.1:7364",
webStep3: "在界面里配置模型,开始对话",
webStep3Desc:
"进入「模型仓库」页,在 DeepSeek 或 OpenRouter 分组里粘贴 API key 并设为默认;回到对话页把第一个任务交给 Agent,例如「分析 data.csv,输出各季度销售额汇总」。",
"进入「模型库」页,在 DeepSeek 或 OpenRouter 分组里粘贴 API key 并设为默认;回到对话页把第一个任务交给 Agent,例如「分析 data.csv,输出各季度销售额汇总」。",
getKeyPrefix: "获取 API key:",
getDeepseekKey: "DeepSeek 控制台",
getOpenrouterKey: "OpenRouter 控制台",
+9
View File
@@ -152,6 +152,15 @@ export interface ProjectCreateResponse {
project: ProjectSummary;
}
export interface ProjectUpdateRequest {
/** New display name. The projectId itself is immutable — only this label can change. */
name: string;
}
export interface ProjectUpdateResponse {
project: ProjectSummary;
}
export interface MemberInfo {
userId: string;
role: ProjectRole;
+26 -3
View File
@@ -1,10 +1,20 @@
/**
* Project routes: GET|POST /api/projects, DELETE /api/projects/:p.
* Project routes: GET|POST /api/projects, PATCH|DELETE /api/projects/:p.
*/
import { Hono } from "hono";
import type { ProjectCreateResponse, ProjectsResponse } from "../../api/types.js";
import type {
ProjectCreateResponse,
ProjectUpdateResponse,
ProjectsResponse,
} from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { optionalString, readJson, requireString, requireValidId } from "../validate.js";
import {
badRequest,
optionalString,
readJson,
requireString,
requireValidId,
} from "../validate.js";
import type { AppDeps } from "../../app.js";
export function projectsRoutes(deps: AppDeps): Hono<AppEnv> {
@@ -23,6 +33,19 @@ export function projectsRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json({ project } satisfies ProjectCreateResponse, 201);
});
/** Rename (owner): the display name only — the id names the directory and is immutable. */
app.patch("/:projectId", async (c) => {
const projectId = requireValidId(c, "projectId");
const body = await readJson(c);
// Trimmed before the length check, so " " is rejected rather than stored as a blank
// display name (minLen alone counts the spaces). The web client trims too; this makes a
// direct API call behave the same.
const name = requireString(body, "name", { maxLen: 100, label: "name" }).trim();
if (name === "") throw badRequest("name must be at least 1 characters.");
const project = await deps.projectService.renameProject(c.var.user.userId, projectId, name);
return c.json({ project } satisfies ProjectUpdateResponse);
});
app.delete("/:projectId", async (c) => {
// Defensive id validation (FD-4): deleteProject constructs the project directory path and recursively deletes it.
await deps.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
@@ -208,6 +208,16 @@ export class ProjectConfigService {
return typeof raw.name === "string" ? raw.name : undefined;
}
/**
* Rewrites the display name, preserving every other field (models, credentials, default
* model): read-modify-write of the same toml, like ensurePresetModels. The id itself is
* immutable — only this label changes.
*/
async setName(projectId: string, name: string): Promise<void> {
const raw = await this.readRaw(projectId);
await this.writeRaw(projectId, { ...raw, name });
}
/** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
async getDefaultModelRef(projectId: string): Promise<ModelRef | undefined> {
const raw = await this.readRaw(projectId);
@@ -244,6 +244,25 @@ export class ProjectService {
});
}
/**
* Rename a Project's display name (owner): the id stays immutable — it names the directory,
* the Workspace paths and every stored reference — so only the label in project_config.toml
* changes. Returns the refreshed summary so the caller can swap it into its list without a
* reload.
*/
async renameProject(userId: string, projectId: string, name: string): Promise<ProjectSummary> {
const row = this.requireProjectOwner(userId, projectId);
await this.deps.projectConfig.setName(projectId, name);
// requireProjectOwner already established the role; it returns the plain row.
return {
projectId,
name,
role: "owner",
ownerUserId: row.ownerUserId,
createdAt: row.createdAt,
};
}
/**
* Delete a Project (owner): default_project is refused; deleting the user's
* **last accessible Project** is refused too (deleting it would leave the list
+116
View File
@@ -0,0 +1,116 @@
/**
* PATCH /api/projects/:projectId — renaming a Project's display name.
*
* The display name is the only mutable field of a Project: the id names the directory, the
* Workspace paths and every stored reference, so it stays immutable and there is no route that
* changes it. These tests pin the three things that could quietly go wrong: who is allowed to
* rename (owner only, with a non-member unable to tell the Project exists), that the write is a
* read-modify-write of project_config.toml rather than a replacement — models and their
* credentials must survive a rename — and that a blank name is refused rather than stored.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import type {
ModelsResponse,
ProjectCreateResponse,
ProjectUpdateResponse,
ProjectsResponse,
} from "../src/api/types.js";
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("project rename", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let member: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let projectId: string;
beforeEach(async () => {
t = await createTestApp();
const a = await provisionUser(t.app, "owner_a");
const b = await provisionUser(t.app, "member_b");
const c = await provisionUser(t.app, "outsider_c");
owner = apiClient(t.app, a.cookie);
member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie);
const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-shared", name: "Before" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
expect(
(await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status,
).toBe(201);
});
afterEach(async () => {
await t.cleanup();
});
it("owner renames: the response, the Project list and the toml all carry the new name", async () => {
const res = await owner.patch(`/api/projects/${projectId}`, { name: "After" });
expect(res.status).toBe(200);
expect(((await res.json()) as ProjectUpdateResponse).project).toMatchObject({
projectId,
name: "After",
role: "owner",
});
const list = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
expect(list.projects.find((p) => p.projectId === projectId)?.name).toBe("After");
const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8");
expect(toml).toContain('name = "After"');
expect(toml).not.toContain("Before");
});
it("keeps models and their credentials — the write is read-modify-write, not a replacement", async () => {
const put = await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }],
});
expect(put.status).toBe(200);
expect((await owner.patch(`/api/projects/${projectId}`, { name: "Renamed" })).status).toBe(200);
const models = (await (
await owner.get(`/api/projects/${projectId}/models`)
).json()) as ModelsResponse;
expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-1" });
expect(models.models).toHaveLength(1);
// The key is masked on read, so assert on the file: a replacing write would have dropped it.
const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8");
expect(toml).toContain("sk-super-secret-key-123456");
expect(toml).toContain('name = "Renamed"');
});
it("owner only: a member gets 403, a non-member 404 (existence is not leaked)", async () => {
expect((await member.patch(`/api/projects/${projectId}`, { name: "Nope" })).status).toBe(403);
expect((await outsider.patch(`/api/projects/${projectId}`, { name: "Nope" })).status).toBe(404);
// Neither refusal wrote anything.
const list = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
expect(list.projects.find((p) => p.projectId === projectId)?.name).toBe("Before");
});
it("rejects a missing, blank or over-long name", async () => {
expect((await owner.patch(`/api/projects/${projectId}`, {})).status).toBe(400);
expect((await owner.patch(`/api/projects/${projectId}`, { name: "" })).status).toBe(400);
// Whitespace only: trimmed to empty rather than stored as a blank display name.
expect((await owner.patch(`/api/projects/${projectId}`, { name: " " })).status).toBe(400);
expect(
(await owner.patch(`/api/projects/${projectId}`, { name: "x".repeat(101) })).status,
).toBe(400);
const list = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
expect(list.projects.find((p) => p.projectId === projectId)?.name).toBe("Before");
});
it("stores the trimmed name", async () => {
expect((await owner.patch(`/api/projects/${projectId}`, { name: " Padded " })).status).toBe(
200,
);
const list = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
expect(list.projects.find((p) => p.projectId === projectId)?.name).toBe("Padded");
});
});
+5 -4
View File
@@ -23,7 +23,8 @@
* chrome used to stop fitting below ~412px;
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
* - the collapsed rail shows, in product-specified order, last conversation / new chat /
* Agents / Skills / Models / Costs / Traces / Benchmark with localized (en + zh) hover
* Agents / Skills / Models / Cost Center / Trajectories / Evaluation Center with localized
* (en + zh) hover
* tooltips; "last conversation" targets the newest non-archived session and is disabled
* while none exists; expanding from the rail restores the pinned sidebar;
* - login page: a single brand penguin logo above the form (part of the form area; the
@@ -289,8 +290,8 @@ test("layout: collapsed rail — order, bilingual tooltips, last conversation",
"Agents",
"Skills",
"Models",
"Costs",
"Trajectory",
"Cost Center",
"Trajectories",
"Evaluation Center",
];
const attrs = (name) =>
@@ -355,7 +356,7 @@ test("layout: collapsed rail — order, bilingual tooltips, last conversation",
"新建对话",
"智能体",
"技能库",
"模型仓库",
"模型库",
"成本中心",
"轨迹观测",
"评估中心",
+9
View File
@@ -40,6 +40,8 @@ import type {
PrefsResponse,
ProjectCreateRequest,
ProjectCreateResponse,
ProjectUpdateRequest,
ProjectUpdateResponse,
ProjectsResponse,
ScheduleItem,
SchedulesResponse,
@@ -114,6 +116,13 @@ export const listProjects = () => apiFetch<ProjectsResponse>("/api/projects");
export const createProject = (body: ProjectCreateRequest) =>
apiFetch<ProjectCreateResponse>("/api/projects", { method: "POST", body });
/** Rename a Project's display name (owner); the id is immutable. */
export const updateProject = (projectId: string, body: ProjectUpdateRequest) =>
apiFetch<ProjectUpdateResponse>(`/api/projects/${encodeURIComponent(projectId)}`, {
method: "PATCH",
body,
});
export const deleteProject = (projectId: string) =>
apiFetch<void>(`/api/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
@@ -1,10 +1,14 @@
/**
* Admin self-update dialog (opened from the sidebar user menu's update reminder):
* explains that the new release is downloaded into the install directory and that the
* service must be restarted afterwards, then runs POST /api/version/update and shows the
* outcome — the CLI's own output tail in a scrollable <pre> for the failed/unsupported
* states, and a restart hint on success. Closing is blocked while the update runs (the
* request can take minutes; navigating away would just hide the result).
* Update dialog, opened from the user menu's single update row once a newer release is known.
* It is the only place the update story is told, so it carries everything the menu used to
* spread across three rows: the new version, the release-notes link, and — for admins only —
* the self-update action. It explains that the release is downloaded into the install
* directory and that the service must be restarted afterwards, then runs
* POST /api/version/update and shows the outcome: the CLI's own output tail in a scrollable
* <pre> for the failed/unsupported states, a restart hint on success. Non-admins get the same
* information plus the link, with no action to run (the endpoint is admin-only anyway).
* Closing is blocked while the update runs (the request can take minutes; navigating away
* would just hide the result).
*/
import { useEffect, useState } from "react";
import type { UpdateRunResponse } from "@prismshadow/penguin-server/api";
@@ -20,10 +24,25 @@ export function UpdateDialog({
open,
onClose,
latestVersion,
releaseUrl = null,
canUpdate,
onRunFinished,
}: {
open: boolean;
onClose: () => void;
latestVersion: string | null;
/** Release page of the new version; omitted/null when the check couldn't resolve one. */
releaseUrl?: string | null;
/** Admin: the self-update action is offered. Otherwise the dialog is read-only. */
canUpdate: boolean;
/**
* Fired once a self-update run has finished (whatever its outcome), when the dialog closes.
* The menu's single update row is the ONLY caller of the update check, and it stops offering
* that check once a newer release is known — so without this the reminder would survive its
* own update: the shared version-info cache never expires within a browser session, leaving
* the row stuck on "New version vX available" with no way back short of a page reload.
*/
onRunFinished?: () => void;
}) {
const [phase, setPhase] = useState<Phase>("confirm");
const [result, setResult] = useState<UpdateRunResponse | null>(null);
@@ -34,6 +53,12 @@ export function UpdateDialog({
setResult(null);
}, [open]);
/** Closing: a finished run invalidates what the reminder row believes, so re-check on the way out. */
const close = () => {
if (phase === "done") onRunFinished?.();
onClose();
};
const run = async () => {
setPhase("running");
try {
@@ -58,14 +83,23 @@ export function UpdateDialog({
return (
<Modal
open={open}
title={S.update.updateNow}
onClose={phase === "running" ? () => undefined : onClose}
/* The title follows the audience: only an admin is here to run an update, and to
everyone else this dialog is the release announcement, so titling it "Update now"
would promise an action its own body then says they cannot take. */
title={
canUpdate
? S.update.updateNow
: latestVersion !== null
? S.update.newVersion(latestVersion)
: S.update.releaseNotes
}
onClose={phase === "running" ? () => undefined : close}
footer={
phase === "done" ? (
<Button onClick={onClose}>{S.common.close}</Button>
phase === "done" || !canUpdate ? (
<Button onClick={close}>{S.common.close}</Button>
) : (
<>
<Button onClick={onClose} disabled={phase === "running"}>
<Button onClick={close} disabled={phase === "running"}>
{S.common.cancel}
</Button>
<Button variant="primary" disabled={phase === "running"} onClick={() => void run()}>
@@ -92,7 +126,22 @@ export function UpdateDialog({
{latestVersion !== null && (
<p className="text-sm font-medium">{S.update.newVersion(latestVersion)}</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.confirmBody}</p>
{/* Release notes: the menu no longer carries this link, so it lives here. */}
{releaseUrl !== null && (
<p>
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.update.releaseNotes}
</a>
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{canUpdate ? S.update.confirmBody : S.update.adminOnly}
</p>
{phase === "running" && (
<p className="text-sm text-gray-500 dark:text-gray-400">{S.update.updating}</p>
)}
@@ -60,9 +60,9 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
navigate(`/chat/${DRAFT_SESSION_ID}`, agentId ? { state: { agentId } } : undefined);
};
/** Page entries (rail positions 3-8): same routes as the pinned nav; Agents uses the rail-specific short label. */
/** Page entries (rail positions 3-8): same routes, same labels as the pinned nav. */
const pages: ReadonlyArray<{ to: string; label: string; icon: string }> = [
{ to: "/agents", label: S.nav.railAgents, icon: NAV_ICONS.agents },
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
{ to: "/models", label: S.nav.models, icon: NAV_ICONS.models },
{ to: "/usage", label: S.nav.usage, icon: NAV_ICONS.usage },
@@ -143,7 +143,10 @@ export function CreateProjectDialog({
);
}
/** Project settings dialog: member management (owner) and deletion (owner); members see a read-only member list. */
/**
* Project settings dialog: display name (owner-editable), member management (owner) and
* deletion (owner); members see the name and member list read-only.
*/
export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { user } = useAuth();
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
@@ -152,23 +155,55 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
// 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);
/** Display-name edit buffer (owner only); saving is explicit, so it stays dirty until Save or reopen. */
const [name, setName] = useState("");
const [nameBusy, setNameBusy] = useState(false);
const [nameError, setNameError] = useState<string | undefined>(undefined);
const projectId = currentProject?.projectId;
const isOwner = currentProject?.role === "owner";
/** The saved display name, with the same id fallback the switcher shows. */
const savedName = currentProject ? projectDisplayName(currentProject) : "";
useEffect(() => {
if (!open || !projectId) return;
setMembers(null);
setLoadError(null);
setConfirmDelete(false);
setName(savedName);
setNameError(undefined);
api
.listMembers(projectId)
.then((res) => setMembers(res.members))
.catch((e: unknown) => setLoadError(apiErrorText(e)));
// savedName is read at open time only: retyping in the field must not be clobbered by a
// list refresh, and reopening the dialog re-seeds it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, projectId]);
if (!currentProject || !projectId) return null;
/**
* Save the display name (owner). The id is immutable, so this is the only editable field of
* the Project itself. Success needs no toast: the switcher, this dialog's field and every
* Project list re-render with the new name once reloadProjects settles (#54, one notification
* per action) — only failures pop one, and the field keeps what was typed so it can be retried.
*/
const saveName = async () => {
const next = name.trim();
if (!next || next === savedName || nameBusy) return;
setNameBusy(true);
setNameError(undefined);
try {
await api.updateProject(projectId, { name: next });
await reloadProjects();
} catch (e) {
setNameError(apiErrorText(e));
} finally {
setNameBusy(false);
}
};
const addMember = async () => {
if (!newMemberId.trim()) return;
try {
@@ -206,12 +241,45 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
return (
<Modal open={open} title={S.project.settingsTitle} onClose={onClose}>
<div className="space-y-4">
{/* Display name: the Project's only editable field (the id names the directory and every
stored reference, so it stays immutable and sits below as a muted mono caption).
Members see the resolved name as plain text. */}
<div>
{isOwner ? (
<>
<FieldLabel>{S.project.displayName}</FieldLabel>
<div className="flex items-stretch gap-2">
<Input
size="sm"
className="min-w-0 flex-1"
value={name}
invalid={Boolean(nameError)}
maxLength={100}
onChange={(e) => {
setName(e.target.value);
if (nameError) setNameError(undefined);
}}
onKeyDown={(e) => {
if (e.key === "Enter") void saveName();
}}
/>
<Button
size="sm"
disabled={nameBusy || !name.trim() || name.trim() === savedName}
onClick={() => void saveName()}
>
{S.common.save}
</Button>
</div>
{nameError !== undefined && <FieldError>{nameError}</FieldError>}
</>
) : (
<>
<p className="mb-1 text-xs font-medium text-gray-500">{S.project.switcher}</p>
<p className="text-sm">
{projectDisplayName(currentProject)}{" "}
<span className="font-mono text-xs text-gray-400">{projectId}</span>
</p>
<p className="text-sm">{savedName}</p>
</>
)}
<p className="mt-1 font-mono text-xs text-gray-400">{projectId}</p>
</div>
<div>
+59 -67
View File
@@ -117,14 +117,6 @@ const PIN_ICON =
const menuItemClass =
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
/**
* Superscript "new version" pill on the version line (accent-colored, raised via
* align-super). Kept literally identical to the draft page's copy in
* features/chat/draft-view.tsx — the two surfaces must not drift apart.
*/
const versionBadgeClass =
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
/** Grouping mode of the Session list (persisted; Workspace is the default). */
type GroupMode = "workspace" | "agent";
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
@@ -235,6 +227,13 @@ export function Sidebar({
// Version row + update reminder: nothing is fetched until the dropdown first opens.
const { version, update } = useVersionInfo(userOpen);
const updateAvailable = update?.updateAvailable === true;
/**
* The newer release's version string, or null while none is known — the single update row's
* whole state machine. A resolved version is required, not just the boolean: the row's label
* names it, so a would-be "available but unnamed" result stays on the check action rather
* than rendering a versionless reminder.
*/
const newVersion = updateAvailable ? (update?.latestVersion ?? null) : null;
// The running version's release date, stamped into core's BUILD_DATE at build time by
// the release workflow — displayed as-is, no network involved. Dev builds and releases
// that predate the stamping (v0.1.2 and earlier) carry null. Shown as the localized
@@ -776,8 +775,13 @@ export function Sidebar({
{/* New chat: the only pinned entry besides the Project switcher above and the user row
below. No background fill, the same gray hover/active styling as the nav items,
distinguished only by its position and font-medium; shows the same gray active state
while on the draft page. */}
<div className="shrink-0 px-2 pt-2">
while on the draft page.
The gap to the scroll area below is this block's OWN pb-2, not padding inside the
scroller: padding-top there belongs to the scrollable content and slides away with
it, so a scrolled nav entry ended up flush against this pinned button, the two
labels touching. Outside the scroller the 8px stays put at every scroll offset —
the same text-to-text rhythm two adjacent nav rows have. */}
<div className="shrink-0 px-2 pb-2 pt-2">
<button
type="button"
onClick={() => newChat(defaultAgentId)}
@@ -805,7 +809,7 @@ export function Sidebar({
overflow-y-auto and stretch the **document**, so expanding "More" / a source
folder made the whole page scroll (composer pushed up, blank space below). */}
<div className="relative min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<nav className="space-y-0.5 pt-2">
<nav className="space-y-0.5">
{navItems.map((item) => (
<NavLink
key={item.to}
@@ -1055,41 +1059,6 @@ export function Sidebar({
<Segmented options={langOptions} value={lang} onChange={setLang} />
</SettingRow>
</div>
{/* Update reminder: release-notes link, plus the self-update action for admins.
Only rendered after the lazy check found a newer release. */}
{update !== null && update.updateAvailable && update.latestVersion !== null && (
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
{update.releaseUrl !== null ? (
<a
href={update.releaseUrl}
target="_blank"
rel="noopener noreferrer"
title={S.update.releaseNotes}
className={`${menuItemClass} flex items-center gap-2 font-medium`}
>
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
{S.update.newVersion(update.latestVersion)}
</a>
) : (
<span className={`${menuItemClass} flex items-center gap-2 font-medium`}>
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
{S.update.newVersion(update.latestVersion)}
</span>
)}
{user?.isAdmin && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
setUpdateDialogOpen(true);
}}
>
{S.update.updateNow}
</button>
)}
</div>
)}
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
type="button"
@@ -1101,37 +1070,52 @@ export function Sidebar({
>
{S.account.changePassword}
</button>
{/* Manual update check, directly below Change password (owner layout). The
running version sits muted on the right of the same row — no product-name
prefix — and the superscript new-version badge rides along there as a
passive indicator: a nested button/link inside this button row would be
invalid HTML, and whenever the badge shows, the clickable affordances
(release link / Update now) are already present in the reminder rows
above. The "last updated" date lives in the row tooltip, keeping the row
itself uncluttered. While checking, the label swaps to the busy text and
the right-side version stays put. Nothing is fetched until the menu first
opens; the version span appears once /api/version resolves. */}
{/* THE update row — one button, two jobs, directly below Change password (owner
layout: the menu used to stack a release-notes link, an admin "Update now" row
and this check row on top of each other). It reads "Check for updates" and runs
the manual check until a newer release is known; from then on it reads "New
version vX available" with a leading accent dot and opens the update dialog
instead, which carries the release-notes link and the admin-only self-update.
The running version sits muted on the right — no product-name prefix, and no
superscript badge any more: the label itself already names the new version.
The "last updated" date lives in the row tooltip, keeping the row uncluttered.
While checking, the label swaps to the busy text and the version stays put.
Nothing is fetched until the menu first opens; the version span appears once
/api/version resolves. */}
<button
type="button"
disabled={updateChecking}
onClick={() => void runUpdateCheck()}
onClick={() => {
if (newVersion !== null) {
setUserOpen(false);
setUpdateDialogOpen(true);
} else {
void runUpdateCheck();
}
}}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span>{updateChecking ? S.update.checking : S.update.checkNow}</span>
<span className="flex min-w-0 items-center gap-2">
{newVersion !== null && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
/>
)}
<span className="min-w-0 truncate">
{updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
</span>
</span>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
{update !== null && update.updateAvailable && update.latestVersion !== null && (
<span
className={versionBadgeClass}
title={S.update.newVersion(update.latestVersion)}
>
{S.update.newVersionBadge}
</span>
)}
</span>
)}
</button>
@@ -1169,7 +1153,15 @@ export function Sidebar({
<UpdateDialog
open={updateDialogOpen}
onClose={() => setUpdateDialogOpen(false)}
latestVersion={update?.latestVersion ?? null}
latestVersion={newVersion}
releaseUrl={update?.releaseUrl ?? null}
canUpdate={user?.isAdmin === true}
/* A finished self-update makes the reminder stale, and the row stops offering the
manual check while a newer release is known — so re-check here, or the row would
still read "New version vX available" after updating to exactly that version, with
no way back short of reloading the page. Silent: the row's own change is the
feedback, and a toast would fire while the user is closing the dialog. */
onRunFinished={() => void forceUpdateCheck().catch(() => undefined)}
/>
<CreateProjectDialog
@@ -500,10 +500,10 @@ function CasesSection({
{item.id}
</span>
</span>
<span className="shrink-0 font-mono text-xs text-gray-500">
{S.benchmark.maxScore("100")}
</span>
<span className="shrink-0 text-xs text-brand-700 dark:text-brand-300">
{/* Styled as the quiet gray action the Workspace download link is, not as a
link: the row itself is the button, so an accent-colored label here read as
a second, separately clickable target. Hover feedback comes from the row. */}
<span className="shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-300">
{S.benchmark.viewCase}
</span>
</button>
@@ -304,7 +304,6 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
{preview?.path ?? caseSummary.id}
</p>
</div>
<span className="shrink-0 text-xs text-gray-400">{S.benchmark.maxScore("100")}</span>
{downloadUrl && preview && (
<a
href={downloadUrl}
@@ -53,21 +53,28 @@ export function AgentTopologyView({
const label = labelFor(node);
const selected = node.sessionId === selectedId;
const stateLabel = node.running ? S.subagentPanel.nodeRunning : S.subagentPanel.nodeDone;
// The node renders the description truncated on its second line; the tooltip carries
// the full sentence, which is the only place it appears untruncated.
const tooltip =
node.description !== null
? `${label} · ${stateLabel}\n${node.description}`
: `${label} · ${stateLabel}`;
return (
<button
key={node.sessionId}
type="button"
aria-label={`${label} · ${stateLabel}`}
aria-pressed={selected}
title={`${label} · ${stateLabel}`}
title={tooltip}
onClick={() => onSelect(node)}
style={{ left: x, top: y, width: NODE_W, height: NODE_H }}
className={`absolute flex items-center gap-1.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
className={`absolute flex flex-col justify-center gap-0.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
selected
? "border-brand-500 ring-1 ring-brand-500"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800/60"
}`}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<AgentAvatar id={node.agentId ?? node.sessionId} name={label} size={18} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
@@ -88,6 +95,17 @@ export function AgentTopologyView({
)}
{/* Status is already part of the button's accessible name: keep the glyph decorative. */}
<StatusIcon state={node.running ? "running" : "done"} size={10} />
</span>
{/* Second line: what this child was spawned to do. Indented to the label's own
left edge (avatar width + gap) and truncated to one line — the model writes a
free-form sentence into a fixed-size box, and the full text is in the tooltip.
Nodes without one (the root, a standalone child, an omitted description) drop
the line and the single row centers itself in the box instead. */}
{node.description !== null && (
<span className="w-full truncate pl-[24px] text-[10px] leading-tight text-gray-400 dark:text-gray-500">
{node.description}
</span>
)}
</button>
);
})}
@@ -25,6 +25,8 @@ export interface TopologyNode {
sessionId: string;
/** Agent running this node: the child's session_meta capture first, else the run_subagent `agent_id` argument; null when unknown (root: filled by the view from the Session DTO). */
agentId: string | null;
/** The spawning call's model-written `description` — what this child was asked to do; null for the root, a standalone item, or when the model omitted it. */
description: string | null;
/** Still running — the spawning card's output hasn't completed (root: the Task's own running state; a standalone item has no card, so it reads as done). */
running: boolean;
/** 0 = the main session; +1 per spawn hop. */
@@ -73,20 +75,36 @@ export function taskStartCount(items: readonly ChatItem[]): number {
return n;
}
/** Lenient `agent_id` extraction from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
/** Lenient string-field read from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
function runSubagentArg(argsJson: string, field: string): string | null {
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed !== null && typeof parsed === "object") {
const id = (parsed as Record<string, unknown>)["agent_id"];
if (typeof id === "string" && id.length > 0) return id;
const value = (parsed as Record<string, unknown>)[field];
if (typeof value === "string" && value.length > 0) return value;
}
} catch {
// Arguments still streaming or malformed: no agent id to offer.
// Arguments still streaming or malformed: nothing to offer.
}
return null;
}
/** Lenient `agent_id` extraction from run_subagent arguments. */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
return runSubagentArg(argsJson, "agent_id");
}
/**
* The model-written `description` argument of run_subagent — one sentence saying what this
* child was spawned to do. It is the only human-readable statement of a subagent's purpose
* (the agent name says who, not what), so the graph renders it as each node's second line and
* repeats it untruncated in the node tooltip. Optional: a model may omit it, and
* `call_description: false` removes the property from the schema entirely — both read as null.
*/
export function descriptionFromRunSubagentArgs(argsJson: string): string | null {
return runSubagentArg(argsJson, "description");
}
/** Extract the latest Task's spawn tree: root first, then children in DFS preorder (document order). */
export function extractTopology(
model: StreamModel,
@@ -154,6 +172,8 @@ function extractFromSlice(
{
sessionId: rootSessionId,
agentId: null,
// The root was not spawned by anyone, so there is no spawning call to describe.
description: null,
running: root.running,
depth: 0,
origin: [],
@@ -169,6 +189,7 @@ function extractFromSlice(
parentId: string,
parentOrigin: string[],
argsAgentId: string | null,
argsDescription: string | null,
): void => {
if (seen.has(sessionId)) return;
seen.add(sessionId);
@@ -185,6 +206,7 @@ function extractFromSlice(
nodes.push({
sessionId,
agentId: child.meta?.agentId ?? argsAgentId,
description: argsDescription,
running,
depth: origin.length,
origin,
@@ -206,9 +228,11 @@ function extractFromSlice(
parentId,
parentOrigin,
agentIdFromRunSubagentArgs(item.argumentsText),
descriptionFromRunSubagentArgs(item.argumentsText),
);
} else if (item.kind === "subagent") {
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null);
// A standalone child has no spawning card in this stream, so neither argument is available.
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null, null);
}
}
};
@@ -251,8 +275,12 @@ export function modelAtOrigin(model: StreamModel, origin: readonly string[]): St
// ---------------------------------------------------------------------------
/** Fixed node box (avatar + truncated name + elapsed time + status glyph) — no text measurement, so the layout stays pure. */
export const NODE_W = 168;
export const NODE_H = 34;
// Node box: two stacked lines — the Agent name (with elapsed + status) over the spawning
// call's description. Sized uniformly rather than per-node: variable heights would have to be
// threaded through row placement and edge geometry below, for the sake of a few nodes that
// carry no description (the root never does) and simply center their single line instead.
export const NODE_W = 200;
export const NODE_H = 46;
export const GAP_X = 32;
export const GAP_Y = 10;
export const PAD = 6;
+18 -9
View File
@@ -313,12 +313,23 @@ export function ChatPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
// TASK-SCOPED panel visibility (owner rule: an open panel belongs to the task it was opened
// for). The pure tracker (advancePanelTaskScope, unit-tested) decides at each observation:
// - entering a session / a NEW Task starting (a user message — taskStartCount increase)
// closes the panel by default, so an unrelated task never inherits it;
// - the CURRENT task's first live spawn auto-opens it (re-armed per task; a manual close
// afterwards is respected until the next boundary).
// A NEW chat starts with both panels closed: a panel opened for an earlier conversation must
// not carry into a freshly created one. The draft is the reset point — it renders no panels
// itself, so the Session created from it (first send navigates to /chat/:id) begins closed,
// while a plain conversation switch keeps whatever the user had open. This effect owns the
// ONLY automatic close of either panel.
useEffect(() => {
if (!draft) return;
filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft]);
// Subagents panel AUTO-OPEN (the one visibility rule this panel has beyond the Files panel's):
// the pure tracker (advancePanelTaskScope, unit-tested) opens it on the CURRENT task's first
// live spawn, re-armed at every task boundary so a manual close is respected until the next
// one. Boundaries themselves no longer close anything — an open panel now survives Session
// switches and new Tasks alike, matching the Files panel.
// The auto-open applies only when docked (a mobile Sheet sliding over the conversation
// uninvited would be worse than staying discoverable via the row), never over an open Files
// panel (an automatic open must not steal an explicit one — the row and the toolbar's amber
@@ -333,9 +344,7 @@ export function ChatPage() {
taskCount,
liveSpawn,
});
if (action === "close") {
subagentsPanelRaw.setOpen(false);
} else if (
if (
action === "autoOpen" &&
subagentsPanelRaw.isDocked &&
!subagentsPanelRaw.open &&
+100 -114
View File
@@ -55,7 +55,8 @@ import { toastError } from "../../components/ui/toast";
import { useVersionInfo } from "../../lib/use-version-info";
import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { EXAMPLE_TASKS, type ExampleTask, type ExampleTaskId } from "./example-tasks";
import { EXAMPLE_FOLDERS } from "./example-tasks";
import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
import { sameModelRef } from "../models/model-grouping";
@@ -89,6 +90,24 @@ function saveAppliedRouteKey(field: RouteStateField, key: string): void {
}
}
/**
* One glyph per example folder, 16×16. Icons live on the folder rather than on each example:
* with the examples reduced to single-line titles, a column of per-row icons was noise
* competing with the titles, while the folder row is exactly where a glyph earns its place —
* it is what you scan to pick a category.
*
* webapps: a browser window (chrome bar + two dots). agents: the SAME robot head the sidebar's
* Agents entry uses (NAV_ICONS.agents) — deliberately not a generic refresh loop, because the
* app already has one glyph that means "agent" and a folder of agent examples should wear it.
* Duplicated as a literal rather than imported: sidebar.tsx imports from chat-page.tsx, which
* renders this file, so importing it back would close an import cycle.
*/
const FOLDER_GLYPHS: Record<ExampleFolderId, string> = {
webapps:
"M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6zM3 9h18M6 6.5h.01M9 6.5h.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",
};
export function DraftView({
projectId,
models,
@@ -422,6 +441,15 @@ export function DraftView({
[exampleBusy, agentSkills, onSend],
);
/**
* The open example folder — bookmark-style, and ALWAYS exactly one: selecting another closes
* the previous, and clicking the open one is a no-op rather than collapsing it. Never
* nullable on purpose. With every folder the same length, "one open" is what makes the
* block's height a constant: the examples area can neither collapse to bare folder rows nor
* grow, so nothing below it shifts as folders are switched.
*/
const [openFolder, setOpenFolder] = useState<ExampleFolderId>(EXAMPLE_FOLDERS[0].id);
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
@@ -490,131 +518,89 @@ export function DraftView({
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
</div>
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow,
stacked vertically in display order on every viewport. Disabled until
agents/models/skills are resolved (onSend would silently no-op without an Agent);
hover only darkens the border, per the card convention. */}
<div className="mt-6 flex flex-col items-stretch gap-2">
{EXAMPLE_TASKS.map((task) => {
const copy = S.chat.exampleTasks[task.id];
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow.
Bookmark-style folders with ALWAYS exactly one open — selecting another closes the
previous, and the open one cannot be collapsed. The block is therefore a FIXED
height: two folder rows plus one folder's rows, whichever folder that is (they are
kept the same length). Nothing below shifts when folders are switched, and no
scroll container is needed — a scrollbar inside a six-line showcase reads as a
defect. Each example is a single-line title; its one-sentence description rides in
the row tooltip rather than a second line. Rows are disabled until
agents/models/skills are resolved (onSend would silently no-op without an Agent). */}
<div className="mt-6 space-y-1">
{EXAMPLE_FOLDERS.map((folder) => {
const open = folder.id === openFolder;
return (
<div key={folder.id}>
{/* A tab, not a disclosure: the open folder stays open (clicking it is a
no-op) and carries the selected fill, so the block always shows one
folder's examples and its height never changes. */}
<button
key={task.id}
type="button"
disabled={exampleBusy !== null || sending || !skillsLoaded || !agentId || !models}
onClick={() => void runExample(task)}
className="group flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition-colors duration-150 hover:border-gray-300 disabled:cursor-default disabled:opacity-60 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
aria-expanded={open}
onClick={() => setOpenFolder(folder.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors duration-150 ${
open
? "bg-gray-100 dark:bg-gray-800/70"
: "hover:bg-gray-100 dark:hover:bg-gray-800/70"
}`}
>
{/* 24×24 line icons (gamepad / music note / sparkle), consistent with the icon convention */}
{task.id === "agentBenchmarkBuild" || task.id === "agentOptimization" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M7 7h8a4 4 0 0 1 4 4v1M17 5l2 2-2 2M17 17H9a4 4 0 0 1-4-4v-1M7 19l-2-2 2-2"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="2" strokeWidth="1.7" />
</svg>
) : task.id === "lol" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M9 18V6l11-2v12"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="6.5" cy="18" r="2.5" strokeWidth="1.7" />
<circle cx="17.5" cy="16" r="2.5" strokeWidth="1.7" />
</svg>
) : task.id === "game" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M6.7 6h10.6a4 4 0 0 1 3.97 3.56c.2 1.8.73 5.05.73 6.44a3 3 0 0 1-3 3c-1 0-1.5-.5-2-1l-1.4-1.4a2 2 0 0 0-1.42-.6H9.82a2 2 0 0 0-1.41.6L7 18c-.5.5-1 1-2 1a3 3 0 0 1-3-3c0-1.39.52-4.64.73-6.44A4 4 0 0 1 6.7 6z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M6.5 11h4M8.5 9v4M15 12h.01M18 10h.01"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M18.5 15.5l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1z"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{copy.label}
</span>
<span className="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{copy.desc}
</span>
</span>
{exampleBusy === task.id ? (
<span className="ml-1 shrink-0 text-xs text-gray-400">{S.common.loading}</span>
) : (
<span className="shrink-0 text-brand-500 dark:text-brand-400">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="ml-1 shrink-0 text-gray-300 transition-colors duration-150 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-400"
aria-hidden
>
<path
d="M5 12h14M13 6l6 6-6 6"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
aria-hidden
>
<path d={FOLDER_GLYPHS[folder.id]} />
</svg>
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-700 dark:text-gray-300">
{S.chat.exampleFolders[folder.id]}
</span>
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{folder.tasks.length}
</span>
<Chevron open={open} size={14} className="text-gray-400" />
</button>
{open && (
<ul className="mt-0.5 space-y-0.5 pl-4">
{folder.tasks.map((task) => {
const copy = S.chat.exampleTasks[task.id];
return (
<li key={task.id}>
<button
type="button"
title={copy.desc}
disabled={
exampleBusy !== null ||
sending ||
!skillsLoaded ||
!agentId ||
!models
}
onClick={() => void runExample(task)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
>
<span className="min-w-0 flex-1 truncate">{copy.label}</span>
{exampleBusy === task.id && (
<span className="shrink-0 text-xs text-gray-400">
{S.common.loading}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
@@ -628,8 +614,8 @@ export function DraftView({
/**
* Superscript "new version" pill on the version line (accent-colored, raised via
* align-super). Kept literally identical to the sidebar footer's copy in
* components/layout/sidebar.tsx — the two surfaces must not drift apart.
* align-super). The only remaining copy: the sidebar's version row dropped its badge when
* the three update rows collapsed into one whose label already names the new version.
*/
const versionBadgeClass =
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
@@ -642,8 +628,8 @@ const versionBadgeClass =
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
* dev builds and releases that predate the stamping (v0.1.2 and earlier) carry null
* and show the version alone. When the update check knows a newer release, a small
* superscript badge follows, linking to the release page (this surface's existing
* affordance; the sidebar's badge additionally offers admins the update dialog).
* superscript badge follows, linking to the release page (this surface's affordance; the
* sidebar user menu instead routes its single update row into the update dialog).
* Fetching starts on mount — useVersionInfo caches at module level, so after the first
* resolution anywhere in the app this renders instantly and never refetches. Nothing
* renders until the version resolves (no placeholder flicker under the brand).
@@ -1,17 +1,47 @@
/**
* Draft-screen example cards in display order.
* Draft-screen example cards, filed into collapsible folders in display order.
*
* Copy and full prompts live in the active locale dictionary at
* `S.chat.exampleTasks[id]`. Skills listed here are pinned only when the
* selected Agent has them installed; an empty list sends the prompt unchanged.
* Folders are what lets the showcase grow past a flat list: exactly ONE folder is open at a
* time (bookmark-style — opening one closes the other), so the block's height is one row per
* folder plus the open folder's own rows, never the whole catalog. Adding an example means
* appending it to the folder it belongs to, not lengthening the page.
*
* Keep the folders similarly sized: the draft page reserves no scroll area for this block, so
* a folder much longer than its siblings is what would make the height jump between them.
*
* Copy and full prompts live in the active locale dictionary at `S.chat.exampleFolders[id]`
* and `S.chat.exampleTasks[id]`. Skills listed here are pinned only when the selected Agent has
* them installed; an empty list sends the prompt unchanged.
*/
export const EXAMPLE_TASKS = [
export const EXAMPLE_FOLDERS = [
{
id: "webapps",
tasks: [
{ id: "game", skills: ["web-design"] },
{ id: "gamecenter", skills: ["web-design"] },
{ id: "lol", skills: ["web-design"] },
],
},
{
id: "agents",
tasks: [
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
{ id: "agentBenchmarkBuild", skills: [] },
{ id: "agentOptimization", skills: [] },
],
},
] as const;
export type ExampleTask = (typeof EXAMPLE_TASKS)[number];
export type ExampleFolder = (typeof EXAMPLE_FOLDERS)[number];
export type ExampleFolderId = ExampleFolder["id"];
export type ExampleTask = ExampleFolder["tasks"][number];
export type ExampleTaskId = ExampleTask["id"];
/**
* Flat view of every example across folders, in display order. The folders drive the UI; this
* is for whole-catalog work that doesn't care which folder an example sits in — looking one up
* by id, or asserting across all of them.
*/
export const EXAMPLE_TASKS: readonly ExampleTask[] = EXAMPLE_FOLDERS.flatMap((folder) => [
...folder.tasks,
]);
@@ -164,7 +164,8 @@ export function SubagentsView({
</div>
) : (
<>
{/* Slim identity strip for the conversation below. */}
{/* Slim identity strip for the conversation below. The spawning call's description
belongs to its node in the graph above, not here — one line, one place. */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-100 px-3 py-1.5 dark:border-gray-800/60">
<AgentAvatar
id={activeModel.meta?.agentId ?? active.sessionId}
+15 -108
View File
@@ -1,7 +1,8 @@
/**
* Files panel state machine: panel open/close, drag-to-resize, the responsive breakpoint for
* desktop docking vs. falling back to a mobile drawer, and the "locate a file in the directory
* tree" navigation command (driven by clicking a file chip inside a message).
* Files panel state machine: panel open/close, the responsive breakpoint for desktop docking
* vs. falling back to a mobile drawer, and the "locate a file in the directory tree" navigation
* command (driven by clicking a file chip inside a message). Drag-to-resize and the width live
* in use-panel-width.ts, shared with the Agents panel so the two open at the same width.
*
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
* structured file-write signal at all (file writes can happen inside the opaque exec_command shell),
@@ -9,18 +10,17 @@
* locating it in the tree.
*
* Only the navigation command resets when sessionId changes; the open/closed state persists
* across sessions (once opened, it stays open); width is a layout preference, not session data,
* so it isn't reset, and it's persisted to localStorage (same as app-layout.tsx's
* sidebarCollapsed). Both the default width and the cap scale proportionally with the window
* width (matching the ~1/3-of-window feel of Codex's Review panel); a dragged preference takes
* priority over the proportional default; double-clicking the handle clears the preference and
* reverts to the proportional default.
* across Sessions (once opened, it stays open — a panel the user opened is part of their
* browsing environment, and switching conversations shouldn't collapse it). Starting a NEW chat
* is the one reset point, and it belongs to the chat page, which owns both panels: see the
* draft effect in chat-page.tsx.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { useCallback, useEffect, useState } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
import { usePanelWidth } from "./use-panel-width";
import type { PanelWidthState } from "./use-panel-width";
export interface FilesPanelState {
export interface FilesPanelState extends PanelWidthState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet (half = browsing / full = preview); unused in the desktop docked state. */
@@ -36,35 +36,9 @@ export interface FilesPanelState {
* as the window width changes — docked and Drawer are mounted mutually exclusively; mounting
* both at once would cause WorkspaceBrowser's own data requests to fire twice. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default, and the stored preference is cleared. */
resetWidth: () => void;
/** Ref to the docked panel's root node: drag-to-resize uses its right edge to compute the target width. */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.filesPanelWidth";
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window (matching Codex's Review panel proportion), clamped within the min/max bounds. */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds, to prevent an oversized value carried over from another device) takes priority over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useFilesPanel(sessionId: string | null): FilesPanelState {
const [open, setOpenRaw] = useState(false);
@@ -78,16 +52,11 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
if (next) setSheetSnap("half");
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** A synchronous mirror of width: read by the mouseup persist step, sidestepping the stale state captured in the event closure. */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const widthState = usePanelWidth();
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session/Agent only resets the navigation command (which pointed at a file in the
// old session); the open/closed state persists across sessions — a workspace panel the user
// opened is part of their browsing environment, and switching sessions shouldn't collapse it.
// old session); the open/closed state deliberately survives — see the header note.
useEffect(() => {
setOpenRequest(null);
}, [sessionId]);
@@ -104,64 +73,6 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
setOpenRequest({ path });
}, []);
// Drag-to-resize: during mousemove, computes the width from the panel's right edge and clamps
// it within the min/max bounds; locks the cursor/selection during the drag to avoid
// accidentally selecting page text on a fast drag (standard, necessary handling for
// drag-to-resize, not something to skip just because there's no prior precedent here).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
// Only persist the preference once the drag ends: mousemove fires every frame, and it's not worth writing to localStorage on every frame.
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clears rather than writing the default value: this way the default keeps following the window's proportion going forward, instead of being frozen at the current pixel value.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap to prevent the docked panel
// from crowding out the chat column. Only shrinks, never grows back: this doesn't overwrite
// the stored preference; enlarging the window again relies on a refresh or double-clicking the handle to restore it.
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
@@ -170,10 +81,6 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
browsePath,
openRequest,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
...widthState,
};
}
@@ -0,0 +1,177 @@
/**
* Width shared by the chat page's two docked panels (Workspace files and Agents), plus the
* drag-to-resize machinery both of them mount.
*
* The panels are MUTUALLY EXCLUSIVE — opening one closes the other — so to the user they read
* as a single right-hand panel that swaps its content. Two independent widths made that swap
* jump, which is why the width is one value here rather than a per-panel preference: a width
* dragged on either panel is the width the other one opens at, immediately and after a reload.
*
* "Immediately" is what rules out two `useState`s over one storage key: only the panel that was
* mounted and dragged would update, and the other would keep a stale copy until its next
* remount. So the value lives in a module-level store both hooks subscribe to via
* useSyncExternalStore, and the persisted preference is written once per drag (mouseup), not
* per frame.
*
* Width is a layout preference, not session data: it is never reset on a Session switch.
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
const MIN_WIDTH = 320;
const WIDTH_STORAGE_KEY = "penguin.panelWidth";
/**
* Pre-unification per-panel keys, adopted once on first load so a dragged width survives the
* merge instead of silently snapping back to the default. The WIDER of the two wins: the two
* panels were sized independently, so picking either one arbitrarily could hand the merged
* panel the narrower of the user's two choices — visibly a regression on the panel that used
* to be wide. The migration deletes the keys as it reads, so it is already dead code for anyone
* who has opened the app once since this shipped.
*
* REMOVAL: delete this constant and storedWidth()'s legacy branch in the release AFTER the one
* shipping this change, as part of preparing that release. Nothing else in the repo reads these
* keys, so it is a pure deletion. See changelog/unreleased/2026-07-30-backward-compatibility.md.
*/
const LEGACY_WIDTH_KEYS = ["penguin.filesPanelWidth", "penguin.subagentsPanelWidth"] as const;
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
export function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/**
* Default width ≈ 40% of the window, clamped within the min/max bounds. Deliberately wider than
* the old ~1/3: one panel now serves both the file tree and the subagent transcript, and the
* transcript is the demanding tenant — a third of the window renders it as a narrow column of
* wrapped tool output.
*/
export function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.4)));
}
/** Reads the stored preference, migrating a legacy per-panel key the first time. Returns null when nothing is stored. */
function storedWidth(): number | null {
try {
const own = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (Number.isFinite(own) && own > 0) return own;
let adopted: number | null = null;
for (const key of LEGACY_WIDTH_KEYS) {
const legacy = Number(localStorage.getItem(key));
// Widest wins — see LEGACY_WIDTH_KEYS: the merged panel must not come out narrower than
// either panel the user had sized.
if (Number.isFinite(legacy) && legacy > 0) adopted = Math.max(adopted ?? 0, legacy);
localStorage.removeItem(key);
}
if (adopted !== null) localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(adopted)));
return adopted;
} catch {
return null; // quota / private mode: fall back to the proportional default
}
}
/** Initial width: the stored preference (clamped back within this window's bounds, so an oversized value carried over from another device can't crowd out the chat column) over the proportional default. */
function initialWidth(): number {
const stored = storedWidth();
if (stored === null) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
// —— Module-level store: one width, every subscriber re-renders on change ——
let sharedWidth: number | null = null;
const listeners = new Set<() => void>();
function readWidth(): number {
sharedWidth ??= initialWidth();
return sharedWidth;
}
function writeWidth(next: number): void {
if (next === sharedWidth) return;
sharedWidth = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export interface PanelWidthState {
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default, and the stored preference is cleared. */
resetWidth: () => void;
/** Ref to the docked panel's root node: drag-to-resize uses its right edge to compute the target width. */
panelRef: RefObject<HTMLDivElement | null>;
}
/**
* The shared width plus this panel's own drag state. `resizing` and `panelRef` stay per-panel
* (each panel has its own DOM node and its own handle); only the width crosses between them.
*/
export function usePanelWidth(): PanelWidthState {
const width = useSyncExternalStore(subscribe, readWidth, readWidth);
const [resizing, setResizing] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
// Drag-to-resize: during mousemove, computes the width from the panel's right edge and clamps
// it within the min/max bounds; locks the cursor/selection during the drag to avoid
// accidentally selecting page text on a fast drag.
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
writeWidth(Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX)));
};
// Only persist once the drag ends: mousemove fires every frame, and it's not worth writing to localStorage on every frame.
const onUp = () => {
setResizing(false);
try {
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(readWidth())));
} catch {
/* best-effort persistence (quota / private mode) */
}
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
writeWidth(defaultWidthFor(window.innerWidth));
// Clears rather than writing the default value: this way the default keeps following the
// window's proportion going forward, instead of being frozen at the current pixel value.
try {
localStorage.removeItem(WIDTH_STORAGE_KEY);
} catch {
/* best-effort */
}
}, []);
// When the window shrinks, clamp the width back within the cap so the docked panel can't
// crowd out the chat column. Shrinks only, never grows back, and never overwrites the stored
// preference: enlarging the window again relies on a refresh or a double-click on the handle.
// Both panels register this; the clamp is idempotent, so the duplicate is harmless.
useEffect(() => {
const onResize = () => writeWidth(Math.min(readWidth(), maxWidthFor(window.innerWidth)));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return { width, resizing, startResize, resetWidth, panelRef };
}
@@ -1,27 +1,27 @@
/**
* Subagents panel state machine (cloned from use-files-panel.ts, which documents the shared
* mechanics in detail): panel open/close, drag-to-resize with its own persisted width key, the
* desktop-dock vs. mobile-Sheet breakpoint, the "focus this child conversation" command driven
* by clicking a subagent chip inside a message, and the displayed Task scope (latest vs. the
* historical Task a chip was clicked on — see taskScope).
* Subagents panel state machine (a sibling of use-files-panel.ts, which documents the shared
* mechanics): panel open/close, the desktop-dock vs. mobile-Sheet breakpoint, the "focus this
* child conversation" command driven by clicking a subagent chip inside a message, and the
* displayed Task scope (latest vs. the historical Task a chip was clicked on — see taskScope).
* Width and drag-to-resize live in use-panel-width.ts, shared with the Files panel so the two
* mutually exclusive panels always open at the same width.
*
* VISIBILITY IS TASK-SCOPED — this deliberately diverges from the Files panel's
* open-persists-across-sessions convention: an open panel belongs to the task it was opened
* for. Starting a new Task (a user message) closes it by default, entering a session starts
* closed, and it comes back only via a manual open (toolbar/chip) or the CURRENT task spawning
* a subagent (auto-open, re-armed per task). The pure tracker below
* (createPanelTaskScope/advancePanelTaskScope) owns those boundary decisions; the chat page
* observes the stream and applies its actions.
* Visibility now follows the Files panel: an open panel survives a Session switch, and only a
* NEW chat resets both to closed (the chat page owns that reset — it owns both panels). What
* stays specific to this panel is the AUTO-OPEN: the current Task's first live spawn opens it
* once, re-armed at each task boundary, so a manual close mid-task is respected. The pure
* tracker below (createPanelTaskScope/advancePanelTaskScope) owns that decision; the chat page
* observes the stream and applies it under its own layout guards.
*
* Width remains a layout preference persisted to localStorage, and the focus command uses the
* fresh-object idiom so clicking the same chip again still re-triggers the panel's focus
* effect.
* The focus command uses the fresh-object idiom so clicking the same chip again still
* re-triggers the panel's focus effect.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { useCallback, useEffect, useState } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
import { usePanelWidth } from "./use-panel-width";
import type { PanelWidthState } from "./use-panel-width";
export interface SubagentsPanelState {
export interface SubagentsPanelState extends PanelWidthState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet; unused in the desktop docked state. */
@@ -42,21 +42,12 @@ export interface SubagentsPanelState {
taskScope: { anchorSessionId: string } | null;
/** Docked at >=1024px (lg); otherwise a bottom Sheet — mounted mutually exclusively. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default. */
resetWidth: () => void;
/** Ref to the docked panel's root node (drag-to-resize measures its right edge). */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.subagentsPanelWidth";
// ---------------------------------------------------------------------------
// Task-scoped visibility tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// Auto-open tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// ---------------------------------------------------------------------------
/**
@@ -76,29 +67,27 @@ export function createPanelTaskScope(): PanelTaskScope {
}
/**
* Advance the tracker with one observation of the current session's stream and return what the
* panel should do — the whole task-scoped lifecycle in one place:
* - session switch → "close" (a session is entered closed; no inherited open state), unless
* its CURRENT task already has a live spawn, which wins as "autoOpen" (a mid-run entry —
* reload included — counts as the spawn introducing itself);
* - a new Task (taskCount increase) → "close" by default (an unrelated task must not inherit
* an open panel) and RE-ARMS the auto-open;
* - a live spawn in the current task → "autoOpen", at most once per task — a manual close
* Advance the tracker with one observation of the current session's stream and return whether
* the panel should auto-open:
* - a live spawn in the current task → "autoOpen", at most once per task, so a manual close
* afterwards is respected until the next boundary;
* - a boundary (a Session switch, or a new Task within the session) RE-ARMS that one attempt
* but is never itself an action — an open panel is not closed here any more. Entering a
* session mid-run therefore auto-opens on the spawn that is already live, which reads as
* that spawn introducing itself;
* - anything else (steering, compaction, more messages in the same task) → null.
* A taskCount DECREASE is a defensive re-baseline (a resync swapped in a smaller model):
* adopted silently — no boundary, and the auto-open attempt counts as consumed so a rebuild
* can never surprise-reopen a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, files panel closed, not
* adopted silently, with the auto-open attempt marked consumed so a rebuild can never
* surprise-open a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, Files panel closed, not
* already open); the attempt is consumed here regardless, so a suppressed attempt never
* retriggers within the same task.
*/
export function advancePanelTaskScope(
state: PanelTaskScope,
obs: { sessionId: string | null; taskCount: number; liveSpawn: boolean },
): "close" | "autoOpen" | null {
): "autoOpen" | null {
const switched = obs.sessionId !== state.sessionId;
const newTask = !switched && obs.taskCount > state.taskCount;
const rebaseline = !switched && obs.taskCount < state.taskCount;
if (switched || obs.taskCount !== state.taskCount) {
state.sessionId = obs.sessionId;
@@ -110,27 +99,9 @@ export function advancePanelTaskScope(
state.autoOpenedAt = state.taskCount;
return "autoOpen";
}
if (switched || newTask) return "close";
return null;
}
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window, clamped within the min/max bounds (same proportion as the Files panel). */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds) over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState {
const [open, setOpenRaw] = useState(false);
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
@@ -152,18 +123,13 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
}
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** Synchronous mirror of width, read by the mouseup persist step (sidesteps the stale closure). */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const widthState = usePanelWidth();
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session resets the focus command and any pinned historical Task scope (both
// pointed into the old session's stream — the new session opens on its latest topology).
// The open/closed state is NOT touched here: visibility is task-scoped and owned by the
// chat page's tracker (advancePanelTaskScope above), which closes on session entry and new
// Tasks, and auto-opens on the current task's first live spawn.
// The open/closed state is NOT touched: like the Files panel, an open panel survives the
// switch, and only a new chat closes it (chat-page.tsx).
useEffect(() => {
setFocusRequest(null);
setTaskScope(null);
@@ -184,58 +150,6 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
setFocusRequest({ sessionId: sid, origin });
}, []);
// Drag-to-resize: identical handling to the Files panel (see use-files-panel.ts for the rationale comments).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clear rather than write the default: the default keeps following the window's proportion.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap (shrinks only, like the Files panel).
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
@@ -245,10 +159,6 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
focusRequest,
taskScope,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
...widthState,
};
}
+52 -13
View File
@@ -16,13 +16,11 @@ export const en: Strings = {
agents: "Agents",
skills: "Skills",
models: "Models",
usage: "Costs",
traces: "Trajectory",
usage: "Cost Center",
traces: "Trajectories",
benchmark: "Evaluation Center",
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
// Collapsed-rail tooltip (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
lastConversation: "Last conversation",
// Deliberately equal to nav.agents: the key exists only because the zh dictionary words the rail entry differently ("Agents" vs "Agent library").
railAgents: "Agents",
collapseSidebar: "Collapse sidebar",
expandSidebar: "Expand sidebar",
collapseGroup: "Collapse",
@@ -61,7 +59,12 @@ export const en: Strings = {
/** Superscript badge on the version lines when the update check found a newer release. */
newVersionBadge: "New version available",
newVersion: (v: string) => `New version v${v} available`,
/** Manual check action in the sidebar user menu, with its busy label and toast outcomes. */
/**
* The sidebar user menu's SINGLE update row: it reads "Check for updates" until a newer
* release is known and runs the manual check; once one is known it reads newVersion() and
* opens the update dialog instead (which carries the release-notes link and, for admins,
* the self-update action).
*/
checkNow: "Check for updates",
checking: "Checking…",
upToDate: "You're on the latest version",
@@ -76,6 +79,8 @@ export const en: Strings = {
unsupported: "This install cannot be updated from the web UI",
confirmBody:
"Downloads the latest release and installs it into the install directory on the server (the data directory is not touched). Restart the service afterwards for the update to take effect.",
/** Shown in place of confirmBody to non-admins, who can read the release notes but cannot run the update. */
adminOnly: "Only an administrator can run the update from here.",
},
common: {
@@ -162,6 +167,8 @@ export const en: Strings = {
idPrefixHint:
"The id is prefixed with your username and a hyphen; append lowercase letters, digits or underscores. Cannot be changed later.",
name: "Display name (optional, defaults to the Project id)",
/** The display-name field in Project settings (required there, unlike the create dialog's optional one). */
displayName: "Display name",
settings: "Project settings",
settingsTitle: "Project settings",
members: "Members",
@@ -548,9 +555,14 @@ export const en: Strings = {
tempWorkspaces: "Temp workspaces",
newSessionInWorkspace: "New chat in this workspace",
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
/** Folder names for the draft page's collapsible examples (bookmark-style: exactly one open at a time). */
exampleFolders: {
webapps: "Build web apps",
agents: "Build and optimize agents",
},
exampleTasks: {
game: {
label: "Example: 2D penguin sled game",
label: "2D penguin sled game",
desc: "A cute Antarctic penguin sleds over rocks, easy start with a gentle difficulty ramp — a 2D pure-frontend mini game",
prompt:
"Build a cute Antarctic penguin sledding 2D game: press Space to jump over the rocks " +
@@ -562,8 +574,30 @@ export const en: Strings = {
"When done, test it in a browser once, confirm the first few seconds are easy to " +
"clear, and tell me how to open it and how to play.",
},
gamecenter: {
label: "A mini-game center built by multiple agents",
desc: "Ten pure-frontend games with no repeated mechanics, built in parallel behind one index page",
prompt: `Build a web mini-game center with multiple agents working in parallel: 10 pure-frontend games with no two sharing the same mechanic, plus an index page.
## How to split the work
- First plan the 10 games (say snake, 2048, tetris, breakout, minesweeper, memory match, sokoban, space shooter, platform jumper, rhythm tap), confirm no two mechanics repeat, and fix a shared directory layout, palette and interaction spec.
- Then hand the 10 games to several subagents to implement in parallel — each subagent owns exactly one game, follows the agreed spec, and never edits another's files.
## Each game
- Its own \`games/<slug>/index.html\`: pure frontend, a single file that runs straight from file://, with no backend and no CDN assets.
- Start / restart, live score or timer, a lose-or-clear summary, both keyboard and touch controls, and the rules written on the page.
- A way back to the index page.
## Index page
- \`index.html\` at the root: a card grid listing all 10 games (name + one-line mechanic + controls), each card opening its game.
- One design language shared with every game, following the web-design skill; dark/light themes via \`<html data-theme>\` remembered in localStorage; responsive, single column on phones.
## Wrap-up
- Review as a whole: the 10 mechanics really are distinct, the styling is consistent, and every index link resolves.
- Self-test each game in a browser — it starts, it ends, it restarts — then tell me how to open it.`,
},
lol: {
label: "Example: League of Legends music player",
label: "League of Legends music player",
desc: "Worlds anthems on the SoundCloud Widget API — a single file that opens from file://",
prompt: `Build a League of Legends Worlds anthem player with the SoundCloud Widget API (see https://developers.soundcloud.com/docs/api/html5-widget): a single index.html that works when opened from file://.
@@ -593,7 +627,7 @@ Penguin visual style (see the web-design skill), dark/light themes via <html dat
When done, open index.html in a browser and self-test once.`,
},
rag: {
label: "Example: build a Claude Code docs expert",
label: "Build a Claude Code docs RAG agent",
desc: "Collect the claude-code-docs repo into a conversational RAG knowledge app with source citations",
prompt:
"Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG knowledge app: " +
@@ -602,10 +636,16 @@ When done, open index.html in a browser and self-test once.`,
"with retrieval-augmented replies and clickable citations that reveal the matched " +
"original text chunk and link to the real documents; " +
"give it a beautiful web chat UI following the web-design skill, with a few example questions in the empty state. " +
"When done, run the app, verify one streamed answer yourself, and tell me how to access it.",
"Pay particular attention to matching the question's language against the corpus: the docs are English, " +
"while questions will often be Chinese. With a lexical retriever such as BM25, a Chinese query MUST be " +
"converted to English first (translate it, or extract English keywords) before it reaches the index — " +
"otherwise not a single Chinese term matches the English index and retrieval silently degrades to nothing. " +
"Mixed Chinese/English questions must retrieve correctly too, and the answer should follow the language of the question. " +
"When done, run the app and self-test one Chinese question and one English question, confirming both retrieve " +
"the right English documents and stream their answers, then tell me how to access it.",
},
agentBenchmarkBuild: {
label: "Example: create a decision Agent and capability evaluation",
label: "Build a general-purpose decision agent and its benchmark",
desc: "Create a general decision Agent and test it on football, after-sales, and investment tasks",
prompt: `Use \`agent-creation\` followed by \`benchmark-design\` to create a decision Agent and produce a frozen Benchmark with a Formal Baseline.
@@ -627,7 +667,7 @@ Scenarios:
3. Choose investment actions from a strategy, historical markets, and current indicators.`,
},
agentOptimization: {
label: "Example: optimize a decision Agent from its evaluation",
label: "Improve the general-purpose decision agent's accuracy",
desc: "Improve an Agent from existing evaluation results and verify that the new version is better",
prompt: `Use \`agent-optimization\` to optimize a decision Agent against its frozen Benchmark.
@@ -984,7 +1024,6 @@ Scenarios:
viewCase: "View task",
publicMaterials: "Public materials",
statementUnavailable: "The public Statement is unavailable",
maxScore: (score: string): string => `Max ${score}`,
evaluations: "Evaluations",
noEvaluations: "No evaluations yet",
summaryLabel: "Summary",
+49 -12
View File
@@ -15,15 +15,14 @@ export const zh = {
nav: {
chat: "对话",
newChat: "新对话",
agents: "智能体仓库",
agents: "智能体",
skills: "技能库",
models: "模型仓库",
models: "模型库",
usage: "成本中心",
traces: "轨迹观测",
benchmark: "评估中心",
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
// Collapsed-rail tooltip (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
lastConversation: "最近一次对话",
railAgents: "智能体",
collapseSidebar: "收起侧栏",
expandSidebar: "展开侧栏",
collapseGroup: "折叠",
@@ -62,7 +61,10 @@ export const zh = {
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
newVersionBadge: "有新版本可用",
newVersion: (v: string) => `新版本 v${v} 可用`,
/** Manual check action in the sidebar user menu (owner request), with its busy label and toast outcomes. */
/**
* 用户菜单里**唯一**的更新行:未知新版本时显示「检查更新」并执行手动检查;已知新版本后改为
* newVersion() 文案,点击打开更新弹窗(弹窗内含更新说明链接,管理员另有自更新操作)。
*/
checkNow: "检查更新",
checking: "检查中…",
upToDate: "已是最新版本",
@@ -77,6 +79,8 @@ export const zh = {
unsupported: "当前安装方式不支持在线更新",
confirmBody:
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
/** 非管理员看到的说明(可查看更新说明,但不能在此执行更新),替代 confirmBody。 */
adminOnly: "只有管理员可以在这里执行更新。",
},
common: {
@@ -157,6 +161,8 @@ export const zh = {
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
name: "显示名(可选,缺省为 Project id)",
/** Project 设置里的显示名字段(此处必填,与新建对话框的「可选」措辞区分)。 */
displayName: "显示名",
settings: "Project 设置",
settingsTitle: "Project 设置",
members: "成员",
@@ -531,6 +537,11 @@ export const zh = {
tempWorkspaces: "临时工作区",
newSessionInWorkspace: "在此工作区新建对话",
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/** 首页示例的折叠分组名(书签式,同时只展开一个)。 */
exampleFolders: {
webapps: "搭建网页应用",
agents: "搭建和优化智能体",
},
/**
* Example task cards on the draft screen: one click auto-submits the canned prompt. These
* are the FULL working prompts — descriptions stay short, but the submitted instructions
@@ -538,7 +549,7 @@ export const zh = {
*/
exampleTasks: {
game: {
label: "示例:2D 企鹅雪橇越野小游戏",
label: "2D 企鹅雪橇越野小游戏",
desc: "可爱南极企鹅滑雪橇跳石头,难度由易到难的 2D 纯前端小游戏",
prompt:
"做一个可爱的南极企鹅滑雪橇越野 2D 小游戏:按空格键起跳,跃过冰面上迎面而来的石头;" +
@@ -547,8 +558,30 @@ export const zh = {
"2D 横版画面、可爱卡通风,纯前端实现(单个 HTML 文件即可),界面遵循 web-design 技能。" +
"完成后在浏览器里自测一次,确认开局能轻松玩过几秒,并告诉我怎么打开和怎么玩。",
},
gamecenter: {
label: "多智能体搭建小游戏中心",
desc: "并行产出 10 个玩法互不重复的纯前端小游戏,配一个统一风格的索引首页",
prompt: `用多智能体并行搭建一个网页小游戏中心:10 个玩法互不重复的纯前端小游戏,外加一个索引首页。
## 分工方式
- 先规划这 10 个游戏(例如贪吃蛇、2048、俄罗斯方块、打砖块、扫雷、记忆翻牌、推箱子、太空射击、跳跃平台、节奏点击),确认玩法确实互不重复,并定好统一的目录结构、配色与交互规范。
- 再把 10 个游戏分派给多个子智能体并行实现,每个子智能体只负责自己的那一个游戏,严格按既定规范产出,互不改动他人的文件。
## 每个游戏
- 独立的 \`games/<slug>/index.html\`,纯前端单文件、file:// 直接打开即可运行,不依赖后端与任何 CDN 资源。
- 具备开始 / 重新开始、实时计分或计时、失败或通关结算,并同时支持键盘与触摸操作,页面内写明玩法说明。
- 提供返回索引首页的入口。
## 索引首页
- 根目录 \`index.html\`:卡片网格列出全部 10 个游戏(名称 + 一句话玩法 + 操作方式),点击进入对应游戏。
- 与所有游戏共用一套设计语言,遵循 web-design 技能;深色 / 浅色主题(\`<html data-theme>\`)并用 localStorage 记忆;响应式,手机端单列。
## 收尾
- 统一验收:10 个游戏玩法确实不重复、风格一致,索引页的链接全部可达。
- 在浏览器里逐个自测,确认都能开始、能结束、能重开,然后告诉我怎么打开。`,
},
lol: {
label: "示例:英雄联盟音乐播放器",
label: "英雄联盟音乐播放器",
desc: "用 SoundCloud Widget API 播放历届 Worlds 主题曲,单文件即开即用",
prompt: `用 SoundCloud Widget API(见 https://developers.soundcloud.com/docs/api/html5-widget)做一个英雄联盟 Worlds 主题曲播放器,单文件 index.html,file:// 打开即用。
@@ -578,7 +611,7 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
完成后在浏览器打开 index.html 自测一次。`,
},
rag: {
label: "示例:构建 Claude Code 文档专家",
label: "构建 Claude Code 文档 RAG 智能体",
desc: "收集 claude-code-docs 仓库,生成可对话、带来源引用的 RAG 知识应用",
prompt:
"收集 https://github.com/ericbuess/claude-code-docs 的文档,构建一个 RAG 知识应用:" +
@@ -586,10 +619,15 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
"检索增强回答 Claude Code 相关问题并标注可点击的来源引用——" +
"引用要能展示命中的原文片段,并链接到真实文档;" +
"按 web-design 技能提供美观的 Web 聊天界面,空态展示几个示例问题。" +
"完成后运行应用、自测一个问题验证流式回答,并告诉我访问方式。",
"特别注意提问与语料的语言匹配:语料是英文,提问很可能是中文。" +
"如果用 BM25 之类的词法检索,中文查询必须先转换成英文(翻译或抽取英文关键词)再进入检索," +
"否则中文词在英文索引里一个都命不中,会静默退化成空召回;" +
"中英混合提问同样要能正确召回,且回答语言跟随提问语言。" +
"完成后运行应用,用一个中文问题和一个英文问题各自测一次," +
"确认两者都检索到了正确的英文文档、流式回答正常,并告诉我访问方式。",
},
agentBenchmarkBuild: {
label: "示例:创建决策 Agent 和能力评测",
label: "构建通用决策智能体和评测基准",
desc: "创建一个通用决策 Agent,并用足球、售后和投资任务检验它",
prompt: `请依次使用 \`agent-creation\` 和 \`benchmark-design\`,创建决策 Agent,并产出 Frozen Benchmark 与 Formal Baseline。
@@ -611,7 +649,7 @@ Benchmark:
3. 根据投资策略、历史市场与当前指标选择投资动作。`,
},
agentOptimization: {
label: "示例:根据评测优化决策 Agent",
label: "优化通用决策智能体的准确率",
desc: "根据已有评测结果改进 Agent,并验证新版本是否真正提升",
prompt: `请使用 \`agent-optimization\`,根据 Frozen Benchmark 优化决策 Agent。
@@ -962,7 +1000,6 @@ Benchmark:
viewCase: "查看题目",
publicMaterials: "公开材料",
statementUnavailable: "题面暂时无法读取",
maxScore: (score: string): string => `满分 ${score}`,
evaluations: "评估明细",
noEvaluations: "暂无评估记录",
/** Evaluation notes (scoreboard's summary: score source and notes on this round's changes). */
+20 -19
View File
@@ -1,10 +1,11 @@
/**
* advancePanelTaskScope unit tests: the subagents panel's TASK-SCOPED visibility rules —
* entering a session and every new Task close the panel by default; the current task's first
* live spawn auto-opens it (one attempt per task, so a manual close afterwards is respected
* until the next boundary); a taskCount decrease is a defensive re-baseline, never a boundary.
* The chat page feeds observations per render (session id + taskStartCount + live-spawn flag)
* and applies the returned action under its own layout guards.
* advancePanelTaskScope unit tests: the subagents panel's AUTO-OPEN rule — the current task's
* first live spawn opens the panel once, re-armed at every boundary (Session switch or new
* Task) so a manual close is respected until the next one; boundaries themselves never close
* the panel (an open panel survives a switch, like the Files panel); a taskCount decrease is a
* defensive re-baseline that consumes the attempt instead of arming it. The chat page feeds
* observations per render (session id + taskStartCount + live-spawn flag) and applies the
* returned action under its own layout guards.
*/
import { describe, expect, it } from "vitest";
import {
@@ -18,36 +19,35 @@ const obs = (sessionId: string | null, taskCount: number, liveSpawn = false) =>
liveSpawn,
});
describe("advancePanelTaskScope (task-scoped panel visibility)", () => {
it("entering a session closes by default; a mid-run entry with a live spawn auto-opens instead", () => {
describe("advancePanelTaskScope (subagents panel auto-open)", () => {
it("entering a session does nothing on its own; a mid-run entry with a live spawn auto-opens", () => {
const s = createPanelTaskScope();
expect(advancePanelTaskScope(s, obs("A", 3))).toBe("close");
expect(advancePanelTaskScope(s, obs("A", 3))).toBeNull();
const mid = createPanelTaskScope();
expect(advancePanelTaskScope(mid, obs("A", 3, true))).toBe("autoOpen");
});
it("a new Task closes the panel and RE-ARMS the auto-open; the task's own spawn then opens it once", () => {
it("a new Task RE-ARMS the auto-open without closing; the task's own spawn then opens it once", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 1)); // session entry
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBe("autoOpen"); // task 1 spawns
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBeNull(); // once per task
expect(advancePanelTaskScope(s, obs("A", 2))).toBe("close"); // task 2 boundary
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull(); // the boundary fires once
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull(); // task 2 boundary: no close
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBe("autoOpen"); // re-armed for task 2
// Consumed again: a manual close mid-task stays respected until the next boundary.
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBeNull();
});
it("a boundary arriving together with the new task's spawn opens rather than closing (batched commit)", () => {
it("a boundary arriving together with the new task's spawn opens in the same observation", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 1));
expect(advancePanelTaskScope(s, obs("A", 2, true))).toBe("autoOpen");
});
it("a session switch resets the per-task guard", () => {
it("a session switch resets the per-task guard but leaves visibility alone", () => {
const s = createPanelTaskScope();
expect(advancePanelTaskScope(s, obs("A", 1, true))).toBe("autoOpen"); // consumed for A's task 1
expect(advancePanelTaskScope(s, obs("B", 1))).toBe("close"); // B is entered closed
expect(advancePanelTaskScope(s, obs("B", 1))).toBeNull(); // B inherits the open state
expect(advancePanelTaskScope(s, obs("B", 1, true))).toBe("autoOpen"); // B's task 1 arms fresh
});
@@ -58,12 +58,13 @@ describe("advancePanelTaskScope (task-scoped panel visibility)", () => {
expect(advancePanelTaskScope(s, obs("A", 2))).toBeNull();
});
it("a taskCount decrease re-baselines silently: no boundary, no surprise reopen; the next real boundary works", () => {
it("a taskCount decrease re-baselines silently: no surprise reopen, and the next real boundary still arms", () => {
const s = createPanelTaskScope();
advancePanelTaskScope(s, obs("A", 5));
// A resync swapped in a smaller model while a spawn runs: neither close nor auto-open —
// reopening a panel the user closed mid-task would be a surprise.
// A resync swapped in a smaller model while a spawn runs: no auto-open — reopening a panel
// the user closed mid-task would be a surprise.
expect(advancePanelTaskScope(s, obs("A", 3, true))).toBeNull();
expect(advancePanelTaskScope(s, obs("A", 4))).toBe("close");
expect(advancePanelTaskScope(s, obs("A", 4))).toBeNull(); // boundary itself is silent
expect(advancePanelTaskScope(s, obs("A", 4, true))).toBe("autoOpen"); // but it re-armed
});
});