/** * 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; the cache is cleared on successful send. * The sidebar group header "+" / menu "New conversation" explicitly specify an * Agent via route state (overriding the cached selection); a direct visit or * refresh falls back to the cache. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router"; import type { AgentSummary, ApprovalMode, DirListResponse, ModelRefDto, ModelsResponse, SessionCreateRequest, SkillMetadataItem, TaskInputPart, } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; import { apiErrorText } from "../../lib/api-error"; import { useAuth } from "../../state/auth"; 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 { PenguinLogo } from "../../components/ui/penguin-logo"; import { toastError } from "../../components/ui/toast"; import { ChatInput } from "./chat-input"; import { buildSkillsMessage } from "./skill-use"; import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache"; import type { DraftCache } from "./draft-cache"; import { handoffMessage } from "./agent-mentions"; 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; /** * Example tasks on the draft screen, in display order (game card first, LoL music player, * then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a * `` block — only those the selected Agent actually has installed are included, * so the block never references a skill the agent can't read. */ const EXAMPLE_TASKS: { id: "game" | "lol" | "rag"; skills: string[] }[] = [ { id: "game", skills: ["web-design"] }, { id: "lol", skills: ["web-design"] }, { id: "rag", skills: ["penguin-sdk", "web-design"] }, ]; 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(() => userId ? loadDraft(draftKey(userId, projectId)) : {}, ); const [agentId, setAgentId] = useState( cached.agentId ?? currentAgent?.agentId ?? null, ); const [workspace, setWorkspace] = useState(cached.workspace ?? ""); const [approvalMode, setApprovalMode] = useState( cached.approvalMode ?? "allow-all", ); const [modelRef, setModelRef] = useState(cached.modelRef ?? null); /** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */ const [handoffAgentId, setHandoffAgentId] = useState( cached.handoffAgentId ?? 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(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 stateAgentId = (location.state as { agentId?: string } | null)?.agentId; const appliedStateKey = useRef(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) { appliedStateKey.current = 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]); // 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]); // 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([]); /** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `` 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(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 (handoffAgentId) data.handoffAgentId = handoffAgentId; if (skillsRef.current.length > 0) data.skills = skillsRef.current; saveDraft(draftKey(userId, projectId), data); }, [ cancelPendingSave, userId, projectId, agentId, workspace, approvalMode, modelRef, handoffAgentId, ]); // 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. */ 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) clearDraft(draftKey(userId, projectId)); }, [cancelPendingSave, userId, projectId]); 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 every send entry point (composer send / example task / // @-handoff): 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): Promise => { 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 }); 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<"game" | "lol" | "rag" | null>(null); const runExample = useCallback( async (task: (typeof EXAMPLE_TASKS)[number]) => { 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], ); // @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the // draft's current value; model/Workspace use the creation defaults), first input = // source block + the text and images with the @ mention stripped. const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null; const onHandoff = useCallback( async (target: AgentSummary, input: TaskInputPart[]): Promise => { if (!selectedAgent || sendingRef.current) return false; sendingRef.current = true; setSending(true); const origin: TaskInputPart = { type: "text", text: handoffMessage({ agentId: selectedAgent.agentId, ...(selectedAgent.name !== undefined ? { agentName: selectedAgent.name } : {}), }), }; let createdId: string | null = null; try { const created = await api.createSession(projectId, target.agentId, { approvalMode }); createdId = created.session.sessionId; const res = await api.postTask(createdId, { input: [origin, ...input] }); add(created.session); discardDraft(); navigate(`/chat/${res.sessionId}`); return true; } catch (e) { if (createdId) void api.deleteSession(createdId).catch(() => undefined); // The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit. toastError( apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}), ); return false; } finally { sendingRef.current = false; setSending(false); } }, [projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models], ); // 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 (
{/* * 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. */}
{/* 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. */}

{S.appName}

{S.chat.draftSubtitle}

undefined} onCompact={async () => undefined} modelRef={modelRef} models={models?.models ?? []} onChangeModel={setModelRef} {...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})} {...(contextWindow !== undefined ? { contextWindow } : {})} contextNow={0} vision={vision} approvalMode={approvalMode} onChangeApprovalMode={setApprovalMode} modeSaving={false} autoFocus agents={agents} skills={agentSkills} {...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})} onSkillsChange={onSkillsChange} onHandoff={onHandoff} initialText={cached.text ?? ""} onTextChange={onTextChange} {...(cached.handoffAgentId ? { initialHandoffTargetId: cached.handoffAgentId } : {})} onHandoffTargetChange={setHandoffAgentId} /> {/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
{/* 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. */}
{EXAMPLE_TASKS.map((task) => { const copy = S.chat.exampleTasks[task.id]; return ( ); })}
{/* Lower symmetric space — empty, so it matches the upper one exactly */}
); } /** 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 ( setOpen(!open)} className={pillClass} > {selected ? ( ) : null} {selected ? agentDisplayName(selected) : S.common.loading} } >
{agents.length === 0 && (

{S.common.loading}

)} {agents.map((a) => { const active = a.agentId === selected?.agentId; return ( ); })}
); } /** * 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. */ function WorkspaceSelect({ projectId, workspace, onChange, }: { projectId: string; workspace: string; onChange: (path: string) => void; }) { const [open, setOpen] = useState(false); const browsedRef = useRef(false); const [dir, setDir] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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(e instanceof ApiError ? e.message : S.common.unknownError)) .finally(() => setLoading(false)); }, [projectId], ); const toggle = () => { const next = !open; 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 ( {/* Folder icon */} {label} } >
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
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" />
{/* Directory list (excludes hidden directories) */}
    {parentPath !== null && (
  • )} {entries.map((entry) => (
  • ))} {dir && entries.length === 0 && (
  • {S.chat.workspaceNoSubdirs}
  • )} {loading &&
  • {S.common.loading}
  • } {/* 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 && (
  • {error}
  • )}
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */} {trimmed && ( )} {/* Hint text (bottom of the menu) */}

{S.chat.workspaceHint}

); }