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:
1 parent
97852f43b8
commit
385d506e77
32 files changed
+1135
-557
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user