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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user