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

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

No files matched your search

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