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

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

No files matched your search

@@ -53,41 +53,59 @@ export function AgentTopologyView({
const label = labelFor(node);
const selected = node.sessionId === selectedId;
const stateLabel = node.running ? S.subagentPanel.nodeRunning : S.subagentPanel.nodeDone;
// The node renders the description truncated on its second line; the tooltip carries
// the full sentence, which is the only place it appears untruncated.
const tooltip =
node.description !== null
? `${label} · ${stateLabel}\n${node.description}`
: `${label} · ${stateLabel}`;
return (
<button
key={node.sessionId}
type="button"
aria-label={`${label} · ${stateLabel}`}
aria-pressed={selected}
title={`${label} · ${stateLabel}`}
title={tooltip}
onClick={() => onSelect(node)}
style={{ left: x, top: y, width: NODE_W, height: NODE_H }}
className={`absolute flex items-center gap-1.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
className={`absolute flex flex-col justify-center gap-0.5 rounded-md border bg-white px-2 text-left transition-colors duration-150 dark:bg-gray-900 ${
selected
? "border-brand-500 ring-1 ring-brand-500"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800/60"
}`}
>
<AgentAvatar id={node.agentId ?? node.sessionId} name={label} size={18} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
<span className="flex w-full min-w-0 items-center gap-1.5">
<AgentAvatar id={node.agentId ?? node.sessionId} name={label} size={18} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300">
{label}
</span>
{/* Elapsed: ticking from first appearance while running, frozen at the settled
span when done; omitted when the stamps are unknown (always for the root).
Decorative next to the label — the aria-label pins the accessible name. */}
{node.running
? node.startedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
<LiveDuration sinceMs={node.startedMs} />
</span>
)
: node.elapsedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{humanizeDuration(node.elapsedMs)}
</span>
)}
{/* Status is already part of the button's accessible name: keep the glyph decorative. */}
<StatusIcon state={node.running ? "running" : "done"} size={10} />
</span>
{/* Elapsed: ticking from first appearance while running, frozen at the settled
span when done; omitted when the stamps are unknown (always for the root).
Decorative next to the label — the aria-label pins the accessible name. */}
{node.running
? node.startedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
<LiveDuration sinceMs={node.startedMs} />
</span>
)
: node.elapsedMs !== undefined && (
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
{humanizeDuration(node.elapsedMs)}
</span>
)}
{/* Status is already part of the button's accessible name: keep the glyph decorative. */}
<StatusIcon state={node.running ? "running" : "done"} size={10} />
{/* Second line: what this child was spawned to do. Indented to the label's own
left edge (avatar width + gap) and truncated to one line — the model writes a
free-form sentence into a fixed-size box, and the full text is in the tooltip.
Nodes without one (the root, a standalone child, an omitted description) drop
the line and the single row centers itself in the box instead. */}
{node.description !== null && (
<span className="w-full truncate pl-[24px] text-[10px] leading-tight text-gray-400 dark:text-gray-500">
{node.description}
</span>
)}
</button>
);
})}
@@ -25,6 +25,8 @@ export interface TopologyNode {
sessionId: string;
/** Agent running this node: the child's session_meta capture first, else the run_subagent `agent_id` argument; null when unknown (root: filled by the view from the Session DTO). */
agentId: string | null;
/** The spawning call's model-written `description` — what this child was asked to do; null for the root, a standalone item, or when the model omitted it. */
description: string | null;
/** Still running — the spawning card's output hasn't completed (root: the Task's own running state; a standalone item has no card, so it reads as done). */
running: boolean;
/** 0 = the main session; +1 per spawn hop. */
@@ -73,20 +75,36 @@ export function taskStartCount(items: readonly ChatItem[]): number {
return n;
}
/** Lenient `agent_id` extraction from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
/** Lenient string-field read from run_subagent arguments (complete JSON by the time a child is bound; unparseable/absent → null). */
function runSubagentArg(argsJson: string, field: string): string | null {
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed !== null && typeof parsed === "object") {
const id = (parsed as Record<string, unknown>)["agent_id"];
if (typeof id === "string" && id.length > 0) return id;
const value = (parsed as Record<string, unknown>)[field];
if (typeof value === "string" && value.length > 0) return value;
}
} catch {
// Arguments still streaming or malformed: no agent id to offer.
// Arguments still streaming or malformed: nothing to offer.
}
return null;
}
/** Lenient `agent_id` extraction from run_subagent arguments. */
export function agentIdFromRunSubagentArgs(argsJson: string): string | null {
return runSubagentArg(argsJson, "agent_id");
}
/**
* The model-written `description` argument of run_subagent — one sentence saying what this
* child was spawned to do. It is the only human-readable statement of a subagent's purpose
* (the agent name says who, not what), so the graph renders it as each node's second line and
* repeats it untruncated in the node tooltip. Optional: a model may omit it, and
* `call_description: false` removes the property from the schema entirely — both read as null.
*/
export function descriptionFromRunSubagentArgs(argsJson: string): string | null {
return runSubagentArg(argsJson, "description");
}
/** Extract the latest Task's spawn tree: root first, then children in DFS preorder (document order). */
export function extractTopology(
model: StreamModel,
@@ -154,6 +172,8 @@ function extractFromSlice(
{
sessionId: rootSessionId,
agentId: null,
// The root was not spawned by anyone, so there is no spawning call to describe.
description: null,
running: root.running,
depth: 0,
origin: [],
@@ -169,6 +189,7 @@ function extractFromSlice(
parentId: string,
parentOrigin: string[],
argsAgentId: string | null,
argsDescription: string | null,
): void => {
if (seen.has(sessionId)) return;
seen.add(sessionId);
@@ -185,6 +206,7 @@ function extractFromSlice(
nodes.push({
sessionId,
agentId: child.meta?.agentId ?? argsAgentId,
description: argsDescription,
running,
depth: origin.length,
origin,
@@ -206,9 +228,11 @@ function extractFromSlice(
parentId,
parentOrigin,
agentIdFromRunSubagentArgs(item.argumentsText),
descriptionFromRunSubagentArgs(item.argumentsText),
);
} else if (item.kind === "subagent") {
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null);
// A standalone child has no spawning card in this stream, so neither argument is available.
addChild(item.sessionId, item.model, false, parentId, parentOrigin, null, null);
}
}
};
@@ -251,8 +275,12 @@ export function modelAtOrigin(model: StreamModel, origin: readonly string[]): St
// ---------------------------------------------------------------------------
/** Fixed node box (avatar + truncated name + elapsed time + status glyph) — no text measurement, so the layout stays pure. */
export const NODE_W = 168;
export const NODE_H = 34;
// Node box: two stacked lines — the Agent name (with elapsed + status) over the spawning
// call's description. Sized uniformly rather than per-node: variable heights would have to be
// threaded through row placement and edge geometry below, for the sake of a few nodes that
// carry no description (the root never does) and simply center their single line instead.
export const NODE_W = 200;
export const NODE_H = 46;
export const GAP_X = 32;
export const GAP_Y = 10;
export const PAD = 6;
+18 -9
View File
@@ -313,12 +313,23 @@ export function ChatPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
// TASK-SCOPED panel visibility (owner rule: an open panel belongs to the task it was opened
// for). The pure tracker (advancePanelTaskScope, unit-tested) decides at each observation:
// - entering a session / a NEW Task starting (a user message — taskStartCount increase)
// closes the panel by default, so an unrelated task never inherits it;
// - the CURRENT task's first live spawn auto-opens it (re-armed per task; a manual close
// afterwards is respected until the next boundary).
// A NEW chat starts with both panels closed: a panel opened for an earlier conversation must
// not carry into a freshly created one. The draft is the reset point — it renders no panels
// itself, so the Session created from it (first send navigates to /chat/:id) begins closed,
// while a plain conversation switch keeps whatever the user had open. This effect owns the
// ONLY automatic close of either panel.
useEffect(() => {
if (!draft) return;
filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft]);
// Subagents panel AUTO-OPEN (the one visibility rule this panel has beyond the Files panel's):
// the pure tracker (advancePanelTaskScope, unit-tested) opens it on the CURRENT task's first
// live spawn, re-armed at every task boundary so a manual close is respected until the next
// one. Boundaries themselves no longer close anything — an open panel now survives Session
// switches and new Tasks alike, matching the Files panel.
// The auto-open applies only when docked (a mobile Sheet sliding over the conversation
// uninvited would be worse than staying discoverable via the row), never over an open Files
// panel (an automatic open must not steal an explicit one — the row and the toolbar's amber
@@ -333,9 +344,7 @@ export function ChatPage() {
taskCount,
liveSpawn,
});
if (action === "close") {
subagentsPanelRaw.setOpen(false);
} else if (
if (
action === "autoOpen" &&
subagentsPanelRaw.isDocked &&
!subagentsPanelRaw.open &&
+109 -123
View File
@@ -55,7 +55,8 @@ import { toastError } from "../../components/ui/toast";
import { useVersionInfo } from "../../lib/use-version-info";
import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { EXAMPLE_TASKS, type ExampleTask, type ExampleTaskId } from "./example-tasks";
import { EXAMPLE_FOLDERS } from "./example-tasks";
import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
import { sameModelRef } from "../models/model-grouping";
@@ -89,6 +90,24 @@ function saveAppliedRouteKey(field: RouteStateField, key: string): void {
}
}
/**
* One glyph per example folder, 16×16. Icons live on the folder rather than on each example:
* with the examples reduced to single-line titles, a column of per-row icons was noise
* competing with the titles, while the folder row is exactly where a glyph earns its place —
* it is what you scan to pick a category.
*
* webapps: a browser window (chrome bar + two dots). agents: the SAME robot head the sidebar's
* Agents entry uses (NAV_ICONS.agents) — deliberately not a generic refresh loop, because the
* app already has one glyph that means "agent" and a folder of agent examples should wear it.
* Duplicated as a literal rather than imported: sidebar.tsx imports from chat-page.tsx, which
* renders this file, so importing it back would close an import cycle.
*/
const FOLDER_GLYPHS: Record<ExampleFolderId, string> = {
webapps:
"M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6zM3 9h18M6 6.5h.01M9 6.5h.01",
agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
};
export function DraftView({
projectId,
models,
@@ -422,6 +441,15 @@ export function DraftView({
[exampleBusy, agentSkills, onSend],
);
/**
* The open example folder — bookmark-style, and ALWAYS exactly one: selecting another closes
* the previous, and clicking the open one is a no-op rather than collapsing it. Never
* nullable on purpose. With every folder the same length, "one open" is what makes the
* block's height a constant: the examples area can neither collapse to bare folder rows nor
* grow, so nothing below it shifts as folders are switched.
*/
const [openFolder, setOpenFolder] = useState<ExampleFolderId>(EXAMPLE_FOLDERS[0].id);
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
@@ -490,131 +518,89 @@ export function DraftView({
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
</div>
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow,
stacked vertically in display order on every viewport. Disabled until
agents/models/skills are resolved (onSend would silently no-op without an Agent);
hover only darkens the border, per the card convention. */}
<div className="mt-6 flex flex-col items-stretch gap-2">
{EXAMPLE_TASKS.map((task) => {
const copy = S.chat.exampleTasks[task.id];
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow.
Bookmark-style folders with ALWAYS exactly one open — selecting another closes the
previous, and the open one cannot be collapsed. The block is therefore a FIXED
height: two folder rows plus one folder's rows, whichever folder that is (they are
kept the same length). Nothing below shifts when folders are switched, and no
scroll container is needed — a scrollbar inside a six-line showcase reads as a
defect. Each example is a single-line title; its one-sentence description rides in
the row tooltip rather than a second line. Rows are disabled until
agents/models/skills are resolved (onSend would silently no-op without an Agent). */}
<div className="mt-6 space-y-1">
{EXAMPLE_FOLDERS.map((folder) => {
const open = folder.id === openFolder;
return (
<button
key={task.id}
type="button"
disabled={exampleBusy !== null || sending || !skillsLoaded || !agentId || !models}
onClick={() => void runExample(task)}
className="group flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition-colors duration-150 hover:border-gray-300 disabled:cursor-default disabled:opacity-60 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
{/* 24×24 line icons (gamepad / music note / sparkle), consistent with the icon convention */}
{task.id === "agentBenchmarkBuild" || task.id === "agentOptimization" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M7 7h8a4 4 0 0 1 4 4v1M17 5l2 2-2 2M17 17H9a4 4 0 0 1-4-4v-1M7 19l-2-2 2-2"
<div key={folder.id}>
{/* A tab, not a disclosure: the open folder stays open (clicking it is a
no-op) and carries the selected fill, so the block always shows one
folder's examples and its height never changes. */}
<button
type="button"
aria-expanded={open}
onClick={() => setOpenFolder(folder.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors duration-150 ${
open
? "bg-gray-100 dark:bg-gray-800/70"
: "hover:bg-gray-100 dark:hover:bg-gray-800/70"
}`}
>
<span className="shrink-0 text-brand-500 dark:text-brand-400">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="2" strokeWidth="1.7" />
</svg>
) : task.id === "lol" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M9 18V6l11-2v12"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="6.5" cy="18" r="2.5" strokeWidth="1.7" />
<circle cx="17.5" cy="16" r="2.5" strokeWidth="1.7" />
</svg>
) : task.id === "game" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M6.7 6h10.6a4 4 0 0 1 3.97 3.56c.2 1.8.73 5.05.73 6.44a3 3 0 0 1-3 3c-1 0-1.5-.5-2-1l-1.4-1.4a2 2 0 0 0-1.42-.6H9.82a2 2 0 0 0-1.41.6L7 18c-.5.5-1 1-2 1a3 3 0 0 1-3-3c0-1.39.52-4.64.73-6.44A4 4 0 0 1 6.7 6z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M6.5 11h4M8.5 9v4M15 12h.01M18 10h.01"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M18.5 15.5l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1z"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{copy.label}
aria-hidden
>
<path d={FOLDER_GLYPHS[folder.id]} />
</svg>
</span>
<span className="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{copy.desc}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-700 dark:text-gray-300">
{S.chat.exampleFolders[folder.id]}
</span>
</span>
{exampleBusy === task.id ? (
<span className="ml-1 shrink-0 text-xs text-gray-400">{S.common.loading}</span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="ml-1 shrink-0 text-gray-300 transition-colors duration-150 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-400"
aria-hidden
>
<path
d="M5 12h14M13 6l6 6-6 6"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{folder.tasks.length}
</span>
<Chevron open={open} size={14} className="text-gray-400" />
</button>
{open && (
<ul className="mt-0.5 space-y-0.5 pl-4">
{folder.tasks.map((task) => {
const copy = S.chat.exampleTasks[task.id];
return (
<li key={task.id}>
<button
type="button"
title={copy.desc}
disabled={
exampleBusy !== null ||
sending ||
!skillsLoaded ||
!agentId ||
!models
}
onClick={() => void runExample(task)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
>
<span className="min-w-0 flex-1 truncate">{copy.label}</span>
{exampleBusy === task.id && (
<span className="shrink-0 text-xs text-gray-400">
{S.common.loading}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
</button>
</div>
);
})}
</div>
@@ -628,8 +614,8 @@ export function DraftView({
/**
* Superscript "new version" pill on the version line (accent-colored, raised via
* align-super). Kept literally identical to the sidebar footer's copy in
* components/layout/sidebar.tsx — the two surfaces must not drift apart.
* align-super). The only remaining copy: the sidebar's version row dropped its badge when
* the three update rows collapsed into one whose label already names the new version.
*/
const versionBadgeClass =
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
@@ -642,8 +628,8 @@ const versionBadgeClass =
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
* dev builds and releases that predate the stamping (v0.1.2 and earlier) carry null
* and show the version alone. When the update check knows a newer release, a small
* superscript badge follows, linking to the release page (this surface's existing
* affordance; the sidebar's badge additionally offers admins the update dialog).
* superscript badge follows, linking to the release page (this surface's affordance; the
* sidebar user menu instead routes its single update row into the update dialog).
* Fetching starts on mount — useVersionInfo caches at module level, so after the first
* resolution anywhere in the app this renders instantly and never refetches. Nothing
* renders until the version resolves (no placeholder flicker under the brand).
+41 -11
View File
@@ -1,17 +1,47 @@
/**
* Draft-screen example cards in display order.
* Draft-screen example cards, filed into collapsible folders in display order.
*
* Copy and full prompts live in the active locale dictionary at
* `S.chat.exampleTasks[id]`. Skills listed here are pinned only when the
* selected Agent has them installed; an empty list sends the prompt unchanged.
* Folders are what lets the showcase grow past a flat list: exactly ONE folder is open at a
* time (bookmark-style — opening one closes the other), so the block's height is one row per
* folder plus the open folder's own rows, never the whole catalog. Adding an example means
* appending it to the folder it belongs to, not lengthening the page.
*
* Keep the folders similarly sized: the draft page reserves no scroll area for this block, so
* a folder much longer than its siblings is what would make the height jump between them.
*
* Copy and full prompts live in the active locale dictionary at `S.chat.exampleFolders[id]`
* and `S.chat.exampleTasks[id]`. Skills listed here are pinned only when the selected Agent has
* them installed; an empty list sends the prompt unchanged.
*/
export const EXAMPLE_TASKS = [
{ id: "game", skills: ["web-design"] },
{ id: "lol", skills: ["web-design"] },
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
{ id: "agentBenchmarkBuild", skills: [] },
{ id: "agentOptimization", skills: [] },
export const EXAMPLE_FOLDERS = [
{
id: "webapps",
tasks: [
{ id: "game", skills: ["web-design"] },
{ id: "gamecenter", skills: ["web-design"] },
{ id: "lol", skills: ["web-design"] },
],
},
{
id: "agents",
tasks: [
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
{ id: "agentBenchmarkBuild", skills: [] },
{ id: "agentOptimization", skills: [] },
],
},
] as const;
export type ExampleTask = (typeof EXAMPLE_TASKS)[number];
export type ExampleFolder = (typeof EXAMPLE_FOLDERS)[number];
export type ExampleFolderId = ExampleFolder["id"];
export type ExampleTask = ExampleFolder["tasks"][number];
export type ExampleTaskId = ExampleTask["id"];
/**
* Flat view of every example across folders, in display order. The folders drive the UI; this
* is for whole-catalog work that doesn't care which folder an example sits in — looking one up
* by id, or asserting across all of them.
*/
export const EXAMPLE_TASKS: readonly ExampleTask[] = EXAMPLE_FOLDERS.flatMap((folder) => [
...folder.tasks,
]);
@@ -164,7 +164,8 @@ export function SubagentsView({
</div>
) : (
<>
{/* Slim identity strip for the conversation below. */}
{/* Slim identity strip for the conversation below. The spawning call's description
belongs to its node in the graph above, not here — one line, one place. */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-100 px-3 py-1.5 dark:border-gray-800/60">
<AgentAvatar
id={activeModel.meta?.agentId ?? active.sessionId}
+15 -108
View File
@@ -1,7 +1,8 @@
/**
* Files panel state machine: panel open/close, drag-to-resize, the responsive breakpoint for
* desktop docking vs. falling back to a mobile drawer, and the "locate a file in the directory
* tree" navigation command (driven by clicking a file chip inside a message).
* Files panel state machine: panel open/close, the responsive breakpoint for desktop docking
* vs. falling back to a mobile drawer, and the "locate a file in the directory tree" navigation
* command (driven by clicking a file chip inside a message). Drag-to-resize and the width live
* in use-panel-width.ts, shared with the Agents panel so the two open at the same width.
*
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
* structured file-write signal at all (file writes can happen inside the opaque exec_command shell),
@@ -9,18 +10,17 @@
* locating it in the tree.
*
* Only the navigation command resets when sessionId changes; the open/closed state persists
* across sessions (once opened, it stays open); width is a layout preference, not session data,
* so it isn't reset, and it's persisted to localStorage (same as app-layout.tsx's
* sidebarCollapsed). Both the default width and the cap scale proportionally with the window
* width (matching the ~1/3-of-window feel of Codex's Review panel); a dragged preference takes
* priority over the proportional default; double-clicking the handle clears the preference and
* reverts to the proportional default.
* across Sessions (once opened, it stays open — a panel the user opened is part of their
* browsing environment, and switching conversations shouldn't collapse it). Starting a NEW chat
* is the one reset point, and it belongs to the chat page, which owns both panels: see the
* draft effect in chat-page.tsx.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { useCallback, useEffect, useState } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
import { usePanelWidth } from "./use-panel-width";
import type { PanelWidthState } from "./use-panel-width";
export interface FilesPanelState {
export interface FilesPanelState extends PanelWidthState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet (half = browsing / full = preview); unused in the desktop docked state. */
@@ -36,35 +36,9 @@ export interface FilesPanelState {
* as the window width changes — docked and Drawer are mounted mutually exclusively; mounting
* both at once would cause WorkspaceBrowser's own data requests to fire twice. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default, and the stored preference is cleared. */
resetWidth: () => void;
/** Ref to the docked panel's root node: drag-to-resize uses its right edge to compute the target width. */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.filesPanelWidth";
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window (matching Codex's Review panel proportion), clamped within the min/max bounds. */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds, to prevent an oversized value carried over from another device) takes priority over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useFilesPanel(sessionId: string | null): FilesPanelState {
const [open, setOpenRaw] = useState(false);
@@ -78,16 +52,11 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
if (next) setSheetSnap("half");
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** A synchronous mirror of width: read by the mouseup persist step, sidestepping the stale state captured in the event closure. */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const widthState = usePanelWidth();
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session/Agent only resets the navigation command (which pointed at a file in the
// old session); the open/closed state persists across sessions — a workspace panel the user
// opened is part of their browsing environment, and switching sessions shouldn't collapse it.
// old session); the open/closed state deliberately survives — see the header note.
useEffect(() => {
setOpenRequest(null);
}, [sessionId]);
@@ -104,64 +73,6 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
setOpenRequest({ path });
}, []);
// Drag-to-resize: during mousemove, computes the width from the panel's right edge and clamps
// it within the min/max bounds; locks the cursor/selection during the drag to avoid
// accidentally selecting page text on a fast drag (standard, necessary handling for
// drag-to-resize, not something to skip just because there's no prior precedent here).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
// Only persist the preference once the drag ends: mousemove fires every frame, and it's not worth writing to localStorage on every frame.
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clears rather than writing the default value: this way the default keeps following the window's proportion going forward, instead of being frozen at the current pixel value.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap to prevent the docked panel
// from crowding out the chat column. Only shrinks, never grows back: this doesn't overwrite
// the stored preference; enlarging the window again relies on a refresh or double-clicking the handle to restore it.
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
@@ -170,10 +81,6 @@ export function useFilesPanel(sessionId: string | null): FilesPanelState {
browsePath,
openRequest,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
...widthState,
};
}
@@ -0,0 +1,177 @@
/**
* Width shared by the chat page's two docked panels (Workspace files and Agents), plus the
* drag-to-resize machinery both of them mount.
*
* The panels are MUTUALLY EXCLUSIVE — opening one closes the other — so to the user they read
* as a single right-hand panel that swaps its content. Two independent widths made that swap
* jump, which is why the width is one value here rather than a per-panel preference: a width
* dragged on either panel is the width the other one opens at, immediately and after a reload.
*
* "Immediately" is what rules out two `useState`s over one storage key: only the panel that was
* mounted and dragged would update, and the other would keep a stale copy until its next
* remount. So the value lives in a module-level store both hooks subscribe to via
* useSyncExternalStore, and the persisted preference is written once per drag (mouseup), not
* per frame.
*
* Width is a layout preference, not session data: it is never reset on a Session switch.
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
const MIN_WIDTH = 320;
const WIDTH_STORAGE_KEY = "penguin.panelWidth";
/**
* Pre-unification per-panel keys, adopted once on first load so a dragged width survives the
* merge instead of silently snapping back to the default. The WIDER of the two wins: the two
* panels were sized independently, so picking either one arbitrarily could hand the merged
* panel the narrower of the user's two choices — visibly a regression on the panel that used
* to be wide. The migration deletes the keys as it reads, so it is already dead code for anyone
* who has opened the app once since this shipped.
*
* REMOVAL: delete this constant and storedWidth()'s legacy branch in the release AFTER the one
* shipping this change, as part of preparing that release. Nothing else in the repo reads these
* keys, so it is a pure deletion. See changelog/unreleased/2026-07-30-backward-compatibility.md.
*/
const LEGACY_WIDTH_KEYS = ["penguin.filesPanelWidth", "penguin.subagentsPanelWidth"] as const;
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
export function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/**
* Default width ≈ 40% of the window, clamped within the min/max bounds. Deliberately wider than
* the old ~1/3: one panel now serves both the file tree and the subagent transcript, and the
* transcript is the demanding tenant — a third of the window renders it as a narrow column of
* wrapped tool output.
*/
export function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.4)));
}
/** Reads the stored preference, migrating a legacy per-panel key the first time. Returns null when nothing is stored. */
function storedWidth(): number | null {
try {
const own = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (Number.isFinite(own) && own > 0) return own;
let adopted: number | null = null;
for (const key of LEGACY_WIDTH_KEYS) {
const legacy = Number(localStorage.getItem(key));
// Widest wins — see LEGACY_WIDTH_KEYS: the merged panel must not come out narrower than
// either panel the user had sized.
if (Number.isFinite(legacy) && legacy > 0) adopted = Math.max(adopted ?? 0, legacy);
localStorage.removeItem(key);
}
if (adopted !== null) localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(adopted)));
return adopted;
} catch {
return null; // quota / private mode: fall back to the proportional default
}
}
/** Initial width: the stored preference (clamped back within this window's bounds, so an oversized value carried over from another device can't crowd out the chat column) over the proportional default. */
function initialWidth(): number {
const stored = storedWidth();
if (stored === null) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
// —— Module-level store: one width, every subscriber re-renders on change ——
let sharedWidth: number | null = null;
const listeners = new Set<() => void>();
function readWidth(): number {
sharedWidth ??= initialWidth();
return sharedWidth;
}
function writeWidth(next: number): void {
if (next === sharedWidth) return;
sharedWidth = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export interface PanelWidthState {
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default, and the stored preference is cleared. */
resetWidth: () => void;
/** Ref to the docked panel's root node: drag-to-resize uses its right edge to compute the target width. */
panelRef: RefObject<HTMLDivElement | null>;
}
/**
* The shared width plus this panel's own drag state. `resizing` and `panelRef` stay per-panel
* (each panel has its own DOM node and its own handle); only the width crosses between them.
*/
export function usePanelWidth(): PanelWidthState {
const width = useSyncExternalStore(subscribe, readWidth, readWidth);
const [resizing, setResizing] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
// Drag-to-resize: during mousemove, computes the width from the panel's right edge and clamps
// it within the min/max bounds; locks the cursor/selection during the drag to avoid
// accidentally selecting page text on a fast drag.
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
writeWidth(Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX)));
};
// Only persist once the drag ends: mousemove fires every frame, and it's not worth writing to localStorage on every frame.
const onUp = () => {
setResizing(false);
try {
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(readWidth())));
} catch {
/* best-effort persistence (quota / private mode) */
}
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
writeWidth(defaultWidthFor(window.innerWidth));
// Clears rather than writing the default value: this way the default keeps following the
// window's proportion going forward, instead of being frozen at the current pixel value.
try {
localStorage.removeItem(WIDTH_STORAGE_KEY);
} catch {
/* best-effort */
}
}, []);
// When the window shrinks, clamp the width back within the cap so the docked panel can't
// crowd out the chat column. Shrinks only, never grows back, and never overwrites the stored
// preference: enlarging the window again relies on a refresh or a double-click on the handle.
// Both panels register this; the clamp is idempotent, so the duplicate is harmless.
useEffect(() => {
const onResize = () => writeWidth(Math.min(readWidth(), maxWidthFor(window.innerWidth)));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return { width, resizing, startResize, resetWidth, panelRef };
}
@@ -1,27 +1,27 @@
/**
* Subagents panel state machine (cloned from use-files-panel.ts, which documents the shared
* mechanics in detail): panel open/close, drag-to-resize with its own persisted width key, the
* desktop-dock vs. mobile-Sheet breakpoint, the "focus this child conversation" command driven
* by clicking a subagent chip inside a message, and the displayed Task scope (latest vs. the
* historical Task a chip was clicked on — see taskScope).
* Subagents panel state machine (a sibling of use-files-panel.ts, which documents the shared
* mechanics): panel open/close, the desktop-dock vs. mobile-Sheet breakpoint, the "focus this
* child conversation" command driven by clicking a subagent chip inside a message, and the
* displayed Task scope (latest vs. the historical Task a chip was clicked on — see taskScope).
* Width and drag-to-resize live in use-panel-width.ts, shared with the Files panel so the two
* mutually exclusive panels always open at the same width.
*
* VISIBILITY IS TASK-SCOPED — this deliberately diverges from the Files panel's
* open-persists-across-sessions convention: an open panel belongs to the task it was opened
* for. Starting a new Task (a user message) closes it by default, entering a session starts
* closed, and it comes back only via a manual open (toolbar/chip) or the CURRENT task spawning
* a subagent (auto-open, re-armed per task). The pure tracker below
* (createPanelTaskScope/advancePanelTaskScope) owns those boundary decisions; the chat page
* observes the stream and applies its actions.
* Visibility now follows the Files panel: an open panel survives a Session switch, and only a
* NEW chat resets both to closed (the chat page owns that reset — it owns both panels). What
* stays specific to this panel is the AUTO-OPEN: the current Task's first live spawn opens it
* once, re-armed at each task boundary, so a manual close mid-task is respected. The pure
* tracker below (createPanelTaskScope/advancePanelTaskScope) owns that decision; the chat page
* observes the stream and applies it under its own layout guards.
*
* Width remains a layout preference persisted to localStorage, and the focus command uses the
* fresh-object idiom so clicking the same chip again still re-triggers the panel's focus
* effect.
* The focus command uses the fresh-object idiom so clicking the same chip again still
* re-triggers the panel's focus effect.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { useCallback, useEffect, useState } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
import { usePanelWidth } from "./use-panel-width";
import type { PanelWidthState } from "./use-panel-width";
export interface SubagentsPanelState {
export interface SubagentsPanelState extends PanelWidthState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet; unused in the desktop docked state. */
@@ -42,21 +42,12 @@ export interface SubagentsPanelState {
taskScope: { anchorSessionId: string } | null;
/** Docked at >=1024px (lg); otherwise a bottom Sheet — mounted mutually exclusively. */
isDocked: boolean;
width: number;
resizing: boolean;
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
/** Double-clicking the drag handle: width reverts to the window-proportional default. */
resetWidth: () => void;
/** Ref to the docked panel's root node (drag-to-resize measures its right edge). */
panelRef: RefObject<HTMLDivElement | null>;
}
const MIN_WIDTH = 320;
const DOCK_QUERY = "(min-width: 1024px)";
const WIDTH_STORAGE_KEY = "penguin.subagentsPanelWidth";
// ---------------------------------------------------------------------------
// Task-scoped visibility tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// Auto-open tracker (pure — unit-tested in test/panel-task-scope.test.ts)
// ---------------------------------------------------------------------------
/**
@@ -76,29 +67,27 @@ export function createPanelTaskScope(): PanelTaskScope {
}
/**
* Advance the tracker with one observation of the current session's stream and return what the
* panel should do — the whole task-scoped lifecycle in one place:
* - session switch → "close" (a session is entered closed; no inherited open state), unless
* its CURRENT task already has a live spawn, which wins as "autoOpen" (a mid-run entry —
* reload included — counts as the spawn introducing itself);
* - a new Task (taskCount increase) → "close" by default (an unrelated task must not inherit
* an open panel) and RE-ARMS the auto-open;
* - a live spawn in the current task → "autoOpen", at most once per task — a manual close
* Advance the tracker with one observation of the current session's stream and return whether
* the panel should auto-open:
* - a live spawn in the current task → "autoOpen", at most once per task, so a manual close
* afterwards is respected until the next boundary;
* - a boundary (a Session switch, or a new Task within the session) RE-ARMS that one attempt
* but is never itself an action — an open panel is not closed here any more. Entering a
* session mid-run therefore auto-opens on the spawn that is already live, which reads as
* that spawn introducing itself;
* - anything else (steering, compaction, more messages in the same task) → null.
* A taskCount DECREASE is a defensive re-baseline (a resync swapped in a smaller model):
* adopted silently — no boundary, and the auto-open attempt counts as consumed so a rebuild
* can never surprise-reopen a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, files panel closed, not
* adopted silently, with the auto-open attempt marked consumed so a rebuild can never
* surprise-open a panel the user closed mid-task.
* The caller applies "autoOpen" under its own layout guards (docked, Files panel closed, not
* already open); the attempt is consumed here regardless, so a suppressed attempt never
* retriggers within the same task.
*/
export function advancePanelTaskScope(
state: PanelTaskScope,
obs: { sessionId: string | null; taskCount: number; liveSpawn: boolean },
): "close" | "autoOpen" | null {
): "autoOpen" | null {
const switched = obs.sessionId !== state.sessionId;
const newTask = !switched && obs.taskCount > state.taskCount;
const rebaseline = !switched && obs.taskCount < state.taskCount;
if (switched || obs.taskCount !== state.taskCount) {
state.sessionId = obs.sessionId;
@@ -110,27 +99,9 @@ export function advancePanelTaskScope(
state.autoOpenedAt = state.taskCount;
return "autoOpen";
}
if (switched || newTask) return "close";
return null;
}
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
function maxWidthFor(windowWidth: number): number {
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
}
/** Default width ≈ 1/3 of the window, clamped within the min/max bounds (same proportion as the Files panel). */
function defaultWidthFor(windowWidth: number): number {
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
}
/** Initial width: stored preference (clamped back within the current window's bounds) over the proportional default. */
function initialWidth(): number {
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
}
export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState {
const [open, setOpenRaw] = useState(false);
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
@@ -152,18 +123,13 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
}
setOpenRaw(next);
}, []);
const [width, setWidth] = useState(initialWidth);
/** Synchronous mirror of width, read by the mouseup persist step (sidesteps the stale closure). */
const widthRef = useRef(width);
const [resizing, setResizing] = useState(false);
const widthState = usePanelWidth();
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
const panelRef = useRef<HTMLDivElement>(null);
// Switching Session resets the focus command and any pinned historical Task scope (both
// pointed into the old session's stream — the new session opens on its latest topology).
// The open/closed state is NOT touched here: visibility is task-scoped and owned by the
// chat page's tracker (advancePanelTaskScope above), which closes on session entry and new
// Tasks, and auto-opens on the current task's first live spawn.
// The open/closed state is NOT touched: like the Files panel, an open panel survives the
// switch, and only a new chat closes it (chat-page.tsx).
useEffect(() => {
setFocusRequest(null);
setTaskScope(null);
@@ -184,58 +150,6 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
setFocusRequest({ sessionId: sid, origin });
}, []);
// Drag-to-resize: identical handling to the Files panel (see use-files-panel.ts for the rationale comments).
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const rect = panelRef.current?.getBoundingClientRect();
const right = rect ? rect.right : window.innerWidth;
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
widthRef.current = next;
setWidth(next);
};
const onUp = () => {
setResizing(false);
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
setResizing(true);
}, []);
const resetWidth = useCallback(() => {
const next = defaultWidthFor(window.innerWidth);
widthRef.current = next;
setWidth(next);
// Clear rather than write the default: the default keeps following the window's proportion.
localStorage.removeItem(WIDTH_STORAGE_KEY);
}, []);
// When the window shrinks, clamp the panel back within the cap (shrinks only, like the Files panel).
useEffect(() => {
const onResize = () => {
setWidth((w) => {
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
if (clamped !== w) widthRef.current = clamped;
return clamped;
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return {
open,
setOpen,
@@ -245,10 +159,6 @@ export function useSubagentsPanel(sessionId: string | null): SubagentsPanelState
focusRequest,
taskScope,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
...widthState,
};
}