Files
penguin-harness/packages/web/src/features/chat/draft-view.tsx
T

1017 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Draft view (/chat/new): the pre-persistence form of a new
* conversation, before any Session exists. The input card sits vertically centered;
* before sending, this is where Agent / Workspace / approval mode / Model are all
* chosen in one place — two small dropdown pills sit right below the card (pill
* buttons, styled after ChatGPT's project picker): Agent selection and Workspace
* directory selection (the menu browses server-side directories, and the current
* path can be edited directly); the model picker lives in the input card's bottom
* toolbar, left of the send button (with a vendor logo). The Session is only
* created when **the first message is sent**; once created, Agent / Workspace /
* Model are locked in via meta, and only approval mode remains editable (in the
* session-mode input area).
*
* Draft auto-cache (storage and validation in draft-cache.ts; keys are isolated by
* "user × Project", #68): the four selections are saved as soon as they change;
* body text is keystroke-frequent and deferred/coalesced (if there's an unsaved
* change before unmount, one final write is flushed) — closing and returning to
* the page resumes where you left off; on successful send the cache clears, except
* the model selection, which carries over as the next conversation's default
* (switch-becomes-default, mirroring the thinking level persisting on the Agent).
* The sidebar group header "+" / menu "New conversation" explicitly specify an
* Agent via route state (overriding the cached selection); the workspace-mode
* group header "+" additionally carries a Workspace path pre-filling the
* Workspace selection ("" = auto temp directory). A direct visit or refresh
* falls back to the cache.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useLocation, useNavigate } from "react-router";
import type {
AgentModelConfigDto,
AgentSummary,
ApprovalMode,
DirListResponse,
ModelRefDto,
ModelsResponse,
SessionCreateRequest,
SkillMetadataItem,
TaskInputPart,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { S } from "../../lib/strings";
import { formatMonthDay } from "../../lib/format";
import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth";
import { useLocale } from "../../state/locale";
import { agentDisplayName, useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { Chevron } from "../../components/ui/chevron";
import { Dropdown } from "../../components/ui/dropdown";
import { noAutofill } from "../../components/ui/input";
import { PenguinLogo } from "../../components/ui/penguin-logo";
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_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";
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
const DRAFT_SAVE_DEBOUNCE_MS = 300;
/**
* "Applied" markers for the route-state overrides (one slot per field, holding the last
* consumed location.key). React Router persists location.state AND location.key in
* history.state, which survives a full page reload, while a ref resets with the JS
* context — with a ref alone, a reload would re-apply the override and clobber whatever
* the user changed since (restored from the draft cache). sessionStorage is per-tab
* exactly like history.state, so the marker follows the history entry; on storage
* failure (private mode) both helpers degrade to "not consumed", and the in-component
* ref still provides the previous apply-once-per-mount behavior.
*/
type RouteStateField = "agentId" | "workspace";
function loadAppliedRouteKey(field: RouteStateField): string | null {
try {
return sessionStorage.getItem(`penguin.chatRouteApplied.${field}`);
} catch {
return null;
}
}
function saveAppliedRouteKey(field: RouteStateField, key: string): void {
try {
sessionStorage.setItem(`penguin.chatRouteApplied.${field}`, key);
} catch {
/* best-effort: the dedup marker falls back to the per-mount ref */
}
}
/**
* 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,
}: {
projectId: string;
/** Project model config (already fetched by ChatPage): candidate list and default model. */
models: ModelsResponse | null;
}) {
const navigate = useNavigate();
const location = useLocation();
const { agents, currentAgent, setCurrentAgentId } = useProject();
const { add } = useSessions();
// The draft key includes a user dimension (#68 cross-account leakage). RequireAuth
// guarantees the user is logged in here; on the off chance there's no user (the
// type allows null), it's better to disable caching entirely than to read/write a
// key that isn't account-scoped.
const userId = useAuth().user?.userId ?? null;
// The cache is read only once, on mount: the component remounts keyed by Project,
// so switching Projects automatically switches to the corresponding draft; switching
// accounts always goes through logout (clearing the user unmounts the whole route
// tree), so logging back in is likewise a fresh mount.
const [cached] = useState<DraftCache>(() =>
userId ? loadDraft(draftKey(userId, projectId)) : {},
);
const [agentId, setAgentId] = useState<string | null>(
cached.agentId ?? currentAgent?.agentId ?? null,
);
const [workspace, setWorkspace] = useState(cached.workspace ?? "");
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(
cached.approvalMode ?? "allow-all",
);
const [modelRef, setModelRef] = useState<ModelRefDto | null>(cached.modelRef ?? null);
const textRef = useRef(cached.text ?? "");
/**
* Selected skills (prefilled by "quick invoke" from the Skills page + checked in
* the input area): passed to ChatInput as the initial selection via initialSkills
* on mount, then written back through onSkillsChange and persisted immediately
* (discrete clicks) — survives a refresh; cleared along with the whole draft on
* successful send, kept on failure so it can be resent.
*/
const skillsRef = useRef<string[]>(cached.skills ?? []);
// Unified resolution of the Agent selection (a single effect, single writer):
// explicit route state > current valid value (from cache / panel selection) >
// default_agent > the first one. Explicit intent (sidebar group header "+" / menu
// "New conversation") is applied only once per location.key — clicking "+" again
// for the same Agent gets a new key and re-aligns, while the user's subsequent
// reselection in the panel won't keep getting overridden. Merging this into one
// effect is essential: splitting it into an "apply state" effect and a "fallback
// on invalid value" effect would let the former write B in one render while the
// latter, still judging by the stale closure's invalid value, writes the default
// Agent and clobbers B.
const routeState = location.state as { agentId?: string; workspace?: string } | null;
const stateAgentId = routeState?.agentId;
const appliedStateKey = useRef<string | null>(null);
useEffect(() => {
if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass
const valid = (id: string | null | undefined): id is string =>
!!id && agents.some((a) => a.agentId === id);
if (
stateAgentId &&
appliedStateKey.current !== location.key &&
loadAppliedRouteKey("agentId") !== location.key
) {
appliedStateKey.current = location.key;
saveAppliedRouteKey("agentId", location.key);
if (valid(stateAgentId)) {
setAgentId(stateAgentId);
return;
}
}
if (valid(agentId)) return;
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
}, [agents, agentId, location.key, stateAgentId]);
// Explicit Workspace from route state (the workspace-mode group header "+"): applied once per
// location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills
// the auto temp directory). Unlike the Agent there's no list to validate against, so this is a
// separate effect that never has to wait for a load.
const stateWorkspace = routeState?.workspace;
const appliedWorkspaceKey = useRef<string | null>(null);
useEffect(() => {
if (
stateWorkspace === undefined ||
appliedWorkspaceKey.current === location.key ||
loadAppliedRouteKey("workspace") === location.key
) {
return;
}
appliedWorkspaceKey.current = location.key;
saveAppliedRouteKey("workspace", location.key);
setWorkspace(stateWorkspace);
}, [location.key, stateWorkspace]);
// Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference).
useEffect(() => {
if (!models) return;
if (modelRef && models.models.some((m) => sameModelRef(m, modelRef))) return;
const first = models.models[0];
setModelRef(
models.defaultModel ?? (first ? { provider: first.provider, modelId: first.modelId } : null),
);
}, [models, modelRef]);
// —— Conversation-time thinking level (backed by the Agent settings) ——
// Shows the selected Agent's current `model.thinking_level` ("" = no override); picking a
// level immediately persists it via the agent-config API (the PUT carries only that key —
// the server merges per-key into the YAML, so nothing else is clobbered). The session created
// on first send reads systemConfig fresh, so it runs with the picked level, which also
// becomes the Agent's new default. Refetched whenever the draft's Agent changes; while
// loading (or after a failed fetch) the picker stays disabled (null).
const [thinkingLevel, setThinkingLevel] = useState<string | null>(null);
useEffect(() => {
setThinkingLevel(null);
if (!agentId) return;
let cancelled = false;
api
.getAgentConfig(projectId, agentId)
.then((res) => {
if (!cancelled) setThinkingLevel(res.config.model?.thinkingLevel ?? "");
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [projectId, agentId]);
/** Live mirror for the rollback value (a stale closure would roll back to an outdated level). */
const thinkingRef = useRef<string | null>(null);
thinkingRef.current = thinkingLevel;
const onChangeThinkingLevel = useCallback(
(level: string) => {
// "" (no override) is not persistable through the config API — the picker disables that row.
if (!agentId || !level) return;
const rollback = thinkingRef.current;
setThinkingLevel(level); // Optimistic: the picker reflects the choice immediately.
api
.putAgentConfig(projectId, agentId, {
config: { model: { thinkingLevel: level as AgentModelConfigDto["thinkingLevel"] } },
})
.catch((e: unknown) => {
setThinkingLevel(rollback);
toastError(apiErrorText(e));
});
},
[projectId, agentId],
);
// Skills installed on the currently selected Agent (candidates for the input
// area's skills dropdown): switching Agents first clears the list (which also
// clears the selection in the input area), then refetches; a fetch failure is
// silently treated as no skills. Clearing preserves the reference when already
// empty (doesn't swap in a new array): swapping the reference on the very first
// mount render would trigger ChatInput's pruning effect and wrongly clear the
// quick-invoke preselection.
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
/** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `[use_skills]` pinning doesn't silently depend on network timing. */
const [skillsLoaded, setSkillsLoaded] = useState(false);
useEffect(() => {
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
setSkillsLoaded(false);
if (!agentId) return;
let cancelled = false;
api
.getAgentSkills(projectId, agentId)
.then((res) => {
if (!cancelled) setAgentSkills(res.skills);
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setSkillsLoaded(true);
});
return () => {
cancelled = true;
};
}, [projectId, agentId]);
// —— Auto-cache ——
// Options (Agent / Workspace / approval mode / Model) are discrete clicks: written
// immediately on change; body text is keystroke-frequent: debounced trailing write,
// with a final flush on unmount if there's an unsaved change.
const saveTimer = useRef<number | null>(null);
const cancelPendingSave = useCallback(() => {
if (saveTimer.current !== null) {
window.clearTimeout(saveTimer.current);
saveTimer.current = null;
}
}, []);
const persistNow = useCallback(() => {
cancelPendingSave();
if (!userId) return;
const data: DraftCache = { text: textRef.current, workspace, approvalMode };
if (agentId) data.agentId = agentId;
if (modelRef) data.modelRef = modelRef;
if (skillsRef.current.length > 0) data.skills = skillsRef.current;
saveDraft(draftKey(userId, projectId), data);
}, [cancelPendingSave, userId, projectId, agentId, workspace, approvalMode, modelRef]);
// The timer and unmount cleanup read persistNow via a ref to always get the **latest version**: a stale closure would write back outdated options.
const persistRef = useRef(persistNow);
useEffect(() => {
persistRef.current = persistNow;
// Write immediately on option change (also writes once on mount, idempotently).
persistNow();
}, [persistNow]);
const onTextChange = useCallback(
(text: string) => {
textRef.current = text;
cancelPendingSave();
saveTimer.current = window.setTimeout(() => {
saveTimer.current = null;
persistRef.current();
}, DRAFT_SAVE_DEBOUNCE_MS);
},
[cancelPendingSave],
);
/** Skill checklist change: writes back to the ref and persists immediately (discrete click, same convention as Agent/Model and other options). */
const onSkillsChange = useCallback((names: string[]) => {
skillsRef.current = names;
persistRef.current();
}, []);
// Unmount: if there's still unsaved body text, flush it (so a route change/page switch doesn't lose the last few keystrokes).
useEffect(
() => () => {
if (saveTimer.current !== null) {
window.clearTimeout(saveTimer.current);
persistRef.current();
}
},
[],
);
/**
* Discard the draft after a successful send: first cancels the pending save timer, otherwise
* it would write the just-cleared draft back. The **model selection carries over** as the
* next conversation's default (review: switching the model, like switching the thinking
* level, makes the switched-to value the new default — the level persists on the Agent
* config, the model here in the per-user draft cache); everything else clears.
*/
const discardDraft = useCallback(() => {
cancelPendingSave();
// Clear the preselected skills too: any subsequent write (e.g. the unmount flush) must not resurrect a selection that's already been sent.
skillsRef.current = [];
if (!userId) return;
if (modelRef) saveDraft(draftKey(userId, projectId), { modelRef });
else clearDraft(draftKey(userId, projectId));
}, [cancelPendingSave, userId, projectId, modelRef]);
const selectAgent = (a: AgentSummary) => {
setAgentId(a.agentId);
// Follow through to the global current Agent: keeps the sidebar memory and stats convention consistent.
setCurrentAgentId(a.agentId);
};
// One in-flight guard shared by both send entry points (composer send / example task): a
// second submission while one is running would create a second Session with
// its own first task and a racing navigation. The ref is the synchronous guard; the state
// drives disabled styling on the example button (the composer has its own busy state).
const sendingRef = useRef(false);
const [sending, setSending] = useState(false);
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
// mode are all locked in together), then the route jumps once sent; returns false on any
// failure, so the input area keeps the draft and can resend. `keepDraft` is set by sends
// that did not consume the composer text (the example task), so a typed-but-unsent draft
// survives the navigation instead of being silently discarded.
const onSend = useCallback(
async (
input: TaskInputPart[],
keepDraft = false,
goal: { budget: number } | null = null,
): Promise<boolean> => {
if (!agentId || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
let createdId: string | null = null;
try {
const body: SessionCreateRequest = { approvalMode };
// Model reference is submitted as a pair (provider + modelId; falls back to the Project default when not set).
if (modelRef) {
body.modelId = modelRef.modelId;
body.provider = modelRef.provider;
}
if (workspace.trim()) body.workspace = workspace.trim();
const created = await api.createSession(projectId, agentId, body);
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input, ...(goal ? { goal } : {}) });
add(created.session);
if (!keepDraft) discardDraft();
navigate(`/chat/${res.sessionId}`, { replace: true });
return true;
} catch (e) {
// The Session was created but the first message failed to send (postTask failed): delete
// this empty Session, otherwise every resend attempt would create another one, piling up
// empty sessions with no messages in the sidebar (best-effort cleanup).
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
},
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
);
// Example tasks: one click submits the canned prompt exactly like a hand-typed send (the
// busy id drives the clicked card's spinner; the shared in-flight guard and all failure
// handling live in onSend). keepDraft: an example never consumes the composer text, so a
// typed-but-unsent draft must survive. The selected model / Workspace / approval mode apply as-is.
const [exampleBusy, setExampleBusy] = useState<ExampleTaskId | null>(null);
const runExample = useCallback(
async (task: ExampleTask) => {
if (exampleBusy !== null) return;
setExampleBusy(task.id);
try {
const names = task.skills.filter((n) => agentSkills.some((s) => s.name === n));
await onSend(
[{ type: "text", text: buildSkillsMessage(names, S.chat.exampleTasks[task.id].prompt) }],
true,
);
} finally {
setExampleBusy(null);
}
},
[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).
const modelInfo = models?.models.find((m) => sameModelRef(m, modelRef));
const contextWindow = modelInfo?.contextWindow;
const vision = modelInfo?.vision !== false;
return (
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-6 md:px-4">
{/*
* Vertical layout: everything visible — brand, input card, ownership pills, example tasks —
* lives in ONE block between two empty flex-1 spacers, so the block is centred and the free
* space above and below it is exactly equal. The brand deliberately sits inside that block
* rather than in the upper spacer: keeping it in the spacer made the upper gap shorter than
* the lower one by the brand's own height, which pushed the card up the viewport and left
* the slash menu — it opens upward, `bottom-full` — too little room, so it clipped against
* the top of this scroll container. When the viewport is too short the spacers collapse to
* nothing, the container's own py-6 keeps the content off the edges, and the page falls back
* to natural scrolling.
*/}
<div className="flex-1" />
<div className="mx-auto w-full max-w-3xl">
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
heading). The asset is square-cropped and the graphic already has a bit of built-in
padding, so a small margin is enough to sit visually close to the title. */}
<div className="mb-10 text-center">
<PenguinLogo className="mx-auto mb-1 h-36 w-36 rounded-3xl" />
<h1 className="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
{S.appName}
</h1>
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
<VersionLine />
</div>
<ChatInput
status="idle"
onSend={(input, goal) => onSend(input, false, goal)}
onStop={async () => undefined}
onCompact={async () => undefined}
modelRef={modelRef}
models={models?.models ?? []}
onChangeModel={setModelRef}
thinkingLevel={thinkingLevel}
onChangeThinkingLevel={onChangeThinkingLevel}
{...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})}
{...(contextWindow !== undefined ? { contextWindow } : {})}
contextNow={0}
vision={vision}
approvalMode={approvalMode}
onChangeApprovalMode={setApprovalMode}
modeSaving={false}
autoFocus
agents={agents}
{...(agentId ? { currentAgentId: agentId } : {})}
skills={agentSkills}
{...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})}
onSkillsChange={onSkillsChange}
initialText={cached.text ?? ""}
onTextChange={onTextChange}
/>
{/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
</div>
{/* 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 (
<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"
aria-hidden
>
<path d={FOLDER_GLYPHS[folder.id]} />
</svg>
</span>
<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 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>
)}
</div>
);
})}
</div>
</div>
{/* Lower symmetric space — empty, so it matches the upper one exactly */}
<div className="flex-1" />
</div>
);
}
/**
* Superscript "new version" pill on the version line (accent-colored, raised via
* 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";
/**
* Quiet version line under the brand subtitle: `vX.Y.Z · Last updated Jul 26`
* (localized per dictionary). The product name is not repeated here — the brand wordmark
* sits directly above, and the sidebar's version footer is bare `vX.Y.Z` too. The date is
* the running version's release
* 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 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).
*/
function VersionLine() {
const { locale } = useLocale();
const { version, update } = useVersionInfo(true);
if (version === null) return null;
const date = version.buildDate;
return (
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}${
date !== null ? ` · ${S.update.lastUpdated(formatMonthDay(date, locale))}` : ""
}`}
{update !== null &&
update.updateAvailable &&
update.latestVersion !== null &&
(update.releaseUrl !== null ? (
<a
href={update.releaseUrl}
target="_blank"
rel="noopener noreferrer"
title={S.update.newVersion(update.latestVersion)}
aria-label={S.update.newVersion(update.latestVersion)}
className={versionBadgeClass}
>
{S.update.newVersionBadge}
</a>
) : (
<span className={versionBadgeClass}>{S.update.newVersionBadge}</span>
))}
</p>
);
}
/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */
const pillClass =
"flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " +
"text-xs text-gray-600 transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100";
/** Agent selection (pill dropdown): avatar + name, menu opens downward with an internal scroll cap. */
function AgentSelect({
agents,
selected,
onSelect,
}: {
agents: AgentSummary[];
selected: AgentSummary | null;
onSelect: (agent: AgentSummary) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Dropdown
open={open}
setOpen={setOpen}
menuClass="left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
button={
<button
type="button"
title={S.chat.chooseAgent}
aria-label={S.chat.chooseAgent}
onClick={() => setOpen(!open)}
className={pillClass}
>
{selected ? (
<AgentAvatar
id={selected.agentId}
name={agentDisplayName(selected)}
size={16}
className="shrink-0 rounded"
/>
) : null}
<span className="min-w-0 truncate">
{selected ? agentDisplayName(selected) : S.common.loading}
</span>
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
</button>
}
>
<div className="max-h-56 overflow-y-auto">
{agents.length === 0 && (
<p className="px-3 py-1.5 text-xs text-gray-400">{S.common.loading}</p>
)}
{agents.map((a) => {
const active = a.agentId === selected?.agentId;
return (
<button
key={a.agentId}
type="button"
aria-pressed={active}
onClick={() => {
onSelect(a);
setOpen(false);
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<AgentAvatar
id={a.agentId}
name={agentDisplayName(a)}
size={20}
className="shrink-0 rounded"
/>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-xs ${
active
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-700 dark:text-gray-300"
}`}
>
{agentDisplayName(a)}
</span>
{a.description && (
<span className="block truncate text-[11px] text-gray-400 dark:text-gray-500">
{a.description}
</span>
)}
</span>
<span className="w-4 shrink-0 text-center text-xs text-gray-500 dark:text-gray-400">
{active ? "✓" : ""}
</span>
</button>
);
})}
</div>
</Dropdown>
);
}
/**
* Workspace selection (pill dropdown): the button shows the selected directory name (empty =
* auto temporary directory). The menu browses server-side directories: **the current path can be
* edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts
* to the previous path), the list omits hidden directories, and the hint text sits at the bottom
* of the menu; only loads on first expand. On narrow screens the menu docks to whichever side
* of the pill keeps it inside the viewport (measured on open — see menuDock).
*/
function WorkspaceSelect({
projectId,
workspace,
onChange,
}: {
projectId: string;
workspace: string;
onChange: (path: string) => void;
}) {
const [open, setOpen] = useState(false);
/**
* Menu docking, measured on each open: the pill follows the agent pill in a wrapping row, so
* its left offset varies with the agent's name — a statically left-anchored 20rem panel can
* cross the viewport's right edge on phones (measured ~143px past a 390px viewport). Keep the
* desktop left anchoring whenever the panel fits; otherwise dock to whichever side of the
* pill has more room, capping the width to that room via menuStyle. On desktop the panel
* always fits, so nothing changes there.
*/
const [menuDock, setMenuDock] = useState<{ right: boolean; maxWidth?: number }>({
right: false,
});
const browsedRef = useRef(false);
const [dir, setDir] = useState<DirListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Edit draft for the path row: synced with the browsing position, reverts on a failed commit. */
const [pathDraft, setPathDraft] = useState("");
useEffect(() => {
setPathDraft(dir?.path ?? "");
}, [dir]);
/** Browses level by level (clicking a directory/parent); an empty string means the server's home directory (the default starting point). */
const loadDir = useCallback(
(abs: string) => {
setLoading(true);
setError(null);
api
.listDirs(projectId, abs)
.then(setDir)
.catch((e: unknown) => setError(apiErrorText(e)))
.finally(() => setLoading(false));
},
[projectId],
);
const toggle = (e: ReactMouseEvent<HTMLButtonElement>) => {
const next = !open;
if (next) {
const r = e.currentTarget.getBoundingClientRect();
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
const margin = 12; // breathing room against the viewport edge
// The panel's effective width: w-80 capped by its max-w-[calc(100vw-2rem)] class
// (rem-derived — the root font size is not 16px here).
const width = Math.min(20 * rem, window.innerWidth - 2 * rem);
const roomRight = window.innerWidth - margin - r.left; // room for a left-anchored panel
const roomLeft = r.right - margin; // room for a right-anchored panel
if (roomRight >= width) setMenuDock({ right: false });
else if (roomLeft > roomRight)
setMenuDock({ right: true, ...(roomLeft < width ? { maxWidth: roomLeft } : {}) });
else setMenuDock({ right: false, maxWidth: roomRight });
}
setOpen(next);
// Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory.
if (next && !browsedRef.current) {
browsedRef.current = true;
const ws = workspace.trim();
loadDir(ws.startsWith("/") ? ws : "");
}
};
/** Commits the edited path: navigates to it if it exists, otherwise toasts and reverts to the current browsing position. */
const commitPathEdit = async () => {
const p = pathDraft.trim();
if (!p || p === dir?.path) {
setPathDraft(dir?.path ?? "");
return;
}
try {
setDir(await api.listDirs(projectId, p));
} catch {
toastError(S.chat.workspaceDirInvalid);
setPathDraft(dir?.path ?? "");
}
};
const trimmed = workspace.trim();
// Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty.
const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto;
const parentPath = dir?.parent ?? null;
// Hidden directories (starting with .) are excluded from the list.
const entries = (dir?.entries ?? []).filter((e) => !e.name.startsWith("."));
return (
<Dropdown
open={open}
setOpen={setOpen}
menuClass={`top-full mt-1 w-80 max-w-[calc(100vw-2rem)] ${
menuDock.right ? "right-0 origin-top-right" : "left-0 origin-top-left"
}`}
{...(menuDock.maxWidth !== undefined ? { menuStyle: { maxWidth: menuDock.maxWidth } } : {})}
button={
<button
type="button"
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
aria-label={S.chat.workspace}
onClick={toggle}
className={pillClass}
>
{/* Folder icon */}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="ml-0.5 shrink-0 text-gray-400"
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className={`min-w-0 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
</button>
}
>
<div className="space-y-1.5 px-2.5 pb-2.5 pt-2">
<div className="rounded-md border border-gray-200 dark:border-gray-800">
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
<div className="flex items-center gap-1.5 border-b border-gray-100 px-1.5 py-1 dark:border-gray-800">
<input
value={pathDraft}
placeholder="…"
aria-label={S.chat.workspace}
{...noAutofill}
onChange={(e) => setPathDraft(e.target.value)}
onBlur={() => void commitPathEdit()}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
void commitPathEdit();
} else if (e.key === "Escape") {
// Discard the edit: only reverts the draft; Escape bubbles up to Dropdown, which closes the menu.
setPathDraft(dir?.path ?? "");
}
}}
className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono text-xs text-gray-600 focus:border-gray-300 focus:outline-none dark:text-gray-300 dark:focus:border-gray-600"
/>
<button
type="button"
disabled={!dir}
onClick={() => {
if (!dir) return;
onChange(dir.path);
setOpen(false);
}}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.chat.workspaceUseThis}
</button>
</div>
{/* Directory list (excludes hidden directories) */}
<ul className="max-h-40 overflow-y-auto py-1">
{parentPath !== null && (
<li>
<button
type="button"
onClick={() => loadDir(parentPath)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
↰ {S.chat.workspaceUp}
</button>
</li>
)}
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
onClick={() => loadDir(entry.path)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-400"
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
</button>
</li>
))}
{dir && entries.length === 0 && (
<li className="px-2.5 py-1.5 text-xs text-gray-400">{S.chat.workspaceNoSubdirs}</li>
)}
{loading && <li className="px-2.5 py-1.5 text-xs text-gray-400">{S.common.loading}</li>}
{/* Load failure (e.g. the cached starting directory was deleted): provide "retry" to fall back to the home directory, avoiding getting stuck in an error state. */}
{error && (
<li className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-red-500">
<span className="min-w-0 flex-1 truncate" title={error}>
{error}
</span>
<button
type="button"
onClick={() => loadDir("")}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.common.retry}
</button>
</li>
)}
</ul>
</div>
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */}
{trimmed && (
<button
type="button"
onClick={() => {
onChange("");
setOpen(false);
}}
className="text-xs text-gray-500 underline decoration-gray-300 underline-offset-2 transition-colors duration-150 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{S.chat.workspaceClear}
</button>
)}
{/* Hint text (bottom of the menu) */}
<p className="px-0.5 text-xs leading-5 text-gray-400 dark:text-gray-500">
{S.chat.workspaceHint}
</p>
</div>
</Dropdown>
);
}