From 385d506e77a5584266d17ba29110085f1b179608 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Thu, 30 Jul 2026 17:25:50 +0800 Subject: [PATCH] feat(web,server): unify nav naming, panel width and the update entry (#130) Co-authored-by: Claude Opus 5 (1M context) --- .../2026-07-30-backward-compatibility.md | 23 ++ .../2026-07-30-web-app-refinements.md | 57 +++++ changelog/unreleased/README.md | 4 + .../src/environment/tools/command/session.ts | 15 +- packages/core/test/engine.test.ts | 30 ++- packages/core/test/environment.test.ts | 27 +- packages/landing/src/lib/strings.ts | 2 +- packages/server/src/api/types.ts | 9 + packages/server/src/http/routes/projects.ts | 29 ++- .../src/services/project-config-service.ts | 10 + .../server/src/services/project-service.ts | 19 ++ packages/server/test/project-rename.test.ts | 116 +++++++++ packages/web/e2e/layout.spec.mjs | 9 +- packages/web/src/api/endpoints.ts | 9 + .../src/components/account/update-dialog.tsx | 73 +++++- .../web/src/components/layout/app-layout.tsx | 4 +- .../src/components/layout/project-dialogs.tsx | 80 +++++- .../web/src/components/layout/sidebar.tsx | 126 +++++----- .../src/features/benchmark/benchmark-page.tsx | 8 +- .../benchmark/benchmark-statement-browser.tsx | 1 - .../src/features/chat/agent-topology-view.tsx | 60 +++-- .../web/src/features/chat/agent-topology.ts | 44 +++- packages/web/src/features/chat/chat-page.tsx | 27 +- packages/web/src/features/chat/draft-view.tsx | 232 ++++++++---------- .../web/src/features/chat/example-tasks.ts | 52 +++- .../web/src/features/chat/subagents-view.tsx | 3 +- .../web/src/features/chat/use-files-panel.ts | 123 ++-------- .../web/src/features/chat/use-panel-width.ts | 177 +++++++++++++ .../src/features/chat/use-subagents-panel.ts | 158 +++--------- packages/web/src/lib/strings-en.ts | 65 ++++- packages/web/src/lib/strings.ts | 61 ++++- packages/web/test/panel-task-scope.test.ts | 39 +-- 32 files changed, 1135 insertions(+), 557 deletions(-) create mode 100644 changelog/unreleased/2026-07-30-backward-compatibility.md create mode 100644 changelog/unreleased/2026-07-30-web-app-refinements.md create mode 100644 packages/server/test/project-rename.test.ts create mode 100644 packages/web/src/features/chat/use-panel-width.ts diff --git a/changelog/unreleased/2026-07-30-backward-compatibility.md b/changelog/unreleased/2026-07-30-backward-compatibility.md new file mode 100644 index 0000000..23c486c --- /dev/null +++ b/changelog/unreleased/2026-07-30-backward-compatibility.md @@ -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. diff --git a/changelog/unreleased/2026-07-30-web-app-refinements.md b/changelog/unreleased/2026-07-30-web-app-refinements.md new file mode 100644 index 0000000..952070b --- /dev/null +++ b/changelog/unreleased/2026-07-30-web-app-refinements.md @@ -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//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. diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md index b445c7f..814c013 100644 --- a/changelog/unreleased/README.md +++ b/changelog/unreleased/README.md @@ -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)) diff --git a/packages/core/src/environment/tools/command/session.ts b/packages/core/src/environment/tools/command/session.ts index 92f7c49..64c7b13 100644 --- a/packages/core/src/environment/tools/command/session.ts +++ b/packages/core/src/environment/tools/command/session.ts @@ -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 /** diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 8e33256..864ba61 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -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 { + 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( diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts index 8f90b99..f3477cd 100644 --- a/packages/core/test/environment.test.ts +++ b/packages/core/test/environment.test.ts @@ -45,6 +45,28 @@ async function collect(gen: AsyncGenerator): Promise 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 { + 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!"); }); }); diff --git a/packages/landing/src/lib/strings.ts b/packages/landing/src/lib/strings.ts index 2bb27cc..2fec12e 100644 --- a/packages/landing/src/lib/strings.ts +++ b/packages/landing/src/lib/strings.ts @@ -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 控制台", diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 5e77587..cd62f04 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -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; diff --git a/packages/server/src/http/routes/projects.ts b/packages/server/src/http/routes/projects.ts index 5536dc1..b64bfda 100644 --- a/packages/server/src/http/routes/projects.ts +++ b/packages/server/src/http/routes/projects.ts @@ -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 { @@ -23,6 +33,19 @@ export function projectsRoutes(deps: AppDeps): Hono { 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")); diff --git a/packages/server/src/services/project-config-service.ts b/packages/server/src/services/project-config-service.ts index 8339f7a..f93895d 100644 --- a/packages/server/src/services/project-config-service.ts +++ b/packages/server/src/services/project-config-service.ts @@ -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 { + 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 { const raw = await this.readRaw(projectId); diff --git a/packages/server/src/services/project-service.ts b/packages/server/src/services/project-service.ts index 18a4238..bc6e837 100644 --- a/packages/server/src/services/project-service.ts +++ b/packages/server/src/services/project-service.ts @@ -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 { + 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 diff --git a/packages/server/test/project-rename.test.ts b/packages/server/test/project-rename.test.ts new file mode 100644 index 0000000..98b7e00 --- /dev/null +++ b/packages/server/test/project-rename.test.ts @@ -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; + let member: ReturnType; + let outsider: ReturnType; + 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"); + }); +}); diff --git a/packages/web/e2e/layout.spec.mjs b/packages/web/e2e/layout.spec.mjs index 4b6e6c6..64925ab 100644 --- a/packages/web/e2e/layout.spec.mjs +++ b/packages/web/e2e/layout.spec.mjs @@ -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", "新建对话", "智能体", "技能库", - "模型仓库", + "模型库", "成本中心", "轨迹观测", "评估中心", diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 18b7ef0..b7e22d7 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -40,6 +40,8 @@ import type { PrefsResponse, ProjectCreateRequest, ProjectCreateResponse, + ProjectUpdateRequest, + ProjectUpdateResponse, ProjectsResponse, ScheduleItem, SchedulesResponse, @@ -114,6 +116,13 @@ export const listProjects = () => apiFetch("/api/projects"); export const createProject = (body: ProjectCreateRequest) => apiFetch("/api/projects", { method: "POST", body }); +/** Rename a Project's display name (owner); the id is immutable. */ +export const updateProject = (projectId: string, body: ProjectUpdateRequest) => + apiFetch(`/api/projects/${encodeURIComponent(projectId)}`, { + method: "PATCH", + body, + }); + export const deleteProject = (projectId: string) => apiFetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }); diff --git a/packages/web/src/components/account/update-dialog.tsx b/packages/web/src/components/account/update-dialog.tsx index 5503503..78222da 100644 --- a/packages/web/src/components/account/update-dialog.tsx +++ b/packages/web/src/components/account/update-dialog.tsx @@ -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
 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
+ * 
 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("confirm");
   const [result, setResult] = useState(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 (
      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" ? (
-          
+        phase === "done" || !canUpdate ? (
+          
         ) : (
           <>
-            
             
+              
+              {nameError !== undefined && {nameError}}
+            
+          ) : (
+            <>
+              

{S.project.switcher}

+

{savedName}

+ + )} +

{projectId}

diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 3cd7e3a..4079b03 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -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. */} -
+ 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. */} +
- )} -
- )}
- {/* 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. */} @@ -1169,7 +1153,15 @@ export function Sidebar({ 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)} /> - - {S.benchmark.maxScore("100")} - - + {/* 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. */} + {S.benchmark.viewCase} diff --git a/packages/web/src/features/benchmark/benchmark-statement-browser.tsx b/packages/web/src/features/benchmark/benchmark-statement-browser.tsx index 86f4077..45e73ec 100644 --- a/packages/web/src/features/benchmark/benchmark-statement-browser.tsx +++ b/packages/web/src/features/benchmark/benchmark-statement-browser.tsx @@ -304,7 +304,6 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas {preview?.path ?? caseSummary.id}

- {S.benchmark.maxScore("100")} {downloadUrl && preview && ( 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" }`} > - - - {label} + + + + {label} + + {/* Elapsed: ticking from first appearance while running, frozen at the settled + span when done; omitted when the stamps are unknown (always for the root). + Decorative next to the label — the aria-label pins the accessible name. */} + {node.running + ? node.startedMs !== undefined && ( + + + + ) + : node.elapsedMs !== undefined && ( + + {humanizeDuration(node.elapsedMs)} + + )} + {/* Status is already part of the button's accessible name: keep the glyph decorative. */} + - {/* Elapsed: ticking from first appearance while running, frozen at the settled - span when done; omitted when the stamps are unknown (always for the root). - Decorative next to the label — the aria-label pins the accessible name. */} - {node.running - ? node.startedMs !== undefined && ( - - - - ) - : node.elapsedMs !== undefined && ( - - {humanizeDuration(node.elapsedMs)} - - )} - {/* Status is already part of the button's accessible name: keep the glyph decorative. */} - + {/* 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 && ( + + {node.description} + + )} ); })} diff --git a/packages/web/src/features/chat/agent-topology.ts b/packages/web/src/features/chat/agent-topology.ts index ce37b9c..e4cb8dc 100644 --- a/packages/web/src/features/chat/agent-topology.ts +++ b/packages/web/src/features/chat/agent-topology.ts @@ -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)["agent_id"]; - if (typeof id === "string" && id.length > 0) return id; + const value = (parsed as Record)[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; diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 0395a57..d445abc 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -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 && diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx index 0674ed5..db7a295 100644 --- a/packages/web/src/features/chat/draft-view.tsx +++ b/packages/web/src/features/chat/draft-view.tsx @@ -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 = { + 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(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({
- {/* 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. */} -
- {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). */} +
+ {EXAMPLE_FOLDERS.map((folder) => { + const open = folder.id === openFolder; return ( - + + {open && ( +
    + {folder.tasks.map((task) => { + const copy = S.chat.exampleTasks[task.id]; + return ( +
  • + +
  • + ); + })} +
)} - +
); })}
@@ -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). diff --git a/packages/web/src/features/chat/example-tasks.ts b/packages/web/src/features/chat/example-tasks.ts index 641504d..ebe0257 100644 --- a/packages/web/src/features/chat/example-tasks.ts +++ b/packages/web/src/features/chat/example-tasks.ts @@ -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 = [ - { id: "game", skills: ["web-design"] }, - { id: "lol", skills: ["web-design"] }, - { id: "rag", skills: ["penguin-sdk", "web-design"] }, - { id: "agentBenchmarkBuild", skills: [] }, - { id: "agentOptimization", skills: [] }, +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, +]); diff --git a/packages/web/src/features/chat/subagents-view.tsx b/packages/web/src/features/chat/subagents-view.tsx index 9a14488..425d702 100644 --- a/packages/web/src/features/chat/subagents-view.tsx +++ b/packages/web/src/features/chat/subagents-view.tsx @@ -164,7 +164,8 @@ export function SubagentsView({
) : ( <> - {/* 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. */}
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) => 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; } -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(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) => { - 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, }; } diff --git a/packages/web/src/features/chat/use-panel-width.ts b/packages/web/src/features/chat/use-panel-width.ts new file mode 100644 index 0000000..a177b40 --- /dev/null +++ b/packages/web/src/features/chat/use-panel-width.ts @@ -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) => 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; +} + +/** + * 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(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) => { + 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 }; +} diff --git a/packages/web/src/features/chat/use-subagents-panel.ts b/packages/web/src/features/chat/use-subagents-panel.ts index 5854319..f6a2301 100644 --- a/packages/web/src/features/chat/use-subagents-panel.ts +++ b/packages/web/src/features/chat/use-subagents-panel.ts @@ -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) => 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; } -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("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(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) => { - 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, }; } diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index a39b326..ace1242 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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//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 \`\` 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 `Max ${score}`, evaluations: "Evaluations", noEvaluations: "No evaluations yet", summaryLabel: "Summary", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index cd8002d..7f93e5a 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -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//index.html\`,纯前端单文件、file:// 直接打开即可运行,不依赖后端与任何 CDN 资源。 +- 具备开始 / 重新开始、实时计分或计时、失败或通关结算,并同时支持键盘与触摸操作,页面内写明玩法说明。 +- 提供返回索引首页的入口。 + +## 索引首页 +- 根目录 \`index.html\`:卡片网格列出全部 10 个游戏(名称 + 一句话玩法 + 操作方式),点击进入对应游戏。 +- 与所有游戏共用一套设计语言,遵循 web-design 技能;深色 / 浅色主题(\`\`)并用 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 技能),深色/浅色主题( `满分 ${score}`, evaluations: "评估明细", noEvaluations: "暂无评估记录", /** Evaluation notes (scoreboard's summary: score source and notes on this round's changes). */ diff --git a/packages/web/test/panel-task-scope.test.ts b/packages/web/test/panel-task-scope.test.ts index cf905e9..6302ce0 100644 --- a/packages/web/test/panel-task-scope.test.ts +++ b/packages/web/test/panel-task-scope.test.ts @@ -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 }); });