/** * Chat page (refactored version): * a thin top toolbar (Session title / status / iconized stats / Files toggle / details popup) + * the message stream and input area (input box vertically centered when there are no messages). * Files is no longer a mutually exclusive tab — it's a persistent, closable, resizable docked * panel on the right (use-files-panel.ts), and each message's trailing file summary card jumps to * and locates the file in the tree via onOpenFile. * Approval mode and Model/context usage live in the input area's toolbar; context is compacted * via the /compact slash command. * Draft state (/chat/new) is carried by DraftView: Agent / Workspace / approval mode / Model are * chosen before sending, and everything except approval mode is locked once the Session is * created. The Session list and the new-chat entry point live in the global sidebar. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router"; import type { AgentSummary, ApprovalMode, ModelsResponse, 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 { useDocumentTitle } from "../../lib/use-document-title"; import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format"; import { approvalKey } from "../../lib/omni/stream-model"; import { useTheme } from "../../state/theme"; import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; import { Modal } from "../../components/ui/modal"; import { Button } from "../../components/ui/button"; import { Skeleton } from "../../components/ui/skeleton"; import { Truncated } from "../../components/ui/truncated"; import { Dropdown } from "../../components/ui/dropdown"; import { EmptyState } from "../../components/ui/empty-state"; import { toastError } from "../../components/ui/toast"; import { MessageStream } from "./message-stream"; import type { StreamRenderContext } from "./message-stream"; import { ChatInput } from "./chat-input"; import { DraftView } from "./draft-view"; import { handoffMessage } from "./agent-mentions"; import { sameModelRef } from "../models/model-grouping"; import { providerInfo } from "@prismshadow/penguin-core/model-catalog"; import { FilesPanel } from "./files-panel"; import { useFilesPanel } from "./use-files-panel"; import { useSessionDraft } from "./use-session-draft"; import { useSessionStream } from "./use-session-stream"; const STAT_ICONS = { // Tokens (database / stacked cylinders) tokens: "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3", // Cost (circled dollar sign) cost: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-15v12m2.6-9.3c-.5-.8-1.5-1.2-2.6-1.2-1.5 0-2.7.8-2.7 2 0 2.7 5.4 1.3 5.4 4 0 1.2-1.2 2-2.7 2-1.2 0-2.2-.5-2.7-1.4", // Elapsed time (clock) elapsed: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-14v5l3 2", // Files (folder) folder: "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-2z", } as const; /** Iconized stat item: a symbol + a value, with the title giving the full meaning. */ function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) { return ( {value} ); } /** * Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may * still want to change the model or configure a key first. The actual Session is only created * once **the first message is sent** (once created, the model is locked into its meta). * Real session ids always start with `session-`, so there's no collision with this constant. */ export const DRAFT_SESSION_ID = "new"; export function ChatPage() { const navigate = useNavigate(); const params = useParams<{ sessionId?: string }>(); const { currency } = useTheme(); const { currentProject, currentAgent, setCurrentAgentId, reloadAgents, agents } = useProject(); const projectId = currentProject?.projectId ?? null; const agentId = currentAgent?.agentId ?? null; const { sessions, loading: sessionsLoading, reload: reloadSessions, add: addSession, replace, setStatus, setTitle, } = useSessions(); const [sessionCost, setSessionCost] = useState(null); const [costUncosted, setCostUncosted] = useState(false); const [credentialGuide, setCredentialGuide] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [modeSaving, setModeSaving] = useState(false); const [models, setModels] = useState(null); const routeSessionId = params.sessionId ?? null; const filesPanel = useFilesPanel(routeSessionId); const draft = routeSessionId === DRAFT_SESSION_ID; const selected = draft ? null : (sessions.find((s) => s.sessionId === routeSessionId) ?? null); // Currently effective model (session state, the model reference comes from the Session DTO): model selection in draft state is handled internally by DraftView. const activeModelRef = selected ? { provider: selected.provider, modelId: selected.modelId } : null; // Tab title follows the current Session (refreshes in sync once the auto-generated title arrives). useDocumentTitle(selected ? (selected.title ?? S.chat.defaultSessionTitle) : S.nav.chat); const stream = useSessionStream( selected?.sessionId ?? null, selected?.status ?? "idle", setTitle, // Sub-session registration notice (session_created is pushed over the parent session's channel): reload the list so it appears immediately. () => void reloadSessions(), ); // Chat input area draft: caches text, @ target, and selected skills keyed by sessionId; restored after navigating away and back or a refresh, discarded on successful send. const { initial: sessionDraft, onTextChange: onDraftTextChange, onHandoffTargetChange: onDraftHandoffChange, onSkillsChange: onDraftSkillsChange, discard: discardSessionDraft, } = useSessionDraft(selected?.sessionId ?? null); // Current Agent follows the Session in the route (keeps the sidebar and stats aligned on deep // links / refresh). Only aligns when **the selected Session changes** — never put agentId in // the dependency array: otherwise, when switching from a "running session" to a new chat with // a different Agent, navigate and setCurrentAgentId aren't in the same batch — a transitional // render of "new agentId + old route (old session still selected)" would appear first, and // this effect would then flip the Agent back to the old session's Agent based on that, // causing the new chat to end up created on the old Agent. const selectedSessionId = selected?.sessionId ?? null; const selectedAgentId = selected?.agentId ?? null; useEffect(() => { if (selectedSessionId && selectedAgentId) setCurrentAgentId(selectedAgentId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSessionId, selectedAgentId, setCurrentAgentId]); // Skills installed on the session's Agent (candidates for the input area's skill dropdown): // fetched keyed on the session's Agent; on switch, cleared first (which also clears the input // area's selection) before refetching; a failed fetch is silently treated as no skills. // Clearing preserves reference identity (an already-empty array isn't replaced), matching // draft-view's convention. const [agentSkills, setAgentSkills] = useState([]); useEffect(() => { setAgentSkills((prev) => (prev.length > 0 ? [] : prev)); if (!projectId || !selectedAgentId) return; let cancelled = false; api .getAgentSkills(projectId, selectedAgentId) .then((res) => { if (!cancelled) setAgentSkills(res.skills); }) .catch(() => undefined); return () => { cancelled = true; }; }, [projectId, selectedAgentId]); // The Session list is paged: a deep-linked Session (old bookmark, cross-page jump) may sit // beyond the loaded pages. Look it up directly and insert it before the auto-select effect // below concludes it doesn't exist; only a failed probe releases that redirect. const [probeFailedId, setProbeFailedId] = useState(null); useEffect(() => { if (draft || !routeSessionId || sessionsLoading) return; if (sessions.some((s) => s.sessionId === routeSessionId)) return; let cancelled = false; api.getSession(routeSessionId).then( (res) => { if (!cancelled) addSession(res.session); }, () => { if (!cancelled) setProbeFailedId(routeSessionId); }, ); return () => { cancelled = true; }; }, [draft, routeSessionId, sessionsLoading, sessions, addSession]); // Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one). useEffect(() => { if (sessionsLoading || draft) return; if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return; // A routed id missing from the paged list isn't gone until the direct lookup fails. if (routeSessionId && probeFailedId !== routeSessionId) return; const first = sessions[0]; navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true }); }, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]); // Sync task_state to the sidebar list badge. useEffect(() => { if (selected) setStatus(selected.sessionId, stream.taskState); }, [stream.taskState, selected, setStatus]); // Task returns from running/compacting to idle: this turn may have spawned a sub-session or // auto-created a new Agent — reload the session and Agent lists so they appear in the sidebar // immediately (no manual refresh needed). const prevTaskRef = useRef(stream.taskState); useEffect(() => { const prev = prevTaskRef.current; prevTaskRef.current = stream.taskState; if (prev !== "idle" && stream.taskState === "idle") { void reloadSessions(); void reloadAgents(); } }, [stream.taskState, reloadSessions, reloadAgents]); // Existence cache for message file cards (session-level): normalized relative path -> whether // it exists; while a lookup is in flight, the cache shares a single Promise, so a batch of // concurrent mounts only issues one files/stat call. const statCacheRef = useRef(new Map>()); // Session switch: resets the cost and the file-card existence cache, avoiding stale data from // the previous Session (Files panel state resets itself keyed on sessionId inside use-files-panel). useEffect(() => { setSessionCost(null); setCostUncosted(false); statCacheRef.current = new Map(); }, [routeSessionId]); // Batched existence check (message file cards): merges only the cache-miss paths into a single // files/stat call, and the result lands in the session-level cache — during streaming, the // candidate set is re-checked on every change, and the cache ensures only new paths trigger a // request. On request failure, the placeholder is cleared (don't permanently cache a "couldn't // find" as "doesn't exist"), returned as not-existing this time, and re-checked on the next mount. const statFiles = useCallback( async (paths: string[]): Promise> => { const sessionId = selected?.sessionId ?? null; const cache = statCacheRef.current; const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p)); if (sessionId !== null && misses.length > 0) { const batch = api .statSessionFiles(sessionId, misses) .then((res) => new Set(res.existing)) .catch(() => null); for (const p of misses) { cache.set( p, batch.then((existing) => { if (existing === null) { cache.delete(p); return false; } const exists = existing.has(p); cache.set(p, exists); return exists; }), ); } } const result = new Set(); await Promise.all( paths.map(async (p) => { const hit = cache.get(p); if (hit === true || (hit instanceof Promise && (await hit))) result.add(p); }), ); return result; }, [selected?.sessionId], ); // Session's cumulative cost: refreshed on entry and every time it returns to idle (cost is computed by the server in real time based on current pricing). useEffect(() => { if (!projectId || !selected || stream.taskState !== "idle") return; let cancelled = false; api .getUsage(projectId, { groupBy: "session", agentId: selected.agentId }) .then((res) => { if (cancelled) return; const row = res.groups.find((g) => g.key === selected.sessionId); setSessionCost(row?.cost ?? null); setCostUncosted(row?.hasUncosted ?? false); }) .catch(() => undefined); return () => { cancelled = true; }; }, [projectId, selected, stream.taskState]); // Model config (context window + credential guide): fetched once per Project. // // The credential guide **only ever nags once per lifetime** (first entry after registration): // gated by the server prefs' credentialGuideSeen — previously it checked "default model has no // key" and popped up a dialog on every visit to the chat page, which was repeated nagging for // users who simply don't intend to configure a key / use environment variables instead. useEffect(() => { if (!projectId) return; let cancelled = false; setModels(null); void (async () => { try { const res = await api.getModels(projectId); if (cancelled) return; setModels(res); const { prefs } = await api.getPrefs(); if (cancelled || prefs.credentialGuideSeen) return; const def = res.models.find((m) => sameModelRef(m, res.defaultModel)); const missing = !res.defaultModel || !def?.credential?.apiKeyMasked; if (missing) setCredentialGuide(true); // Mark as "seen" regardless of whether the dialog actually popped up: only ever once. void api.putPrefs({ credentialGuideSeen: true }).catch(() => undefined); } catch { // A failed fetch doesn't affect the rest of the page. } })(); return () => { cancelled = true; }; }, [projectId]); // Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact). const syncHealedSessionId = useCallback( async (currentId: string, respondedId: string) => { if (respondedId === currentId) return; await reloadSessions(); navigate(`/chat/${respondedId}`, { replace: true }); }, [reloadSessions, navigate], ); const onSend = useCallback( async (input: TaskInputPart[]): Promise => { if (!selected) return false; try { const res = await api.postTask(selected.sessionId, { input }); discardSessionDraft(); await syncHealedSessionId(selected.sessionId, res.sessionId); return true; } catch (e) { // Returning false -> the input area keeps the draft, letting the user fix it and resend (the error copy includes the session model's upstream id). toastError(apiErrorText(e, { modelId: selected.modelId })); return false; } }, [selected, discardSessionDraft, syncHealedSessionId], ); // @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent // (approval mode carries over from the input area's current value; model/Workspace use the // creation defaults). The first input = a source block (current agent / Session // / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new // chat once sent. // Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent). const onHandoff = useCallback( async (target: AgentSummary, input: TaskInputPart[]): Promise => { if (!projectId || !currentAgent || !selected) return false; const origin: TaskInputPart = { type: "text", text: handoffMessage({ agentId: currentAgent.agentId, ...(currentAgent.name !== undefined ? { agentName: currentAgent.name } : {}), sessionId: selected.sessionId, workspace: selected.workspace, ...(selected.title !== undefined ? { sessionTitle: selected.title } : {}), }), }; let createdId: string | null = null; try { const created = await api.createSession(projectId, target.agentId, { approvalMode: selected.approvalMode, }); createdId = created.session.sessionId; const res = await api.postTask(createdId, { input: [origin, ...input] }); addSession(created.session); // The text body has been handed off into the new chat: discard the current session's input draft along with it. discardSessionDraft(); 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; } }, [projectId, currentAgent, selected, addSession, discardSessionDraft, navigate, models], ); const onStop = useCallback(async () => { if (!selected) return; await api.postAbort(selected.sessionId).catch(() => undefined); }, [selected]); const onApprove = useCallback( async (toolCallId: string, decision: "allow" | "deny", origin: string[]) => { if (!selected) return; // A decision clicked locally is marked "manual"; removed from the pending table keyed by the origin composite key. stream.markLocalDecision(toolCallId); const key = approvalKey(origin, toolCallId); try { await api.postApproval(selected.sessionId, toolCallId, { decision }); stream.resolveApproval(key); } catch (e) { if (e instanceof ApiError && e.status === 404) stream.resolveApproval(key); } }, [selected, stream], ); const onChangeApprovalMode = useCallback( (mode: ApprovalMode) => { if (!selected || modeSaving) return; setModeSaving(true); void api .patchSession(selected.sessionId, { approvalMode: mode }) .then((res) => replace(res.session)) .catch((e: unknown) => { toastError(apiErrorText(e)); }) .finally(() => setModeSaving(false)); }, [selected, modeSaving, replace], ); const onCompact = useCallback(async () => { if (!selected) return; try { // compact shares get-or-resume-or-heal with tasks: it can likewise self-heal to a new session_id. const res = await api.postCompact(selected.sessionId); await syncHealedSessionId(selected.sessionId, res.sessionId); } catch (e) { toastError(apiErrorText(e, { modelId: selected.modelId })); } }, [selected, syncHealedSessionId]); // "New Chat" = enter draft state: no Session is created until the first message is sent. const newChat = useCallback(() => { navigate(`/chat/${DRAFT_SESSION_ID}`); }, [navigate]); // Real-time cost for this turn: converts the Task's bucketed usage using the session Model's // (paired reference) current pricing; null if no pricing is configured. const modelPricing = models?.models.find((m) => sameModelRef(m, activeModelRef))?.pricing; const ctx: StreamRenderContext = { pendingApprovals: stream.pendingApprovals, onApprove, origin: [], // Any non-idle state (running / compacting) counts as "not yet stopped": compaction can // happen mid-turn, and if only running were checked, the trailing group would flash // "finished running" during compaction before flipping back to "running". taskRunning: stream.taskState !== "idle", taskCost: (stats) => { if (!modelPricing) return null; const b = stats.tokensByBucket; return ( (b.cacheRead * modelPricing.cacheRead + b.cacheWrite * modelPricing.cacheWrite + b.output * modelPricing.output) / 1e6 ); }, onOpenFile: (path) => { // The file card has already normalized the text path to a Workspace-relative path // (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows // separators), so this just opens the panel and navigates to it directly. filesPanel.setOpen(true); filesPanel.browsePath(path); }, workspace: selected?.workspace ?? null, statFiles, }; if (!projectId || !agentId) { return (
); } const totalTokens = stream.model.stats.sessionTotal + stream.model.stats.subagentTotal; const modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef)); const contextWindow = modelInfo?.contextWindow; // Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images. const vision = modelInfo?.vision !== false; const emptyChat = selected !== null && !stream.loading && !stream.error && stream.model.items.length === 0; // Input area in session state: Agent / Workspace / Model are already locked by the Session // (the model selector isn't rendered; models is only used to look up the locked model's // provider logo and display name for a read-only display) — only approval mode can still be // changed (saved immediately on change). const input = selected && ( 0 ? { initialSkills: sessionDraft.skills } : {})} onSkillsChange={onDraftSkillsChange} onHandoff={onHandoff} initialText={sessionDraft.text ?? ""} onTextChange={onDraftTextChange} {...(sessionDraft.handoffAgentId ? { initialHandoffTargetId: sessionDraft.handoffAgentId } : {})} onHandoffTargetChange={onDraftHandoffChange} /> ); return (
{/* Thin top toolbar */} {selected && (

{/* Running indicator (placed to the right of the title); the compacting state is shown separately by the compaction banner within the message stream, not repeated here. */} {stream.taskState === "running" && ( {S.chat.statusRunning} )}
{/* Stats: Token / cost / elapsed time (icon + title for the full meaning) */}
{/* When there's no cost (the Model has no pricing configured), don't render this stat at all, rather than showing a "—" — that would take up space while saying nothing, only making people think the cost is zero or something's broken. */} {sessionCost != null && ( )}
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */} {/* Details popup: Model / Workspace / created time / stats */} setInfoOpen(!infoOpen)} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200" > } >

{S.chat.model}

{/* Paired display: upstream model_id + provider name (two separate fields on the Session DTO). */}

{selected.modelId} {providerInfo(selected.provider)?.label ?? selected.provider}

{S.chat.workspace}

{selected.workspace}

{S.models.createdAt}

{formatDateTime(selected.createdAt)}

{S.chat.sessionStats}

{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}

{S.chat.statTokens} {humanizeTokens(totalTokens)} {sessionCost != null && ` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "} · {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}

)} {/* Body: chat column + the docked Files panel on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
{draft ? ( // Draft state: DraftView's vertically centered input card + Agent / Workspace // selection panel; the Session is only created once the first message is sent. Keyed // by Project: switching Project remounts and switches to that Project's draft cache // (Agent selection happens inside the draft itself, so it's no longer part of the key). ) : ( // Keyed by Session: the whole block does a light fade-in when switching sessions.
{selected ? ( stream.error ? ( // History failed to load: show a clear error and a retry entry point, instead of staying on a misleading empty state.

{S.chat.historyLoadFailed}:{stream.error}

) : stream.loading ? (
) : ( // The empty state shares the same structure as the message stream (message // area + bottom input area): only the message area's content differs, and // ChatInput always mounts in the same JSX slot, so it isn't unmounted and // recreated when the first message arrives (preserving draft/focus). <>
{emptyChat ? (

{S.chat.emptyGreeting}

) : ( )}
{input}
) ) : sessionsLoading ? (
) : ( {S.nav.newChat}} /> )}
)}
{selected && }
setCredentialGuide(false)} footer={ <> } >

{S.project.noCredentialBody}

); }