Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
@@ -0,0 +1,176 @@
/**
* @-agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
* chat-page.tsx and unit tests). Only a **leading** @ is meaningful: a target picked from
* the menu is pinned as a highlighted chip at the front of the input (the text itself
* carries no @ marker); hand-typed/pasted text starting with `@<agentId>` takes effect the
* same way on send. Any @ elsewhere in the text is plain text.
* - `matchMention`: finds the `@` prefix currently being typed from the text before the
* caret, driving the agent-picker popup;
* - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive);
* - `splitLeadingMention`: on send, parses a leading `@<agentId>`, splitting off the target
* agent from the remaining text;
* - `handoffMessage`: generates the first message for the @-mentioned agent's **new
* conversation** — an origin note (`<handoff_from>` block) carrying the source agent /
* Session / Workspace, followed by the user's text as a subsequent text part).
*/
import type { AgentSummary } from "@prismshadow/penguin-server/api";
/** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */
const ID_PREFIX = /^[\w-]*$/;
/**
* The @ mention currently being typed: `start` is the index of `@` in the full text,
* `query` is the prefix between `@` and the caret, and `end` is the end position of the
* same token to the right of the caret — selecting a candidate replaces the **entire**
* `start..end` token (no leftover tail when the caret sits mid-token).
*/
export interface MentionMatch {
start: number;
end: number;
query: string;
}
/**
* Finds the @ mention currently being typed at the caret; returns null if none.
* `@` must be at the start of the text or preceded by whitespace (to avoid treating
* ordinary text like emails as mentions); only id characters are allowed between `@` and
* the caret.
*/
export function matchMention(text: string, caret: number): MentionMatch | null {
const before = text.slice(0, caret);
const at = before.lastIndexOf("@");
if (at < 0) return null;
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
const query = before.slice(at + 1);
if (!ID_PREFIX.test(query)) return null;
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
return { start: at, end: caret + rest.length, query };
}
/** Filters candidate agents by prefix (agentId or display name, case-insensitive); an empty prefix returns all. */
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
const q = query.toLowerCase();
return agents.filter(
(a) => a.agentId.toLowerCase().startsWith(q) || (a.name ?? "").toLowerCase().startsWith(q),
);
}
/**
* Parses a leading mention: when text (expected to already be trimmed) starts with
* `@<existing agentId>`, splits off the target agent from the remaining text (the id is
* the longest `[\w-]+` run after `@`, and must exactly match an existing agentId — `@foo2`
* does not count as @-ing foo; leading whitespace in the remaining text is trimmed).
* Returns null when the text doesn't start with an @ for an existing agent; an @ elsewhere
* in the text is never parsed.
*/
export function splitLeadingMention(
text: string,
agents: AgentSummary[],
): { agent: AgentSummary; rest: string } | null {
const m = /^@([\w-]+)([\s\S]*)$/.exec(text);
if (!m) return null;
const agent = agents.find((a) => a.agentId === m[1]);
if (!agent) return null;
return { agent, rest: m[2]!.trimStart() };
}
/** Origin info for an @-handoff new conversation: source agent is always present; source Session is omitted while it's still a draft (not yet created). */
export interface HandoffOrigin {
agentId: string;
agentName?: string;
sessionId?: string;
sessionTitle?: string;
workspace?: string;
}
/**
* First message of an @-handoff new conversation (in English): the `<handoff_from>` block
* states that this conversation was opened by an @ mention and carries the source agent /
* Session / Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the
* source agent as its working target, or reaching source files via the Workspace path);
* the parenthetical label is omitted when the display name/title equals the id or is
* absent. When rendering the message stream, `parseHandoffMessage` collapses this into a
* one-line handoff notice — the raw text isn't shown (the model still sees it as usual).
*/
export function handoffMessage(origin: HandoffOrigin): string {
const name =
origin.agentName && origin.agentName !== origin.agentId ? ` (${origin.agentName})` : "";
const lines = [`agent: ${origin.agentId}${name}`];
if (origin.sessionId) {
const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : "";
lines.push(`session: ${origin.sessionId}${title}`);
}
if (origin.workspace) lines.push(`workspace: ${origin.workspace}`);
return [
"<handoff_from>",
"This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.",
...lines,
"</handoff_from>",
].join("\n");
}
/**
* Inverse parse of `handoffMessage` (lets the message stream collapse the origin block into
* a handoff notice): returns origin info when the whole message is strictly one
* `<handoff_from>` block, otherwise returns null (a normal user message renders as-is).
* Field lines are parsed as `key: id (label)`; non-field lines such as the explanation
* sentence are ignored.
*/
export function parseHandoffMessage(text: string): HandoffOrigin | null {
const block = /^<handoff_from>\n([\s\S]*)\n<\/handoff_from>$/.exec(text.trim());
if (!block) return null;
const origin: HandoffOrigin = { agentId: "" };
for (const line of block[1]!.split("\n")) {
const kv = /^(agent|session|workspace): (.+)$/.exec(line);
if (!kv) continue;
const key = kv[1]!;
const value = kv[2]!;
if (key === "workspace") {
origin.workspace = value;
continue;
}
const labeled = /^([\w-]+) \((.*)\)$/.exec(value);
const id = labeled ? labeled[1]! : value;
const label = labeled?.[2];
if (key === "agent") {
origin.agentId = id;
if (label !== undefined) origin.agentName = label;
} else {
origin.sessionId = id;
if (label !== undefined) origin.sessionTitle = label;
}
}
return origin.agentId ? origin : null;
}
/** Origin info for a scheduled-task trigger (the server's scheduledMessage `<scheduled_task>` block). */
export interface ScheduledOrigin {
/** Task name (filename minus .toml). */
name: string;
/** Trigger timestamp (ISO 8601); empty string when absent from the block. */
firedAt: string;
}
/**
* Inverse parse of the server's scheduledMessage (lets the message stream collapse the
* origin block into a scheduled-task notice): returns origin info and the remaining text
* when the message **starts with** a `<scheduled_task>` block, otherwise returns null.
* Unlike handoff, the block is followed by the task's Prompt body, which must be returned
* alongside it for normal rendering (the raw block isn't shown; the Trace page shows it
* as-is).
*/
export function parseScheduledMessage(
text: string,
): { origin: ScheduledOrigin; rest: string } | null {
const m = /^<scheduled_task>\n([\s\S]*?)\n<\/scheduled_task>/.exec(text);
if (!m) return null;
const origin: ScheduledOrigin = { name: "", firedAt: "" };
for (const line of m[1]!.split("\n")) {
const kv = /^(schedule|fired_at): (.+)$/.exec(line);
if (!kv) continue;
if (kv[1] === "schedule") origin.name = kv[2]!;
else origin.firedAt = kv[2]!;
}
if (!origin.name) return null;
return { origin, rest: text.slice(m[0].length).replace(/^\n+/, "") };
}
@@ -0,0 +1,36 @@
/**
* Approval buttons: appear on the
* corresponding tool card when always-ask is set and there's a pending approval; the decision
* is submitted via POST /api/sessions/:s/approvals/:toolCallId.
*/
import { useState } from "react";
import { S } from "../../lib/strings";
import { Button } from "../../components/ui/button";
export function ApprovalButtons({
onDecide,
}: {
onDecide: (decision: "allow" | "deny") => Promise<void>;
}) {
const [busy, setBusy] = useState(false);
const decide = async (decision: "allow" | "deny") => {
setBusy(true);
try {
await onDecide(decision);
} finally {
setBusy(false);
}
};
return (
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" disabled={busy} onClick={() => void decide("allow")}>
{S.chat.approve}
</Button>
<Button size="sm" disabled={busy} onClick={() => void decide("deny")}>
{S.chat.deny}
</Button>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,756 @@
/**
* 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 (
<span
title={label}
className="flex shrink-0 items-center gap-1 font-mono text-xs text-gray-500 dark:text-gray-400"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={icon} />
</svg>
{value}
</span>
);
}
/**
* 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<number | null>(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<ModelsResponse | null>(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<SkillMetadataItem[]>([]);
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]);
// 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;
const first = sessions[0];
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
}, [sessionsLoading, draft, routeSessionId, 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<string, boolean | Promise<boolean>>());
// 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<ReadonlySet<string>> => {
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<string>();
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<boolean> => {
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 <handoff_from> 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<boolean> => {
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 (
<div className="p-6">
<Skeleton className="h-6 w-64" />
</div>
);
}
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 && (
<ChatInput
status={stream.taskState}
onSend={onSend}
onStop={onStop}
onCompact={onCompact}
modelRef={activeModelRef}
{...(models !== null ? { models: models.models } : {})}
{...(contextWindow !== undefined ? { contextWindow } : {})}
contextNow={stream.model.stats.contextNow}
contextStale={stream.model.stats.contextStale}
vision={vision}
approvalMode={selected.approvalMode}
onChangeApprovalMode={onChangeApprovalMode}
modeSaving={modeSaving}
autoFocus
agents={agents}
skills={agentSkills}
{...(sessionDraft.skills && sessionDraft.skills.length > 0
? { initialSkills: sessionDraft.skills }
: {})}
onSkillsChange={onDraftSkillsChange}
onHandoff={onHandoff}
initialText={sessionDraft.text ?? ""}
onTextChange={onDraftTextChange}
{...(sessionDraft.handoffAgentId
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
: {})}
onHandoffTargetChange={onDraftHandoffChange}
/>
);
return (
<div className="flex h-full flex-col bg-white dark:bg-gray-950">
{/* Thin top toolbar */}
{selected && (
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-200 px-3 py-2 md:px-4 dark:border-gray-800">
<div className="flex min-w-0 flex-1 items-center gap-3">
<h1 className="flex min-w-0 text-[15px] font-semibold">
<Truncated text={selected.title ?? S.chat.defaultSessionTitle} />
</h1>
{/* 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" && (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
{S.chat.statusRunning}
</span>
)}
</div>
{/* Stats: Token / cost / elapsed time (icon + title for the full meaning) */}
<div className="hidden items-center gap-3 sm:flex">
<StatChip
icon={STAT_ICONS.tokens}
value={humanizeTokens(totalTokens)}
label={`${S.chat.statTokens}(Token)`}
/>
{/* 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 && (
<StatChip
icon={STAT_ICONS.cost}
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
label={`${S.chat.statCost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
/>
)}
<StatChip
icon={STAT_ICONS.elapsed}
value={humanizeDuration(stream.model.stats.sessionElapsedMs)}
label={S.chat.statElapsed}
/>
</div>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
<button
type="button"
aria-expanded={filesPanel.open}
onClick={() => filesPanel.setOpen(!filesPanel.open)}
title={S.chat.openWorkspace}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
filesPanel.open
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
>
<path d={STAT_ICONS.folder} />
</svg>
{S.chat.openWorkspace}
</button>
{/* Details popup: Model / Workspace / created time / stats */}
<Dropdown
open={infoOpen}
setOpen={setInfoOpen}
menuClass="right-0 top-full mt-1 w-80 max-w-[calc(100vw-1.5rem)] origin-top-right"
button={
<button
type="button"
title={S.chat.infoPanel}
onClick={() => 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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5m0-8h.01" />
</svg>
</button>
}
>
<div className="space-y-3 px-3.5 py-2.5 text-sm">
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.model}
</p>
{/* Paired display: upstream model_id + provider name (two separate fields on the Session DTO). */}
<p className="truncate text-xs">
<span className="font-mono">{selected.modelId}</span>
<span className="ml-1.5 text-gray-400 dark:text-gray-500">
{providerInfo(selected.provider)?.label ?? selected.provider}
</span>
</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.workspace}
</p>
<p className="break-all font-mono text-xs leading-5">{selected.workspace}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.models.createdAt}
</p>
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.sessionStats}
</p>
{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}
<p className="font-mono text-xs">
{S.chat.statTokens} {humanizeTokens(totalTokens)}
{sessionCost != null &&
` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "}
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
</p>
</div>
</div>
</Dropdown>
</div>
)}
{/* Body: chat column + the docked Files panel on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{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).
<DraftView key={`draft:${projectId}`} projectId={projectId} models={models} />
) : (
// Keyed by Session: the whole block does a light fade-in when switching sessions.
<div
key={selected?.sessionId ?? "empty"}
className="anim-fade flex min-h-0 flex-1 flex-col"
>
{selected ? (
stream.error ? (
// History failed to load: show a clear error and a retry entry point, instead of staying on a misleading empty state.
<div className="flex h-full flex-col items-center justify-center gap-3 p-6">
<p className="text-sm text-red-600 dark:text-red-400">
{S.chat.historyLoadFailed}:{stream.error}
</p>
<Button onClick={stream.retry}>{S.common.retry}</Button>
</div>
) : stream.loading ? (
<div className="space-y-3 p-6">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-5 w-1/2" />
</div>
) : (
// 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).
<>
<div className="min-h-0 flex-1">
{emptyChat ? (
<div className="flex h-full items-center justify-center px-4">
<p className="text-lg font-medium text-gray-400 dark:text-gray-500">
{S.chat.emptyGreeting}
</p>
</div>
) : (
<MessageStream
items={stream.model.items}
version={stream.version}
ctx={ctx}
/>
)}
</div>
<div className="shrink-0 border-t border-gray-200 bg-white px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 md:pb-3 dark:border-gray-800 dark:bg-gray-950">
<div className="mx-auto max-w-3xl">{input}</div>
</div>
</>
)
) : sessionsLoading ? (
<div className="space-y-3 p-6">
<Skeleton className="h-5 w-1/2" />
</div>
) : (
<EmptyState
title={S.chat.noSessions}
action={<Button onClick={newChat}>{S.nav.newChat}</Button>}
/>
)}
</div>
)}
</div>
{selected && <FilesPanel session={selected} panel={filesPanel} />}
</div>
<Modal
open={credentialGuide}
title={S.project.noCredentialTitle}
onClose={() => setCredentialGuide(false)}
footer={
<>
<Button onClick={() => setCredentialGuide(false)}>{S.project.later}</Button>
<Button
variant="primary"
onClick={() => {
setCredentialGuide(false);
navigate("/models");
}}
>
{S.project.goToModels}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">{S.project.noCredentialBody}</p>
</Modal>
</div>
);
}
@@ -0,0 +1,97 @@
/**
* Code block (visual reference: better-chatbot's pre-block): top bar = language label + copy
* button, body highlighted via Shiki — inline CSS variables for both the github-light /
* github-dark themes, with dark mode switched via style overrides under html.dark (see
* styles.css), so theme switching doesn't require re-highlighting.
* Shiki is dynamically imported (its own chunk, loaded only once the first code block appears);
* before loading completes and for unknown languages, it falls back to an unhighlighted <pre>.
*
* highlight=false (while a message is streaming) skips highlighting and falls back to plain
* text: every streaming frame re-renders the full code with a growing length, and re-tokenizing
* the whole block each time would be O(n^2) main-thread cost, and an in-progress highlight can't
* be canceled; once streaming settles, highlight flips true and a single final highlight is done.
*/
import { useEffect, useState } from "react";
import { S } from "../../lib/strings";
import { STAT_ICONS } from "../../lib/stat-icons";
import { GlyphIcon } from "../../components/ui/glyph-icon";
const COPIED_MS = 1500;
/** Copy button in the header bar (same visual style as MessageMeta's copy button; always visible — the header bar has no hover-gated container). */
function CopyCodeButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), COPIED_MS);
};
return (
<button
type="button"
title={copied ? S.common.copied : S.chat.copyCode}
aria-label={S.chat.copyCode}
onClick={copy}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
</button>
);
}
export function CodeBlock({
language,
code,
highlight = true,
}: {
language: string;
code: string;
highlight?: boolean;
}) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
if (!highlight) {
setHtml(null);
return;
}
let alive = true;
void import("shiki")
.then((shiki) =>
shiki.codeToHtml(code, {
lang: language || "text",
themes: { light: "github-light", dark: "github-dark" },
}),
)
.then((out) => {
if (alive) setHtml(out);
})
.catch(() => {
// Unknown language / failed to load: keep the unhighlighted fallback.
if (alive) setHtml(null);
});
return () => {
alive = false;
};
}, [code, language, highlight]);
return (
<div className="code-block my-2 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-1 dark:border-gray-800 dark:bg-gray-900">
<span className="font-mono text-xs lowercase text-gray-500 dark:text-gray-400">
{language || "text"}
</span>
<CopyCodeButton code={code} />
</div>
<div className="overflow-x-auto bg-white text-[13px] leading-relaxed dark:bg-gray-950">
{html ? (
<div dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre className="m-0 px-3 py-2.5 font-mono text-gray-800 dark:text-gray-200">
<code>{code}</code>
</pre>
)}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
/**
* Compaction banner: shows "Compaction in
* progress (summarize/discard)" between compaction_begin and compaction_end, then a one-line
* message once done.
* Doesn't show Tokens: the banner only needs to state whether compaction happened and whether it
* succeeded. Compaction's cost lands in different places depending on when it occurs — compaction
* that happens **mid-turn** counts toward that turn's stats line and cost; compaction **after a
* turn ends** and manual compaction both go into the Session total (the Trace page lists
* compaction turns separately); see the task-stats module comments.
*/
import { S } from "../../lib/strings";
import type { CompactionItem } from "../../lib/omni/stream-model";
export function CompactionBanner({ item }: { item: CompactionItem }) {
if (item.running) {
return (
<div className="anim-msg my-2 flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
{S.chat.compactionRunning(item.mode)}
</div>
);
}
const text =
item.status === "completed"
? S.chat.compactionDone(item.mode)
: S.chat.compactionFailed(item.status ?? "failed");
return <p className="my-1 font-mono text-xs text-gray-500 dark:text-gray-400">{text}</p>;
}
@@ -0,0 +1,117 @@
/**
* Draft cache (localStorage; one entry per "user × Project" for new conversations, one per
* "user × Session" for existing sessions): reads validate field-by-field — storage may have been
* corrupted externally, so bad fields are dropped rather than crashing the page; writes are
* best-effort (silently fail under quota limits/private browsing). Pure functions + injectable
* storage: vitest runs in a Node environment (no localStorage), so unit tests inject an in-memory
* implementation.
*
* The key must include userId (#68): if the same browser logs into different accounts in
* succession and the key only contains the Project/Session ID, the later user would recover the
* previous user's text, Workspace, model selection, and @ target — a cross-account information leak.
*/
import type { ApprovalMode } from "@prismshadow/penguin-server/api";
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
export interface DraftCache {
text?: string;
agentId?: string;
workspace?: string;
approvalMode?: ApprovalMode;
/**
* The model selected in the draft (a paired reference; (provider, modelId) is the unique key):
* load validates the object shape; the old string-typed modelId field is simply dropped
* (product hasn't shipped, so no migration is done).
*/
modelRef?: { provider: string; modelId: string };
/** The @ handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
handoffAgentId?: string;
/**
* Preselected skill names (written by the quick-invoke action on the Skill library page):
* used as the initial selection when ChatInput mounts, then trimmed to remove names not in the
* installed list once it's ready; cleared along with the entire draft on successful send.
*/
skills?: string[];
}
/** Minimal storage interface (a subset of localStorage). */
export interface DraftStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Cache key for a new conversation (draft page): one per "user × Project". */
export const draftKey = (userId: string, projectId: string): string =>
`penguin.chatDraft.${userId}.${projectId}`;
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and @ target; everything else is locked to the Session). */
export const sessionDraftKey = (userId: string, sessionId: string): string =>
`penguin.chatDraft.session.${userId}.${sessionId}`;
/** Parses and validates raw JSON field-by-field: null / malformed JSON / non-object / invalid fields are all dropped. */
export function parseDraft(raw: string | null): DraftCache {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const o = parsed as Record<string, unknown>;
const out: DraftCache = {};
if (typeof o.text === "string") out.text = o.text;
if (typeof o.agentId === "string") out.agentId = o.agentId;
if (typeof o.workspace === "string") out.workspace = o.workspace;
// The model reference must be a paired { provider, modelId } object; the old string-typed
// modelId and any malformed shape are dropped.
if (typeof o.modelRef === "object" && o.modelRef !== null) {
const r = o.modelRef as Record<string, unknown>;
if (typeof r.provider === "string" && typeof r.modelId === "string") {
out.modelRef = { provider: r.provider, modelId: r.modelId };
}
}
if (typeof o.handoffAgentId === "string") out.handoffAgentId = o.handoffAgentId;
if (Array.isArray(o.skills)) {
// Elements are validated one by one: non-string items are filtered out; if empty after
// filtering, the whole field is omitted.
const skills = o.skills.filter((s): s is string => typeof s === "string");
if (skills.length > 0) out.skills = skills;
}
if (
typeof o.approvalMode === "string" &&
APPROVAL_MODES.includes(o.approvalMode as ApprovalMode)
) {
out.approvalMode = o.approvalMode as ApprovalMode;
}
return out;
} catch {
return {};
}
}
export function loadDraft(key: string, storage: DraftStorage = localStorage): DraftCache {
try {
return parseDraft(storage.getItem(key));
} catch {
return {};
}
}
export function saveDraft(
key: string,
draft: DraftCache,
storage: DraftStorage = localStorage,
): void {
try {
storage.setItem(key, JSON.stringify(draft));
} catch {
/* Write fails under quota limits/private browsing: draft cache is best-effort */
}
}
export function clearDraft(key: string, storage: DraftStorage = localStorage): void {
try {
storage.removeItem(key);
} catch {
/* ignore */
}
}
@@ -0,0 +1,692 @@
/**
* 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 { 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;
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);
/** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */
const [handoffAgentId, setHandoffAgentId] = useState<string | null>(
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<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 stateAgentId = (location.state as { agentId?: string } | null)?.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) {
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<SkillMetadataItem[]>([]);
useEffect(() => {
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
if (!agentId) return;
let cancelled = false;
api
.getAgentSkills(projectId, agentId)
.then((res) => {
if (!cancelled) setAgentSkills(res.skills);
})
.catch(() => undefined);
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 (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);
};
// 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.
const onSend = useCallback(
async (input: TaskInputPart[]): Promise<boolean> => {
if (!agentId) return false;
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);
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;
}
},
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
);
// @ 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 =
// <handoff_from> 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<boolean> => {
if (!selectedAgent) return false;
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;
}
},
[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 (
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
{/*
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
* space (right above the input card). When the viewport is too short, the upper space
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
* page falls back to natural document-flow scrolling (without clipping the top).
*/}
<div className="flex flex-1 flex-col justify-end pt-6">
{/* 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>
</div>
</div>
<div className="mx-auto w-full max-w-3xl">
<ChatInput
status="idle"
onSend={onSend}
onStop={async () => 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) */}
<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>
</div>
{/* Lower symmetric space (compresses to padding at minimum height) */}
<div className="flex-1 pb-6" />
</div>
);
}
/** 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} 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} 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.
*/
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<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(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 (
<Dropdown
open={open}
setOpen={setOpen}
menuClass="left-0 top-full mt-1 w-80 max-w-[calc(100vw-2rem)] origin-top-left"
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}
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>
);
}
@@ -0,0 +1,111 @@
/**
* Files panel: on desktop (≥1024px, see isDocked in use-files-panel.ts) it docks to the right
* of the chat with a drag-to-resize edge; on narrower viewports it becomes a bottom Sheet
* (snaps to half for browsing / full for preview, gesture-draggable) so the vertical layout
* keeps the chat transcript above it visible. Content is a single WorkspaceBrowser directory-tree
* view; clicking a file chip in a message navigates the tree via openRequest (use-files-panel.ts).
*/
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import { Sheet } from "../../components/ui/sheet";
import { WorkspaceBrowser } from "./workspace-browser";
import type { FilesPanelState } from "./use-files-panel";
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function FilesPanel({ session, panel }: { session: SessionInfo; panel: FilesPanelState }) {
if (!panel.isDocked) {
return (
<Sheet
open={panel.open}
snap={panel.sheetSnap}
onSnapChange={panel.setSheetSnap}
onClose={() => panel.setOpen(false)}
title={S.files.title}
>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<WorkspaceBrowser
session={session}
openRequest={panel.openRequest}
active={panel.open}
// Entering preview from list view: bump the snap point up to full (preview needs the space)
onPreviewOpen={() => panel.setSheetSnap("full")}
/>
</div>
</div>
</Sheet>
);
}
return (
<>
{panel.open && (
<div
onMouseDown={panel.startResize}
onDoubleClick={panel.resetWidth}
title={S.files.resizeHandle}
className={`w-1.5 shrink-0 cursor-col-resize transition-colors duration-150 hover:bg-brand-300/50 dark:hover:bg-brand-700/40 ${
panel.resizing ? "bg-brand-400/60" : "bg-transparent"
}`}
/>
)}
<div
ref={panel.panelRef}
style={{ width: panel.open ? panel.width : 0 }}
// Use inert rather than unmounting when closed: the width transition needs the node to
// stay mounted, and inert removes content collapsed to 0 width from the tab order and
// accessibility tree, so keyboard users can't Tab into a close button that's visually gone.
inert={!panel.open}
// Freeze the panel's pointer events while dragging to resize: a preview iframe
// (HTML/PDF) is a separate document, and mousemove over it gets swallowed instead of
// reaching us, so the width stops tracking the cursor; pointer-events-none lets events
// pass through.
//
// relative: the clipping window acts as its own containing block. Content is fixed at
// the target width (see below), and when closed the whole block sits outside the
// viewport's right edge — if an absolute descendant (e.g. the upload button's sr-only
// input) anchored to the nearest initial containing block instead, it would bypass this
// overflow-hidden and stretch the **document** wide, making a horizontal scrollbar
// appear out of nowhere.
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
}`}
>
{/* Content is fixed at the target width; the outer element is only a clipping window:
during the open/close animation the outer element passes through intermediate
widths, and if the content resized along with it, the text would get squeezed into
a column frame-by-frame before expanding back out. With a fixed width, the content
behaves as a rigid body that slides in and out past the clipping edge with zero
reflow. While dragging to resize, both values stay in sync, so this isn't affected. */}
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
{/* Title row for docked state (the Sheet state has its own title bar via Sheet, no duplication needed) */}
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
<h4 className="min-w-0 flex-1 truncate text-sm font-semibold">{S.files.title}</h4>
<button
type="button"
onClick={() => panel.setOpen(false)}
title={S.common.close}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<CloseIcon />
</button>
</div>
<div className="min-h-0 flex-1">
<WorkspaceBrowser
session={session}
openRequest={panel.openRequest}
active={panel.open}
/>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,39 @@
/**
* Handoff notice for a chat created via @ delegation: the source block (<handoff_from>) isn't
* shown verbatim, it's collapsed into a single line reading "Handed off from <agent>'s chat";
* when there's a source Session, the whole line is clickable and jumps back to the original chat
* (the source Session's title goes into the title hover tooltip, taking no space in the body).
*/
import { useNavigate } from "react-router";
import { S } from "../../lib/strings";
import type { HandoffOrigin } from "./agent-mentions";
/** Display name of the source agent: `displayName (@id)` when the display name differs from the id, otherwise just `@id`. */
function agentLabel(origin: HandoffOrigin): string {
return origin.agentName && origin.agentName !== origin.agentId
? `${origin.agentName} (@${origin.agentId})`
: `@${origin.agentId}`;
}
export function HandoffBanner({ origin }: { origin: HandoffOrigin }) {
const navigate = useNavigate();
const text = S.chat.handoffFrom(agentLabel(origin));
const frame =
"anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400";
// A handoff initiated from draft state has no source Session: only the origin is shown, with nowhere to jump to.
if (!origin.sessionId) return <p className={frame}>{text}</p>;
const sessionId = origin.sessionId;
return (
<button
type="button"
title={S.chat.handoffBack(origin.sessionTitle)}
onClick={() => navigate(`/chat/${sessionId}`)}
className={`${frame} transition-colors hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200`}
>
{text}
<span aria-hidden className="text-gray-400 dark:text-gray-500">
→
</span>
</button>
);
}
@@ -0,0 +1,19 @@
/**
* Live duration (inline display on running thinking/tool cards): ticks every second. sinceMs
* comes from the server-side message timestamp and may drift from the local clock; negative
* values are shown as 0; a pulsing ellipsis is shown when missing.
* `offsetMs` is the already-settled duration of a prior segment (e.g. a tool call's argument
* generation phase), added on top of the live segment as it ticks.
*/
import { useEffect, useState } from "react";
import { humanizeDuration } from "../../lib/format";
export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offsetMs?: number }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
if (sinceMs === undefined) return <span className="animate-pulse">…</span>;
return <>{humanizeDuration(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
}
@@ -0,0 +1,193 @@
/**
* Message-level file summary card (visual reference: Codex's "files changed" card): extracts
* file paths from inline code in the assistant's text (heuristic via isFilePathLike), normalizes
* them to Workspace-relative paths, confirms they actually exist via files/stat, and aggregates
* them into a unified card at the end of the message — a light-background single-line header bar
* ("N files") + a list of file rows inside the card; each row's path is split into a "faded
* directory / bold filename" pair, with a "Preview" label at the end of the row making the action
* explicit, and clicking the whole row navigates to the Files panel preview for that relative
* path via onOpenFile. Collapses when there are more than 3 rows.
* The whole card doesn't render until the stat result comes back (to avoid a flash-then-disappear);
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
* matches error message examples, external paths, and other strings that can't actually be
* opened, so this card is only responsible for "if you click it, it really opens".
* Doesn't include diff stats — the only built-in tool is exec_command (file writes happen inside
* the shell), so the protocol has no structured edit signal; this is just an aggregated view of
* text references, hence the neutral "N files" title.
*/
import { useEffect, useMemo, useState } from "react";
import { S } from "../../lib/strings";
import { isFilePathLike, toWorkspaceRelative } from "../../lib/file-path";
const MAX_VISIBLE = 3;
/** Extracts file paths from inline code in raw Markdown (deduplicated, preserving order of appearance). */
export function extractFilePaths(markdown: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const m of markdown.matchAll(/`([^`\n]+)`/g)) {
const text = m[1]!.trim();
if (!isFilePathLike(text) || seen.has(text)) continue;
seen.add(text);
out.push(text);
}
return out;
}
/** Path split into segments: directory prefix faded, filename bold (Codex-style). The directory
* segment has shrink-[9999] and collapses first, the filename segment truncates only after —
* both segments are truncatable, so the row never overflows its container on a narrow panel. */
function PathLabel({ path }: { path: string }) {
const slash = path.lastIndexOf("/");
const dir = slash >= 0 ? path.slice(0, slash + 1) : "";
const name = slash >= 0 ? path.slice(slash + 1) : path;
return (
<span className="flex min-w-0 items-baseline font-mono text-sm">
{dir && (
<span className="min-w-0 shrink-[9999] truncate text-gray-400 dark:text-gray-500">
{dir}
</span>
)}
<span className="min-w-0 truncate font-semibold text-gray-800 dark:text-gray-100">
{name}
</span>
</span>
);
}
export function MessageFilesCard({
text,
workspace,
statFiles,
onOpenFile,
}: {
/** Raw Markdown text of the assistant message. */
text: string;
/** Absolute Workspace path of the current Session (used to normalize absolute paths found in the text). */
workspace: string | null;
/** Batched existence check (provided by chat-page, with a session-level cache): returns the set of relative paths confirmed to exist. */
statFiles: (paths: string[]) => Promise<ReadonlySet<string>>;
onOpenFile: (path: string) => void;
}) {
// Candidates: lexical extraction -> normalize to Workspace-relative paths (discard ones that
// can't be normalized) -> deduplicate keyed by the normalized result (used for both display and onOpenFile).
const candidates = useMemo(() => {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of extractFilePaths(text)) {
const rel = toWorkspaceRelative(raw, workspace);
if (rel === null || seen.has(rel)) continue;
seen.add(rel);
out.push(rel);
}
return out;
}, [text, workspace]);
// null = stat hasn't returned yet (don't render, to avoid a flash-then-disappear); once returned, only list paths confirmed to exist.
const [paths, setPaths] = useState<string[] | null>(null);
useEffect(() => {
setPaths(null);
if (candidates.length === 0) return;
let cancelled = false;
statFiles(candidates)
.then((existing) => {
if (!cancelled) setPaths(candidates.filter((p) => existing.has(p)));
})
// Query failure keeps it unrendered: the error has already been cleared by the cache layer, so the next mount will re-query.
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [candidates, statFiles]);
const [expanded, setExpanded] = useState(false);
if (paths === null || paths.length === 0) return null;
const visible = expanded ? paths : paths.slice(0, MAX_VISIBLE);
const hidden = paths.length - visible.length;
return (
<div className="anim-msg my-3 overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{/* Header bar: a single line of "icon + N files", light background to distinguish it from
the rows (Codex-style). No card-level action entry point — each row already has its own
"Preview", adding one to the header would just duplicate the row action. */}
<div className="flex items-center gap-2 border-b border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800/60 dark:bg-gray-800/40">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
className="shrink-0 text-gray-400"
>
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
</svg>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{S.chat.filesInMessage(paths.length)}
</span>
</div>
{/* File row list: thin dividers within the card, clicking a row opens it in the Files panel preview. */}
<div className="divide-y divide-gray-100 dark:divide-gray-800/60">
{visible.map((path) => (
<button
key={path}
type="button"
title={path}
onClick={() => onOpenFile(path)}
className="group flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
className="shrink-0 text-gray-400"
>
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
</svg>
<PathLabel path={path} />
<span className="min-w-0 flex-1" />
{/* Right-aligned "click to preview" text: makes the row action explicit (a trailing
chevron would read as expand/collapse instead). The whole row is already a
<button> (buttons can't nest), so this uses a span, and the click still targets
the whole row. */}
<span
aria-hidden
className="shrink-0 text-xs text-gray-400 transition-colors duration-150 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300"
>
{S.chat.openPreview}
</span>
</button>
))}
{(hidden > 0 || expanded) && paths.length > MAX_VISIBLE && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-2 text-left text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50"
>
{expanded ? S.chat.showLess : S.chat.showMoreFiles(hidden)}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={`transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,216 @@
/**
* Rendering dispatch for a single view-model item: user prompts are
* right-aligned brand bubbles (including image thumbnails), thinking collapsible blocks, text
* streamed as Markdown, tool cards, subagent cards, compaction banners, abort markers, and Task
* stats lines. Items have a light entrance animation.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { useLocale } from "../../state/locale";
import { formatMessageTime } from "../../lib/format";
import { STAT_ICONS } from "../../lib/stat-icons";
import { splitImageAttachments } from "../../lib/attachments";
import type { ChatItem } from "../../lib/omni/stream-model";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { MessageFilesCard } from "./message-files-card";
import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
import { SubagentCard } from "./subagent-card";
import { CompactionBanner } from "./compaction-banner";
import { HandoffBanner } from "./handoff-banner";
import { ScheduledBanner } from "./scheduled-banner";
import { SkillsBanner } from "./skills-banner";
import { parseHandoffMessage, parseScheduledMessage } from "./agent-mentions";
import { parseSkillsMessage } from "./skill-use";
import { TaskStatsLine } from "./task-stats-line";
import type { StreamRenderContext } from "./message-stream";
/** Duration the "Copied" tooltip stays visible (milliseconds). */
const COPIED_MS = 1200;
/**
* Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0`
* rather than `hidden`) — it surfaces on hovering the message list, and because the space is
* always reserved, surfacing it never pushes content below it down (using `hidden` would cause
* every item to jitter). Keyboard users can also reveal it via `focus-within` (otherwise the copy
* button would be focusable but never visible).
*
* When `text` is omitted, only the timestamp is shown, no copy button — there's no clear meaning
* to copying an image message.
*/
function MessageMeta({
atMs,
text,
align = "left",
}: {
atMs?: number;
text?: string;
align?: "left" | "right";
}) {
const { locale } = useLocale();
const [copied, setCopied] = useState(false);
const copy = () => {
if (text === undefined) return;
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), COPIED_MS);
};
return (
<div
className={`flex h-5 items-center gap-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 ${
align === "right" ? "justify-end" : "justify-start"
}`}
>
{atMs !== undefined && (
<span className="text-[11px] text-gray-400">{formatMessageTime(atMs, locale)}</span>
)}
{text !== undefined && (
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyMessage}
aria-label={S.chat.copyMessage}
onClick={copy}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
</button>
)}
</div>
);
}
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
switch (item.kind) {
case "user_text": {
// Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
const handoff = parseHandoffMessage(item.text);
if (handoff) return <HandoffBanner origin={handoff} />;
// Source block for a scheduled-task trigger: collapsed into a single-line notice, with the task's prompt body rendered as usual (verbatim on the Trace page).
const scheduled = parseScheduledMessage(item.text);
// Source block for a skill invocation: parsing continues on scheduled's remaining body
// (handoff -> scheduled -> skills, blocks stripped in a chain); a match collapses into a
// "using skill" banner, with the body rendered as usual.
const afterScheduled = scheduled ? scheduled.rest : item.text;
const skills = parseSkillsMessage(afterScheduled);
// Attachment row restoration: for models that don't support images, input images are
// written to disk as a path row; this pulls that out at render time and shows the actual
// image. Mirrors the vision-model path (user_text + user_image as separate messages) in
// shape: one bubble for the text, one bubble per image, styled the same as user_image.
const { text, images } = splitImageAttachments(skills ? skills.rest : afterScheduled);
return (
<>
{scheduled && <ScheduledBanner origin={scheduled.origin} />}
{skills && <SkillsBanner names={skills.skills} />}
{text && (
<div className="anim-msg group my-4 flex flex-col items-end">
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
{/* break-words: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing. */}
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-900 dark:text-gray-100">
{text}
</p>
</div>
<MessageMeta
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
text={text}
align="right"
/>
</div>
)}
{images.map((src, i) => (
<div key={i} className="anim-msg group my-4 flex flex-col items-end">
<div className="max-w-[88%] rounded-lg bg-gray-100 p-1.5 md:max-w-[75%] dark:bg-gray-800">
<ZoomableImage
src={src}
alt={S.chat.imageAlt}
className="max-h-48 max-w-full rounded-md"
/>
</div>
<MessageMeta
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
align="right"
/>
</div>
))}
</>
);
}
case "user_image":
return (
<div className="anim-msg group my-4 flex flex-col items-end">
<div className="max-w-[88%] rounded-lg bg-gray-100 p-1.5 md:max-w-[75%] dark:bg-gray-800">
<ZoomableImage
src={item.imageUrl}
alt={S.chat.imageAlt}
className="max-h-48 max-w-full rounded-md"
/>
</div>
<MessageMeta {...(item.atMs !== undefined ? { atMs: item.atMs } : {})} align="right" />
</div>
);
case "assistant_text":
// Doesn't attach MessageMeta: this turn's reply timestamp and copy both belong to the
// stats line right below it (TaskStatsLine) — that line already serves as this reply's
// footer, and rendering both would pop up two copy buttons in the same spot. The stats
// line's copy grabs **all** of this turn's assistant text (see collectTaskAssistant),
// which is more useful than copying segment by segment.
return (
<div className="md-body anim-msg my-3 text-base leading-relaxed text-gray-800 dark:text-gray-100">
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation). */}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
{item.streaming && <span className="animate-pulse text-gray-400">▌</span>}
{item.stopReason && item.stopReason !== "completed" && (
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
)}
{/* File summary card (Codex-style): aggregates file references in the text once streaming ends (lists only ones confirmed to exist). */}
{!item.streaming && ctx.onOpenFile && ctx.statFiles && (
<MessageFilesCard
text={item.text}
workspace={ctx.workspace ?? null}
statFiles={ctx.statFiles}
onOpenFile={ctx.onOpenFile}
/>
)}
</div>
);
case "thinking":
return <ThinkingBlock item={item} />;
case "tool_call":
return <ToolCallCard item={item} ctx={ctx} />;
case "subagent":
return (
<div className="anim-msg my-2">
<SubagentCard sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
</div>
);
case "abort":
return (
<p className="anim-msg my-1 font-mono text-xs text-gray-500 dark:text-gray-400">
{S.chat.aborted(item.reason)}
</p>
);
case "reconnect":
return (
<p className="anim-msg my-1 font-mono text-xs text-amber-600 dark:text-amber-500">
{S.chat.reconnect(
item.status,
item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting",
item.attempt,
)}
</p>
);
case "compaction":
return <CompactionBanner item={item} />;
case "task_stats":
return (
<TaskStatsLine
stats={item.stats}
assistantText={item.assistantText}
cost={item.stats ? (ctx.taskCost?.(item.stats) ?? null) : null}
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
/>
);
}
}
@@ -0,0 +1,188 @@
/**
* Message stream container: renders the ChatItem list; auto-sticks to the
* bottom while streaming — an upward swipe immediately pauses follow, and scrolling back near
* the bottom resumes it (see stream-follow.ts for the exact rule).
* StreamRenderContext threads the pending-approval map and approval callback down to tool
* cards at any nesting depth.
*/
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
import type { ChatItem } from "../../lib/omni/stream-model";
import type { TaskStats } from "../../lib/omni/task-stats";
import type { PendingApproval } from "./use-session-stream";
import { EmptyState } from "../../components/ui/empty-state";
import { MessageItem } from "./message-item";
import { WorkGroup, isWorkItem } from "./work-group";
import { createStreamFollow } from "./stream-follow";
import type { StreamFollow } from "./stream-follow";
/** Context passed down to nested rendering (pending approvals + approval submit callback + current origin chain). */
export interface StreamRenderContext {
/** approvalKey(origin, toolCallId) → pending approval (disambiguates by origin when parent/child session tool_call_ids collide). */
pendingApprovals: ReadonlyMap<string, PendingApproval>;
onApprove: (toolCallId: string, decision: "allow" | "deny", origin: string[]) => Promise<void>;
/** Origin chain at the current render level (empty array for the main session; subagent cards append one level each). */
origin: string[];
/**
* Whether the Task at this level is still running (taskState for the main session, its own
* running state for a subagent card). The "Reasoning & Tools" group uses this to decide: as
* long as the model might still call another tool, the trailing group always shows "Running".
*/
taskRunning: boolean;
/** Converts this turn's stats into cost (USD) using the current Model pricing; returns null when no price is configured (cost is hidden). */
taskCost?: (stats: TaskStats) => number | null;
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
onOpenFile?: (path: string) => void;
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
workspace?: string | null;
/** Batch file-existence check (with session-level caching); the card doesn't render if this isn't wired up. */
statFiles?: (paths: string[]) => Promise<ReadonlySet<string>>;
}
/** Pure list rendering (reused recursively inside subagent cards): consecutive thinking + tool-call items are aggregated into one "Reasoning & Tools" group. */
export function MessageItems({ items, ctx }: { items: ChatItem[]; ctx: StreamRenderContext }) {
// Split into segments first — group (consecutive thinking + tool calls) or single (everything
// else) — then render. WorkGroup needs to know whether it's the last segment (current turn
// still in progress) to decide its default expanded/collapsed state.
type Seg = { type: "group"; items: ChatItem[] } | { type: "single"; item: ChatItem };
const segs: Seg[] = [];
let run: ChatItem[] = [];
const flushRun = () => {
if (run.length > 0) {
segs.push({ type: "group", items: run });
run = [];
}
};
for (const item of items) {
if (isWorkItem(item)) run.push(item);
else {
flushRun();
segs.push({ type: "single", item });
}
}
flushRun();
const renderSeg = (seg: Seg, i: number): ReactNode =>
seg.type === "group" ? (
<WorkGroup
key={`wg-${seg.items[0]!.id}`}
items={seg.items}
ctx={ctx}
isLast={i === segs.length - 1}
/>
) : (
<MessageItem key={seg.item.id} item={seg.item} ctx={ctx} />
);
/**
* Each turn's AI-side content (reply, reasoning-and-tools group, compaction banner, ...) plus
* its trailing stats row shares a single group container. The stats row is that turn's footer
* (timestamp and copy button live there, and the whole row is transparent by default), so
* hovering **any** content within the turn must be able to reveal it.
*
* The stats row can't simply be paired with the adjacent assistant reply — if compaction
* happens mid-turn, the compaction banner gets inserted between them (items:
* assistant_text → compaction → task_stats), breaking the pairing instantly. The stats row
* would then become a strip that's both invisible and unhoverable (the element's own `group`
* class doesn't apply to itself: group-hover is a descendant selector).
*
* The container is created as soon as the turn's **first** segment appears, keyed by that
* segment's id, and the key never changes afterward. If we waited for the stats row to arrive
* before moving already-rendered groups into a new container, React would treat it as a
* position change — unmount and remount — and the WorkGroup and tool-card expanded states
* (each backed by its own internal useState) would reset instantly: any tool details the user
* had manually expanded would collapse the moment the reply finishes.
*
* User messages never enter this container: they have their own footer, and including them
* would make hovering a user message also light up the AI's stats row.
*/
const nodes: ReactNode[] = [];
let turn: { seg: Seg; i: number }[] = [];
const flushTurn = () => {
if (turn.length === 0) return;
const first = turn[0]!.seg;
const key = first.type === "group" ? first.items[0]!.id : first.item.id;
const body = turn;
turn = [];
nodes.push(
<div key={`turn-${key}`} className="group">
{body.map((t) => renderSeg(t.seg, t.i))}
</div>,
);
};
for (let i = 0; i < segs.length; i++) {
const seg = segs[i]!;
const isUserMsg =
seg.type === "single" && (seg.item.kind === "user_text" || seg.item.kind === "user_image");
if (isUserMsg) {
flushTurn();
nodes.push(renderSeg(seg, i));
continue;
}
turn.push({ seg, i });
// Stats row = end of this turn; seal the container here. Anything after belongs to the next turn.
if (seg.type === "single" && seg.item.kind === "task_stats") flushTurn();
}
flushTurn(); // Turn not yet finished (stats row hasn't arrived): container already exists, subsequent content is appended directly
return <>{nodes}</>;
}
export function MessageStream({
items,
version,
ctx,
}: {
items: ChatItem[];
/** View-model version number (a repaint signal for in-place updates that also drives auto-scroll). */
version: number;
ctx: StreamRenderContext;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
const followRef = useRef<StreamFollow | null>(null);
const follow = (followRef.current ??= createStreamFollow());
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
follow.scrolled({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
};
useEffect(() => {
const el = scrollRef.current;
if (el && follow.stick) el.scrollTop = el.scrollHeight;
}, [version, follow]);
return (
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => follow.wheel(e.deltaY)}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
</div>
</div>
);
}
@@ -0,0 +1,21 @@
/**
* Origin hint for a scheduled-task-triggered message: the origin block (<scheduled_task>) is
* not rendered verbatim on screen; it collapses into one line reading "Triggered by scheduled
* task '<name>'" plus a localized trigger timestamp (a static display with no navigation — task
* management lives on the Agent settings page's "Scheduled Tasks" tab). The task prompt body
* itself is rendered as usual by the caller.
*/
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
import type { ScheduledOrigin } from "./agent-mentions";
export function ScheduledBanner({ origin }: { origin: ScheduledOrigin }) {
return (
<p className="anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
{S.chat.scheduledFrom(origin.name)}
{origin.firedAt && (
<span className="text-gray-400 dark:text-gray-500">{formatDateTime(origin.firedAt)}</span>
)}
</p>
);
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Skill invocation for the chat input area (pure logic, shared by chat-input / message-item /
* the Skill library page, and unit tests).
* The `<use_skills>` block is a globally agreed-upon format (shared by frontend, backend, and the
* core prompt template):
*
* <use_skills>
* skills: name1, name2
* </use_skills>
* (blank line) body text…
*
* - `buildSkillsMessage`: prepends the source block to the body when selected skills are
* non-empty; returns the body unchanged for an empty list;
* - `parseSkillsMessage`: only recognizes a block at **the start of the message**, returning the
* skill names and the remaining body (the message stream collapses the raw block into a
* "Skill used" banner and renders the body normally; the Trace page shows it as-is);
* - `localizedText` / `skillSlashItems`: pure assembly of UI-language text lookup and slash
* skill command items.
*/
import type { SkillMetadataItem } from "@prismshadow/penguin-server/api";
/** Book icon (24×24 line path): shared across skill-related UI (nav items are inlined separately in sidebar / app-layout). */
export const BOOK_ICON =
"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z";
/** Generates a message body with a `<use_skills>` block: an empty list omits the block; when there's no body text, only the block is returned (no trailing blank line). */
export function buildSkillsMessage(names: string[], text: string): string {
if (names.length === 0) return text;
const block = `<use_skills>\nskills: ${names.join(", ")}\n</use_skills>`;
return text ? `${block}\n\n${text}` : block;
}
/**
* Reverse parse of `buildSkillsMessage`: when the message **starts with** a `<use_skills>`
* block, returns the skill names and the remaining body; otherwise returns null (a block
* appearing mid-body is treated as plain text and not parsed). The `skills:` line is split by
* comma and whitespace-trimmed; an empty list is treated as not a source block.
*/
export function parseSkillsMessage(text: string): { skills: string[]; rest: string } | null {
const m = /^<use_skills>\nskills: ([^\n]+)\n<\/use_skills>/.exec(text);
if (!m) return null;
const skills = m[1]!
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (skills.length === 0) return null;
return { skills, rest: text.slice(m[0].length).replace(/^\n+/, "") };
}
/**
* Picks copy based on the UI language: uses the Chinese value when locale is zh and one is
* provided, otherwise falls back to English. Shared by the skill library page's group
* name/group description/skill description, the input area's chip hint, and the slash skill
* item's description (the library metadata's Chinese fields are all optional).
*/
export function localizedText(locale: "zh" | "en", enText: string, zhText?: string): string {
return locale === "zh" && zhText ? zhText : enText;
}
/** Minimal shape needed to pick a short description's copy (SkillMetadataItem is a superset). */
export interface SkillDescLike {
description: string;
shortDescription?: string;
shortDescriptionZh?: string;
}
/**
* Picks the **short description** for the UI language (falls back to the full description if
* missing): language takes priority over length — zh tries shortDescriptionZh ->
* shortDescription -> description in order; en tries shortDescription -> description. Shared by
* the skill library card, the composer's skills dropdown, and the slash description.
*/
export function localizedShortText(locale: "zh" | "en", s: SkillDescLike): string {
if (locale === "zh") {
return s.shortDescriptionZh || s.shortDescription || s.description;
}
return s.shortDescription || s.description;
}
/**
* Search filter for the skills dropdown (pure function, shared by chat-input's SkillSelect and
* unit tests): case-insensitive substring match against the skill name and localized
* description; an empty query (including whitespace-only) returns the full list.
*/
export function filterSkills(
skills: SkillMetadataItem[],
locale: "zh" | "en",
query: string,
): SkillMetadataItem[] {
const q = query.trim().toLowerCase();
if (!q) return skills;
// Match target matches what's displayed: name + localized short text (zh can match the Chinese short description, en is always English).
return skills.filter(
(s) =>
s.name.toLowerCase().includes(q) || localizedShortText(locale, s).toLowerCase().includes(q),
);
}
/** Skill command item for the slash menu (`/<skill_name>` toggles that skill's selection). */
export interface SkillSlashItem {
/** Skill name (the run action toggles selection by name). */
name: string;
/** Menu command: `/<skill_name>` (slash filtering matches on this prefix). */
cmd: string;
/** Menu description: the skill's short description first, falling back to the full description if missing (per the UI language; truncated by the menu's own styling if too long). */
desc: string;
}
/** Assembles installed skills into slash command items (pure function, shared by chat-input's commands and unit tests). */
export function skillSlashItems(
skills: SkillMetadataItem[],
locale: "zh" | "en",
): SkillSlashItem[] {
return skills.map((s) => ({
name: s.name,
cmd: `/${s.name}`,
desc: localizedShortText(locale, s),
}));
}
@@ -0,0 +1,18 @@
/**
* Source notice for a skill invocation: the `<use_skills>` block at the start of a message isn't
* shown verbatim, it's collapsed into a single line reading "Using skills: <names>" (book icon +
* static text, no navigation — skill management lives on the skill library page); the body text
* after the block is rendered as usual by the caller.
*/
import { S } from "../../lib/strings";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { BOOK_ICON } from "./skill-use";
export function SkillsBanner({ names }: { names: string[] }) {
return (
<p className="anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
<GlyphIcon d={BOOK_ICON} className="text-gray-400 dark:text-gray-500" />
{S.chat.skillsBanner(names)}
</p>
);
}
@@ -0,0 +1,76 @@
/**
* Follow-decision logic for auto-stick-to-bottom in the message stream (pure logic,
* unit-testable; issue #75).
*
* "Exiting follow" and "resuming follow" are two independent judgments:
* - Exit: any user intent to scroll up takes effect immediately — detected from the input event
* itself for wheel-up / touch-drag-down (even if position doesn't change, e.g. already at the
* top), and from a scrollTop regression for scrollbar-drag-up / keyboard. This doesn't rely on
* an "80px from bottom" threshold — otherwise a short scroll area with less than 80px of
* scrollable slack could never exit, and streaming updates would keep fighting the upward
* gesture back and forth.
* - Resume: only resumes once the user brings the viewport back near the bottom (within 80px).
* Programmatic stick-to-bottom only happens while following (idempotent); content shrinking
* (e.g. a group collapsing) that clamps scrollTop downward while still touching the bottom
* (≤1px) doesn't count as scrolling up and doesn't change intent.
* - The first scroll event has no direction to judge from, so it initializes from position
* (≥80px from bottom is treated as being at a historical position, i.e. not following) — this
* doesn't depend on the call-ordering guarantee of "must stick to bottom programmatically right
* after mount."
*/
export interface ScrollMetrics {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
export interface StreamFollow {
/** Whether it should currently auto-stick to bottom on streaming updates. */
readonly stick: boolean;
/** wheel: deltaY < 0 is scroll-up intent, exits follow immediately. */
wheel(deltaY: number): void;
touchStart(clientY: number): void;
/** Touch drag: finger moving down = content scrolling up, exits follow. */
touchMove(clientY: number): void;
touchEnd(): void;
/** scroll event (user scrolling and programmatic stick-to-bottom share this path): moving up exits; otherwise nearing the bottom resumes; the first event initializes from position. */
scrolled(m: ScrollMetrics): void;
}
export function createStreamFollow(): StreamFollow {
let stick = true;
let lastTop: number | null = null;
let touchY: number | null = null;
return {
get stick() {
return stick;
},
wheel(deltaY) {
if (deltaY < 0) stick = false;
},
touchStart(clientY) {
touchY = clientY;
},
touchMove(clientY) {
if (touchY !== null && clientY > touchY) stick = false;
touchY = clientY;
},
touchEnd() {
touchY = null;
},
scrolled(m) {
const dist = m.scrollHeight - m.scrollTop - m.clientHeight;
const prev = lastTop;
lastTop = m.scrollTop;
if (prev === null) {
stick = dist < 80;
return;
}
if (m.scrollTop < prev && dist > 1) {
stick = false;
return;
}
if (dist < 80) stick = true;
},
};
}
@@ -0,0 +1,71 @@
/**
* Subagent card: nested inside a run_subagent card (or as a
* standalone card), renders the subsession's full streaming process by following the origin
* chain — expanded by default while running, collapsed once finished; nesting inside the card
* uses the same rendering recursively.
*/
import { useEffect, useRef, useState } from "react";
import { S } from "../../lib/strings";
import type { StreamModel } from "../../lib/omni/stream-model";
import { Chevron } from "../../components/ui/chevron";
import { MessageItems } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
export function SubagentCard({
sessionId,
model,
running,
ctx,
}: {
sessionId: string;
model: StreamModel;
running: boolean;
ctx: StreamRenderContext;
}) {
const [open, setOpen] = useState(running);
const userToggled = useRef(false);
const wasRunning = useRef(running);
// Append this card's subsession id to the nesting level: tool cards within the card match
// pending approvals by the full origin chain; the running state inside the subsession follows
// this card's own running prop, not the parent session's taskRunning.
const nestedCtx: StreamRenderContext = {
...ctx,
...(sessionId ? { origin: [...ctx.origin, sessionId] } : {}),
taskRunning: running,
};
useEffect(() => {
// Auto-collapse once finished (respects the user's choice if they manually toggled it).
if (wasRunning.current && !running && !userToggled.current) setOpen(false);
wasRunning.current = running;
}, [running]);
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50/60 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
onClick={() => {
userToggled.current = true;
setOpen((v) => !v);
}}
className="flex w-full items-center gap-2 rounded-t-md px-3 py-1.5 text-left text-xs text-gray-500 transition-colors duration-200 hover:bg-gray-100/70 dark:text-gray-400 dark:hover:bg-gray-800/50"
>
<Chevron open={open} />
<span className="font-semibold text-gray-700 dark:text-gray-300">{S.chat.subagent}</span>
{sessionId && <span className="truncate font-mono text-gray-400">{sessionId}</span>}
{running && (
<span className="ml-auto flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-gray-400 border-t-transparent" />
{S.chat.subagentRunning}
</span>
)}
</button>
{open && (
<div className="anim-fade border-t border-dashed border-gray-200 px-3 py-1 dark:border-gray-800">
<MessageItems items={model.items} ctx={nestedCtx} />
</div>
)}
</div>
);
}
@@ -0,0 +1,133 @@
/**
* Task stats line: after each Task finishes, shows "this turn's usage" at the
* **bottom-left** of the AI reply — input, output, output TPS, elapsed time, and cost (converted
* in real time from the current Model's pricing, hidden if no pricing is configured); all five
* share the same basis (this turn's usage), each expressed uniformly as icon + value (no text
* labels); the reply timestamp and a copy button sit at the end (copies this turn's assistant
* text, falling back to the stats themselves when there's no text).
* The whole line is invisible but takes up space by default, surfacing only on hovering the
* reply or the line itself — matching the same convention, font size, and color as the user
* message footer: the AI's footer sits bottom-left, the user's sits bottom-right, symmetric on
* both sides.
* Arrow direction reads as "where the tokens go": **up arrow = input** (sent up to the model),
* **down arrow = output** (returned by the model).
* This line only answers "how much did this turn cost", **it doesn't break down the cache
* composition** — how much of the input hit cache is a debugging-level detail that belongs to the
* Trace page's turn card (which shows cache hits and hit rate); the current **context usage** is
* likewise not repeated here — it's expressed by the ring below the input box (see ContextGauge
* in chat-input).
* This line sits right below this turn's AI reply and simultaneously **serves as that reply's
* footer**: it provides the reply timestamp and copy button at the end, so the assistant message
* itself doesn't render a separate one (otherwise two copy buttons would pop up in the same spot).
*/
import { useState } from "react";
import { formatTaskStats } from "../../lib/omni/task-stats";
import type { TaskStats } from "../../lib/omni/task-stats";
import {
formatMessageTime,
formatMoney,
formatTps,
humanizeDuration,
humanizeTokens,
} from "../../lib/format";
import { STAT_ICONS } from "../../lib/stat-icons";
import { S } from "../../lib/strings";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { useTheme } from "../../state/theme";
import { useLocale } from "../../state/locale";
/** Icon + value; hover explains what this item is (the icon alone doesn't convey the exact meaning). */
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
return (
<span title={label} aria-label={label} className="flex items-center gap-1">
<GlyphIcon d={icon} />
{value}
</span>
);
}
export function TaskStatsLine({
stats,
assistantText,
cost,
atMs,
}: {
/** This turn's stats; `null` = no token_usage for this turn (reply was aborted) -> only the timestamp and copy are shown, no stat numbers drawn. */
stats: TaskStats | null;
/** This turn's assistant text (the copy target); falls back to the stats when empty. */
assistantText?: string;
/** This turn's cost (USD); null = no pricing configured, cost isn't shown. */
cost?: number | null;
/** Timestamp of this turn's AI reply (this line is that reply's footer). */
atMs?: number;
}) {
const [copied, setCopied] = useState(false);
const { currency } = useTheme();
const { locale } = useLocale();
// This turn's usage (not a context snapshot): input = this Task's cached + uncached input.
const b = stats?.tokensByBucket;
const input = b ? b.cacheRead + b.cacheWrite : 0;
const copy = () => {
const text =
assistantText?.trim() || stats === null ? (assistantText ?? "") : formatTaskStats(stats);
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
// The whole line is invisible but **takes up space** by default (opacity-0, not hidden) —
// matching the user message footer's convention: only appears on hover, and because the space
// is always reserved, appearing never pushes content below it down. Font size/color also match
// that footer; the AI's footer sits bottom-left, the user's sits bottom-right, symmetric.
return (
<div className="-mt-2 flex h-5 flex-wrap items-center justify-start gap-x-3 text-[11px] text-gray-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 dark:text-gray-500">
{/* Timestamp leads: it's this reply's identity (when it was said), the stat numbers are an
annotation. When this turn has no token_usage (reply was aborted), only the timestamp
and copy remain — nothing is fabricated for what wasn't measured. */}
{atMs !== undefined && <span>{formatMessageTime(atMs, locale)}</span>}
{stats && b && (
<>
<StatChip
icon={STAT_ICONS.input}
value={humanizeTokens(input)}
label={S.chat.statInput}
/>
<StatChip
icon={STAT_ICONS.output}
value={humanizeTokens(b.output)}
label={S.chat.statOutput}
/>
<StatChip
icon={STAT_ICONS.tps}
value={formatTps(stats.outputTps)}
label={S.chat.statTps}
/>
{cost != null && (
<StatChip
icon={STAT_ICONS.cost}
value={formatMoney(cost, currency)}
label={`${S.chat.statCost}(${currency})`}
/>
)}
<StatChip
icon={STAT_ICONS.elapsed}
value={humanizeDuration(stats.elapsedDeltaMs)}
label={S.chat.statElapsed}
/>
</>
)}
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyReply}
aria-label={S.chat.copyReply}
onClick={copy}
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
</button>
</div>
);
}
@@ -0,0 +1,55 @@
/**
* Thinking block: one row within the "Reasoning & Tools" group — status
* icon + "Thinking" + elapsed time, click to expand the full thinking text. Shares the same row
* style and set of running-state icons (in progress / done / failed) as tool cards.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import type { ThinkingItem } from "../../lib/omni/stream-model";
import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { LiveDuration } from "./live-duration";
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
const [open, setOpen] = useState(false);
const failed = item.stopReason !== undefined && item.stopReason !== "completed";
const state: RunState = item.streaming ? "running" : failed ? "failed" : "done";
const stateLabel = item.streaming
? S.chat.workRunning
: failed
? item.stopReason
: S.chat.workDone;
return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<StatusIcon state={state} label={stateLabel} />
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{S.chat.thinking}</span>
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
{item.streaming ? (
<LiveDuration sinceMs={item.startedAtMs} />
) : item.durationMs !== undefined ? (
humanizeDuration(item.durationMs)
) : null}
</span>
{failed && <span className="font-mono text-xs text-gray-400">[{item.stopReason}]</span>}
<span className="min-w-0 flex-1" />
<Chevron open={open} className="text-gray-400" />
</button>
{open && (
<div className="md-body anim-fade mx-3 mb-2 rounded-md bg-gray-50 px-3 py-2 text-sm leading-relaxed text-gray-600 dark:bg-gray-900/60 dark:text-gray-400">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.thinking}</ReactMarkdown>
</div>
)}
</div>
);
}
@@ -0,0 +1,243 @@
/**
* Tool call card: collapses to a single line by
* default — status icon + tool name + duration (a live-ticking timer while running) + status
* badge; clicking expands full arguments, output, and any nested subagent.
* The pending-approval row is always visible regardless of collapsed state; when a pending
* approval appears anywhere in a nested subagent chain, the card auto-expands once (respecting
* the user's choice if they've manually collapsed it since).
*
* Duration accounting = **argument-generation segment + execution segment** (excludes time
* spent waiting on human approval): the model streaming out arguments token by token is often
* slower than the tool call itself, so reporting only the execution segment would badly
* understate this step's cost. While waiting on approval, the already-settled generation
* segment is shown, with a separate "Waiting for approval" badge attached.
*/
import { useEffect, useRef, useState } from "react";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { approvalKey } from "../../lib/omni/stream-model";
import type { ToolCallItem } from "../../lib/omni/stream-model";
import { Badge, stopReasonTone } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { ApprovalButtons } from "./approval-buttons";
import { LiveDuration } from "./live-duration";
import { SubagentCard } from "./subagent-card";
import type { StreamRenderContext } from "./message-stream";
/**
* Argument preview (same approach as the CLI's tool-render): exec_command shows `$ <cmd>`,
* other tools show a single-line `name(args)` prefix. Arguments may be incomplete JSON
* (mid-stream), so extraction is done leniently.
*/
function previewArguments(name: string, argsJson: string): string {
if (name === "exec_command") {
const cmd = extractStringField(argsJson, "cmd");
if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`;
}
return argsJson.replace(/\s+/g, " ").trim();
}
/** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */
function extractStringField(argsJson: string, field: string): string | null {
const key = `"${field}"`;
const keyIndex = argsJson.indexOf(key);
if (keyIndex === -1) return null;
let i = keyIndex + key.length;
while (/\s/.test(argsJson[i] ?? "")) i += 1;
if (argsJson[i] !== ":") return null;
i += 1;
while (/\s/.test(argsJson[i] ?? "")) i += 1;
if (argsJson[i] !== '"') return null;
i += 1;
let out = "";
let escaped = false;
for (; i < argsJson.length; i += 1) {
const ch = argsJson[i]!;
if (escaped) {
out += ch === "n" ? "\n" : ch === "t" ? "\t" : ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') return out;
out += ch;
}
return out;
}
export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) {
const [open, setOpen] = useState(false);
const userToggled = useRef(false);
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
// Whether there's a pending approval anywhere in the nested subagent chain (keys are `origin-chain toolCallId`, matched by prefix at any depth).
const nestedPrefix = item.subagentSessionId
? [...ctx.origin, item.subagentSessionId].join("/")
: null;
const hasNestedPending =
nestedPrefix !== null &&
[...ctx.pendingApprovals.keys()].some(
(k) => k.startsWith(`${nestedPrefix} `) || k.startsWith(`${nestedPrefix}/`),
);
// A nested pending approval needs the user's action: auto-expand once (respected if the user manually collapses it afterward).
useEffect(() => {
if (hasNestedPending && !userToggled.current) setOpen(true);
}, [hasNestedPending]);
const preview = previewArguments(item.name, item.argumentsText);
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
const executing = item.callComplete && !item.outputComplete && !pending;
// Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives.
const genMs =
item.argStartedAtMs !== undefined && item.callStartedAtMs !== undefined
? Math.max(0, item.callStartedAtMs - item.argStartedAtMs)
: 0;
const failed =
(item.callStopReason !== undefined && item.callStopReason !== "completed") ||
(item.outputStopReason !== undefined && item.outputStopReason !== "completed");
const state: RunState = pending
? "waiting"
: executing || item.callStreaming
? "running"
: failed
? "failed"
: "done";
const stateLabel = pending
? S.chat.approvalWaiting
: state === "running"
? S.chat.workRunning
: state === "done"
? S.chat.workDone
: (item.outputStopReason ?? item.callStopReason);
return (
<div>
{/* Collapsed row: status icon + tool name + total duration (generation + execution, excluding approval wait). Expand chevron on the right. */}
<button
type="button"
aria-expanded={open}
onClick={() => {
userToggled.current = true;
setOpen((v) => !v);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<StatusIcon state={state} label={stateLabel} />
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
{item.name || S.chat.unknownTool}
</span>
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
{item.durationMs !== undefined ? (
humanizeDuration(item.durationMs)
) : executing ? (
// Execution timer: argument-generation baseline + a live segment starting from approval grant (or from call completion if no approval was needed).
<LiveDuration sinceMs={item.approvalAtMs ?? item.callStartedAtMs} offsetMs={genMs} />
) : pending ? (
// No ticking while waiting on approval: frozen at the settled argument-generation segment.
genMs > 0 ? (
humanizeDuration(genMs)
) : null
) : item.callStreaming ? (
// Generating arguments: live-ticking timer (falls back to a pulsing ellipsis when no start time is known).
item.argStartedAtMs !== undefined ? (
<LiveDuration sinceMs={item.argStartedAtMs} />
) : (
<span className="animate-pulse">…</span>
)
) : null}
</span>
{pending && (
<span className="shrink-0 font-mono text-xs text-amber-600 dark:text-amber-400">
{S.chat.approvalWaiting}
</span>
)}
{item.callStopReason && item.callStopReason !== "completed" && (
<Badge tone={stopReasonTone(item.callStopReason)}>{item.callStopReason}</Badge>
)}
{item.outputStopReason && item.outputStopReason !== "completed" && (
<Badge tone={stopReasonTone(item.outputStopReason)}>{item.outputStopReason}</Badge>
)}
{item.decision && (
<Badge tone={item.decision === "allow" ? "green" : "red"}>
{item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny}
{" · "}
{item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto}
</Badge>
)}
<span className="min-w-0 flex-1" />
{/* Expand indicator on the right */}
<Chevron open={open} className="text-gray-400" />
</button>
{/* Pending approval: always visible regardless of collapsed state — shows the tool name and arguments so the user knows what they're approving. */}
{pending && (
<div className="border-t border-gray-100 bg-amber-50 px-3 py-2 dark:border-gray-800 dark:bg-amber-950/30">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="shrink-0 rounded-md bg-white px-1.5 py-0.5 font-mono text-xs font-semibold text-gray-700 dark:bg-gray-900 dark:text-gray-300">
{item.name || S.chat.unknownTool}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-600 dark:text-gray-400">
{preview}
</span>
</div>
<ApprovalButtons
onDecide={(decision) => ctx.onApprove(item.toolCallId, decision, ctx.origin)}
/>
</div>
)}
{/* Subagent card: always visible (not hidden after completion, only collapsed by default), unaffected by the tool card's collapsed state. */}
{item.subagent && (
<div className="px-3 pb-2">
<SubagentCard
sessionId={item.subagentSessionId ?? ""}
model={item.subagent}
running={!item.outputComplete}
ctx={ctx}
/>
</div>
)}
{/* Expanded details: full arguments / output */}
{open && (
<div className="anim-fade">
{item.argumentsText && (
// Arguments are shown as a fully wrapped block (no height cap, no scrollbar): the
// arguments are key to understanding this call, and tucking them into an inner
// scroll area would make them hard to read and fight with the message stream's own scroll.
<pre className="whitespace-pre-wrap break-all border-t border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-400">
{item.argumentsText}
</pre>
)}
{(item.output || item.outputStreaming) && (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap border-t border-gray-100 px-3 py-2 text-xs leading-5 text-gray-600 dark:border-gray-800 dark:text-gray-300">
{item.output}
{item.outputStreaming && <span className="animate-pulse">▌</span>}
</pre>
)}
{/* Tool output images (e.g. read_image): shown as thumbnails, click to zoom (ZoomableImage). */}
{item.images && item.images.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-gray-100 px-3 py-2 dark:border-gray-800">
{item.images.map((src, i) => (
<ZoomableImage
key={i}
src={src}
alt={S.chat.toolImageAlt}
className="max-h-40 max-w-full rounded-md border border-gray-200 dark:border-gray-700"
/>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
/**
* 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).
*
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
* structured file-write signal at all (the only built-in tool is the opaque exec_command shell),
* so there's no "Agent output" list to maintain; a file clicked in a message jumps straight to
* 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.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import type { SheetSnap } from "../../components/ui/sheet";
export interface FilesPanelState {
open: boolean;
setOpen: (open: boolean) => void;
/** Snap point for the mobile bottom Sheet (half = browsing / full = preview); unused in the desktop docked state. */
sheetSnap: SheetSnap;
setSheetSnap: (snap: SheetSnap) => void;
/** Clicking a file chip in a message: commands WorkspaceBrowser to navigate to and locate that file. */
browsePath: (path: string) => void;
/** The external navigation command produced by browsePath; each call creates a new object
* reference, ensuring that clicking the same file again still re-triggers WorkspaceBrowser's
* locate effect (compared by object identity, not by path value). */
openRequest: { path: string } | null;
/** Docked at >=1024px (lg); otherwise falls back to a Drawer overlay. Responsive, updates live
* 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);
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
const [openRequest, setOpenRequest] = useState<{ path: string } | null>(null);
/** Opening the panel defaults to browsing intent (Sheet snaps to half); browsePath's preview
* intent overrides it to full within the same batch, without flickering. Closing doesn't
* change the snap point. In the desktop docked state, sheetSnap is unused by anything, so this has no side effect there. */
const setOpen = useCallback((next: boolean) => {
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 [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.
useEffect(() => {
setOpenRequest(null);
}, [sessionId]);
useEffect(() => {
const mq = window.matchMedia(DOCK_QUERY);
const onChange = (e: MediaQueryListEvent) => setIsDocked(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const browsePath = useCallback((path: string) => {
setSheetSnap("full"); // Preview intent: opens the mobile Sheet fully
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,
sheetSnap,
setSheetSnap,
browsePath,
openRequest,
isDocked,
width,
resizing,
startResize,
resetWidth,
panelRef,
};
}
@@ -0,0 +1,125 @@
/**
* Draft auto-cache for an existing session's input area: text +
* @-handoff target + selected skills are cached to localStorage keyed by "user x Session"
* (see draft-cache's sessionDraftKey; the user dimension prevents cross-account leakage on
* the same browser, #68), restored after navigating away/reloading. Model / Workspace /
* approval mode are locked to the Session and need no caching.
*
* Write strategy matches the draft page: text is debounced and merge-written (an unflushed
* edit gets one extra flush before switching sessions/unmounting); @ target and skill
* selection write immediately; **clearing content deletes the key** (leaving an empty shell
* per session would bloat localStorage); discard on a successful send cancels the pending
* timer first, otherwise it would write the just-cleared draft back.
*
* ChatPage keys session content blocks by sessionId, so ChatInput remounts accordingly, but
* this hook is mounted on ChatPage itself and does not remount — switching sessions is
* handled by the [key] effect: cleanup flushes the old session via its stale closure, setup
* resets the refs to the new session's initial values.
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useAuth } from "../../state/auth";
import { clearDraft, loadDraft, saveDraft, sessionDraftKey } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
const SAVE_DEBOUNCE_MS = 300;
export function useSessionDraft(sessionId: string | null): {
/** Initial draft value for the current session (recomputed with sessionId; consumed by ChatInput after it remounts on sessionId). */
initial: DraftCache;
onTextChange: (text: string) => void;
onHandoffTargetChange: (agentId: string | null) => void;
/** Selected-skills change (wired directly to ChatInput's onSkillsChange; a discrete action writes immediately). */
onSkillsChange: (names: string[]) => void;
/** Discard the current session's draft after a successful send. */
discard: () => void;
} {
// No user (shouldn't happen under RequireAuth) disables caching entirely: must not read/write account-agnostic keys (#68).
const userId = useAuth().user?.userId ?? null;
const key = userId && sessionId ? sessionDraftKey(userId, sessionId) : null;
const initial = useMemo<DraftCache>(() => (key ? loadDraft(key) : {}), [key]);
const textRef = useRef(initial.text ?? "");
const handoffRef = useRef<string | null>(initial.handoffAgentId ?? null);
const skillsRef = useRef<string[]>(initial.skills ?? []);
const timer = useRef<number | null>(null);
const cancelPending = useCallback(() => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
timer.current = null;
}
}, []);
const persistNow = useCallback(() => {
cancelPending();
if (!key) return;
const text = textRef.current;
const handoffAgentId = handoffRef.current;
const skills = skillsRef.current;
if (!text && !handoffAgentId && skills.length === 0) {
clearDraft(key);
return;
}
const data: DraftCache = { text };
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
if (skills.length > 0) data.skills = skills;
saveDraft(key, data);
}, [cancelPending, key]);
// Switching session / unmounting: cleanup first flushes the old session's unsaved text
// (persistNow's closure still holds the old key), then setup resets the refs to the new
// session's initial values.
useEffect(() => {
textRef.current = initial.text ?? "";
handoffRef.current = initial.handoffAgentId ?? null;
skillsRef.current = initial.skills ?? [];
return () => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
timer.current = null;
persistNow();
}
};
}, [initial, persistNow]);
const onTextChange = useCallback(
(text: string) => {
textRef.current = text;
cancelPending();
timer.current = window.setTimeout(() => {
timer.current = null;
persistNow();
}, SAVE_DEBOUNCE_MS);
},
[cancelPending, persistNow],
);
const onHandoffTargetChange = useCallback(
(agentId: string | null) => {
handoffRef.current = agentId;
// Discrete action writes immediately (text is carried along via textRef).
persistNow();
},
[persistNow],
);
const onSkillsChange = useCallback(
(names: string[]) => {
skillsRef.current = names;
// Same as @ target: discrete action writes immediately.
persistNow();
},
[persistNow],
);
const discard = useCallback(() => {
cancelPending();
// Also clear selected skills: ChatInput's clear after a successful send doesn't fire a
// callback (same convention as onTextChange); without this, a later text flush would
// resurrect the already-sent selection.
skillsRef.current = [];
if (key) clearDraft(key);
}, [cancelPending, key]);
return { initial, onTextChange, onHandoffTargetChange, onSkillsChange, discard };
}
@@ -0,0 +1,169 @@
/**
* Session stream hook (connect-first + dedupe on use): React adapter layer — the protocol state machine lives in
* lib/omni/stream-controller.ts (pure logic, unit-testable).
*
* 1. On entering a Session, connect SSE first; events are handed to the controller (buffered if
* history isn't ready yet);
* 2. GET messages renders history, then replays the buffer (overlap deduped); on load failure,
* expose error and a retry entry point;
* 3. Disconnects are auto-reconnected by the browser (Last-Event-ID built in; server replays from
* its buffer); resync_required → controller rebuilds the model and keeps consuming the same
* connection;
* 4. task_state in the stream is the authoritative run state (server pushes the current snapshot
* on subscribe); initialStatus only serves as the first-frame placeholder, driving the input
* area state and the Session list badge;
* 5. The pending-approval table is keyed by `origin + toolCallId` (approvalKey); on reconnect the
* server re-sends still-pending requests.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { SessionStatus } from "@prismshadow/penguin-server/api";
import { getMe, getMessages } from "../../api/endpoints";
import { openSessionStream } from "../../api/sse";
import { createStreamController } from "../../lib/omni/stream-controller";
import type { PendingApproval, StreamController } from "../../lib/omni/stream-controller";
import { createStreamModel } from "../../lib/omni/stream-model";
import type { StreamModel } from "../../lib/omni/stream-model";
export type { PendingApproval } from "../../lib/omni/stream-controller";
export interface SessionStreamState {
/** View model (updated in place; version bump triggers re-render). */
model: StreamModel;
version: number;
/** True until history finishes loading. */
loading: boolean;
taskState: SessionStatus;
/** approvalKey(origin, toolCallId) → pending approval. */
pendingApprovals: ReadonlyMap<string, PendingApproval>;
/** Recorded when this client clicks an approval decision (marks it as "manual"). */
markLocalDecision: (toolCallId: string) => void;
/** Removed from the pending table immediately after this client submits a decision (optimistic update; keyed by approvalKey). */
resolveApproval: (key: string) => void;
/** History load failure message (paired with retry to show a retry entry point). */
error: string | null;
/** Re-fetch history (only meaningful after a load failure). */
retry: () => void;
}
const EMPTY_PENDING: ReadonlyMap<string, PendingApproval> = new Map();
export function useSessionStream(
sessionId: string | null,
initialStatus: SessionStatus,
/** Notification of a server auto-generated title (for updating the list in place); held in a ref, doesn't trigger a reconnect. */
onSessionTitle?: (sessionId: string, title: string) => void,
/** New session has been registered (sub-sessions are pushed over the parent session's channel); held in a ref, doesn't trigger a reconnect. */
onSessionCreated?: () => void,
): SessionStreamState {
const [version, setVersion] = useState(0);
const [loading, setLoading] = useState(true);
const [taskState, setTaskState] = useState<SessionStatus>(initialStatus);
const [error, setError] = useState<string | null>(null);
const [pendingTick, setPendingTick] = useState(0);
const onTitleRef = useRef(onSessionTitle);
onTitleRef.current = onSessionTitle;
const onCreatedRef = useRef(onSessionCreated);
onCreatedRef.current = onSessionCreated;
const controllerRef = useRef<StreamController | null>(null);
// Empty model placeholder before the controller is established (first frame).
const placeholderRef = useRef<StreamModel | null>(null);
if (placeholderRef.current === null) placeholderRef.current = createStreamModel();
const rafRef = useRef<number | null>(null);
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single re-render.
const bump = useCallback(() => {
if (rafRef.current !== null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setVersion((v) => v + 1);
});
}, []);
useEffect(() => {
if (!sessionId) {
// Draft state (no session to connect to): reset to idle. If the previous session's running
// state lingers, the draft input area would misjudge "still running" and disable sending —
// so "A is still running" would block sending in the new draft B after switching.
controllerRef.current?.dispose();
controllerRef.current = null;
setTaskState("idle");
setLoading(false);
setError(null);
setPendingTick((t) => t + 1);
setVersion((v) => v + 1);
return;
}
setVersion((v) => v + 1);
setLoading(true);
setError(null);
// First-frame placeholder: the task_state snapshot from the stream (pushed on subscribe)
// subsequently overrides it as the authoritative state.
setTaskState(initialStatus);
setPendingTick((t) => t + 1);
const controller = createStreamController({
loadMessages: async () => (await getMessages(sessionId)).messages,
onTaskState: setTaskState,
onLoading: setLoading,
onError: setError,
onModelChange: bump,
onPendingChange: () => setPendingTick((t) => t + 1),
onSessionTitle: (sid, title) => onTitleRef.current?.(sid, title),
onSessionCreated: () => onCreatedRef.current?.(),
});
controllerRef.current = controller;
// Connect-first: subscribe to the stream before fetching history.
const conn = openSessionStream(sessionId, {
onOmniMessage: controller.handleOmni,
onServerEvent: controller.handleServer,
// EventSource can't read the status code: when the connection is judged a fatal error and
// closes, probe once with GET /api/me; if the session has expired (401), the client's
// global handler clears the user and redirects to the login page.
onError: (closed) => {
if (closed) void getMe().catch(() => undefined);
},
});
void controller.load();
return () => {
controller.dispose();
conn.close();
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
// initialStatus only serves as the first-frame placeholder; it doesn't rebuild the connection
// on parent re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId]);
const markLocalDecision = useCallback((toolCallId: string) => {
controllerRef.current?.markLocalDecision(toolCallId);
}, []);
const resolveApproval = useCallback((key: string) => {
controllerRef.current?.resolveApproval(key);
}, []);
const retry = useCallback(() => {
void controllerRef.current?.retry();
}, []);
// pendingTick participates in the render dependencies, ensuring pending-table changes trigger a re-render.
void pendingTick;
return {
model: controllerRef.current?.model ?? placeholderRef.current,
version,
loading,
taskState,
pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING,
markLocalDecision,
resolveApproval,
error,
retry,
};
}
@@ -0,0 +1,131 @@
/**
* "Reasoning & Tools" group: collapses a run of consecutive thinking +
* tool-call items into one aggregated group.
* Expand policy: the group defaults to expanded while it's the last segment of the message
* stream (the current turn still in progress), and defaults to collapsed once later messages
* push it away from the end (turn finished); a manual toggle by the user is respected afterward.
* A pending approval **forces it open** — otherwise the approval buttons would be unreachable.
*
* Hierarchy: the group header is **a distinct title bar** (solid light-gray background + small
* uppercase status text), and the thinking/tool-call rows inside the group sit on the white
* area below it — the two are deliberately different layers, otherwise "Running"/"Done" would
* blend visually with the step rows and the parent-child relationship would be unreadable.
*
* Status semantics: the group only counts as "Done" once the model stops calling tools. As long
* as this is still the last segment and the Task is running, the model could add another step
* at any moment (there can be a brief gap with no active item between two steps), so it always
* shows "Running"; it flips to "Done" only once a later message (e.g. body text) pushes the
* group away from the end, or the Task has actually finished.
*/
import { useEffect, useRef, useState } from "react";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import { approvalKey } from "../../lib/omni/stream-model";
import type { ChatItem } from "../../lib/omni/stream-model";
import { MessageItem } from "./message-item";
import type { StreamRenderContext } from "./message-stream";
import { summarizeWork } from "./work-summary";
/** Item kinds that belong in the group: thinking and tool calls (subagent cards are nested inside the run_subagent tool card, not listed separately). */
export function isWorkItem(item: ChatItem): boolean {
return item.kind === "thinking" || item.kind === "tool_call";
}
/** Whether an item is still in progress (drives spinner display): streaming, executing, or has a pending approval. */
function itemActive(item: ChatItem, ctx: StreamRenderContext): boolean {
if (item.kind === "thinking") return item.streaming;
if (item.kind === "tool_call") {
if (item.callStreaming || item.outputStreaming) return true;
if (item.callComplete && !item.outputComplete) return true;
return ctx.pendingApprovals.has(approvalKey(ctx.origin, item.toolCallId));
}
return false;
}
/** Whether the group contains a pending approval (used to force it open, ensuring the approval buttons stay reachable). */
function hasPendingApproval(items: ChatItem[], ctx: StreamRenderContext): boolean {
return items.some(
(it) =>
it.kind === "tool_call" && ctx.pendingApprovals.has(approvalKey(ctx.origin, it.toolCallId)),
);
}
export function WorkGroup({
items,
ctx,
isLast,
}: {
items: ChatItem[];
ctx: StreamRenderContext;
/** Whether this group is the last segment of the message stream (current turn still in progress): decides the default expanded/collapsed state. */
isLast: boolean;
}) {
// Last segment + Task running = the model might still call another tool → show Running (even if there's no active item right now).
const active = (isLast && ctx.taskRunning) || items.some((it) => itemActive(it, ctx));
const pending = hasPendingApproval(items, ctx);
const [open, setOpen] = useState(isLast);
const userToggled = useRef(false);
// Before any manual toggle, follow "is last segment": expanded while in progress (last
// segment), collapsed once pushed away from the end by later messages (turn finished).
// Deliberately not driven by per-item active — there can be a brief gap with no active item
// between two steps within a turn, and collapsing on that basis would flicker on every step
// and lose the internal expanded state.
useEffect(() => {
if (!userToggled.current) setOpen(isLast);
}, [isLast]);
// A pending approval must stay actionable: expand the group body regardless of collapsed state (the approval row lives inside it).
const shown = open || pending;
const { steps, durationMs } = summarizeWork(items);
return (
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{/* Group header: a distinct title bar (solid background), on a separate layer from the step rows below it */}
<button
type="button"
aria-expanded={shown}
onClick={() => {
userToggled.current = true;
setOpen((v) => !v);
}}
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
>
<StatusIcon state={active ? "running" : "done"} size={12} />
{/* The title doubles as status: "Running" while in progress, "Done" when finished. */}
<span
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wide ${active ? "text-emerald-600 dark:text-emerald-400" : "text-gray-500 dark:text-gray-400"}`}
>
{active ? S.chat.workRunning : S.chat.workDone}
</span>
{/* A pure-thinking group (no tool calls) doesn't show "0 steps". */}
{steps > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{S.chat.workGroupSteps(steps)}
</span>
)}
{durationMs > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{humanizeDuration(durationMs)}
</span>
)}
{pending && !shown && (
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
{S.chat.approvalWaiting}
</span>
)}
<span className="min-w-0 flex-1" />
<Chevron open={shown} className="text-gray-400" />
</button>
{shown && (
<div className="anim-fade divide-y divide-gray-100 border-t border-gray-200 dark:divide-gray-800/60 dark:border-gray-800">
{items.map((item) => (
<MessageItem key={item.id} item={item} ctx={ctx} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,59 @@
/**
* Summary for the "Reasoning & Tools" group header (pure logic, unit-testable): step count only
* counts **tool calls** (thinking doesn't count as a step).
*
* Duration is computed as the **union of time intervals**: overlapping time from parallel tool
* calls is counted only once, presenting the group's wall-clock working time rather than the sum
* of each item's duration (naive per-item summation would report 15-way parallel work spanning
* 9 minutes as 99 minutes). Intervals follow the same settlement convention as each item's
* durationMs (see settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs];
* a tool's durationMs = argument-generation segment + execution segment (approval wait time is
* excluded), and the two segments are not adjacent on the timeline, so they must be split back
* into two intervals using the same formula: [argStartedAtMs, +generation segment] and
* [approvalAtMs ?? callStartedAtMs, +execution segment] — computing the whole span from the
* execution start point would shift the generation segment into the execution period, producing
* false overlap with parallel tools. Gaps between intervals (waiting for the model's next step)
* are not counted; a segment missing a start point can't be checked for overlap and falls back to
* plain summation.
*/
import type { ChatItem } from "../../lib/omni/stream-model";
export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: number } {
let steps = 0;
let durationMs = 0;
const intervals: [number, number][] = [];
const add = (startMs: number | undefined, spanMs: number) => {
if (spanMs <= 0) return;
if (startMs === undefined) durationMs += spanMs;
else intervals.push([startMs, startMs + spanMs]);
};
for (const it of items) {
if (it.kind === "thinking") {
if (it.durationMs !== undefined) add(it.startedAtMs, it.durationMs);
continue;
}
if (it.kind !== "tool_call") continue;
steps += 1;
if (it.durationMs === undefined) continue;
const genMs =
it.argStartedAtMs !== undefined && it.callStartedAtMs !== undefined
? Math.min(it.durationMs, Math.max(0, it.callStartedAtMs - it.argStartedAtMs))
: 0;
add(it.argStartedAtMs, genMs);
add(it.approvalAtMs ?? it.callStartedAtMs, it.durationMs - genMs);
}
intervals.sort((a, b) => a[0] - b[0]);
let curStart: number | null = null;
let curEnd = 0;
for (const [start, end] of intervals) {
if (curStart === null || start > curEnd) {
if (curStart !== null) durationMs += curEnd - curStart;
curStart = start;
curEnd = end;
} else if (end > curEnd) {
curEnd = end;
}
}
if (curStart !== null) durationMs += curEnd - curStart;
return { steps, durationMs };
}
@@ -0,0 +1,675 @@
/**
* Workspace file browser (the "Browse All" tab of the Files panel): directory navigation
* (breadcrumbs, going up a level navigates via a breadcrumb segment), file list (name/size/
* modified time), preview (Markdown/HTML default to a rendered view + a source toggle, text/
* images shown inline, other types prompt a download), upload (multi-select in the current
* directory, single file <=14MB) and download. Path scoping is validated by the server (including
* auto-creating missing parent directories within the sandbox, an API-level capability).
*
* Single-column list <-> preview drill-down (no side-by-side layout): the panel's width is
* controlled by the outer Files panel and may be much narrower than the viewport, so `lg:`-style
* viewport breakpoints would misjudge things here (a wide viewport doesn't mean this component
* got a wide allotment of space) — so the list and preview are shown mutually exclusively,
* routed by whether the existing `preview` is null, without introducing extra state.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { SessionInfo, WorkspaceFilesResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Button } from "../../components/ui/button";
import { Dropdown } from "../../components/ui/dropdown";
import { SkeletonList } from "../../components/ui/skeleton";
import { CodeBlock } from "./code-block";
const TEXT_EXTS = new Set([
"txt",
"md",
"json",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"py",
"sh",
"bash",
"yaml",
"yml",
"toml",
"css",
"html",
"htm",
"csv",
"log",
"xml",
"ini",
"conf",
"rs",
"go",
"java",
"c",
"h",
"cpp",
"hpp",
"sql",
"rb",
"php",
"gitignore",
"env",
]);
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
const HTML_EXTS = new Set(["html", "htm"]);
/** Read cap for text preview (beyond this, truncated with a download prompt). */
const TEXT_PREVIEW_LIMIT = 256 * 1024;
/** Source highlighting cap: tokenizing the full 256KB preview cap's worth of content in one go would block the main thread, so beyond this it falls back to unhighlighted. */
const HIGHLIGHT_LIMIT = 64 * 1024;
/** Extension -> Shiki language id; extensions not listed are highlighted as "text" (plain text
* with the theme's background color); an id Shiki doesn't recognize is caught by CodeBlock and falls back to an unhighlighted <pre>. */
const SHIKI_LANG_BY_EXT: Record<string, string> = {
html: "html",
htm: "html",
css: "css",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
json: "json",
md: "markdown",
py: "python",
rb: "ruby",
php: "php",
go: "go",
rs: "rust",
java: "java",
c: "c",
h: "c",
cpp: "cpp",
hpp: "cpp",
sh: "shellscript",
bash: "shellscript",
yml: "yaml",
yaml: "yaml",
toml: "toml",
ini: "ini",
conf: "ini",
xml: "xml",
sql: "sql",
log: "log",
};
function langForExt(ext: string): string {
return SHIKI_LANG_BY_EXT[ext] ?? "text";
}
function extOf(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i + 1).toLowerCase() : name.toLowerCase();
}
function joinPath(dir: string, name: string): string {
return dir === "" ? name : `${dir}/${name}`;
}
function dirOf(filePath: string): string {
return filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
}
/** An external reference with a scheme (http(s)/mailto/data, etc.), passed through as-is in the md rendered view. */
const EXTERNAL_REF_RE = /^[a-z][a-z0-9+.-]*:/i;
/** Resolves relative references (image src / link href) within the md rendered view: based on
* the md file's directory, handling ./ and ../ (clamped to the root if it would go past it);
* a leading "/" is treated as the Workspace root. */
function resolveRelative(baseDir: string, ref: string): string {
const out = ref.startsWith("/") || baseDir === "" ? [] : baseDir.split("/");
for (const seg of ref.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") out.pop();
else out.push(seg);
}
return out.join("/");
}
/**
* Storage shim injected into the HTML preview: when the sandbox lacks allow-same-origin, the
* iframe has an opaque origin, and accessing localStorage/sessionStorage throws a SecurityError
* that halts scripts. The shim runs before any page script and falls back to a synchronous
* in-memory implementation (substituted only when the native access throws), preserving sandbox
* isolation while letting the page's scripts run normally.
*/
const STORAGE_SHIM =
"<script>(function(){function mk(){var m={};return{getItem:function(k){return k in m?m[k]:null}," +
"setItem:function(k,v){m[k]=String(v)},removeItem:function(k){delete m[k]},clear:function(){m={}}," +
"key:function(i){return Object.keys(m)[i]||null},get length(){return Object.keys(m).length}}}" +
"['localStorage','sessionStorage'].forEach(function(n){try{window[n].length}catch(e){" +
"Object.defineProperty(window,n,{value:mk(),configurable:true})}})})();</script>";
/** Injects the storage shim at the earliest possible script position in the HTML (right after <head>, otherwise right after <html>, otherwise at the very start). */
function withStorageShim(html: string): string {
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + STORAGE_SHIM);
if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + STORAGE_SHIM);
return STORAGE_SHIM + html;
}
interface Preview {
path: string;
name: string;
kind: "text" | "md" | "image" | "html" | "pdf" | "unsupported";
/** Content for kind=text/md/html (may be truncated). */
content?: string;
truncated?: boolean;
}
export function WorkspaceBrowser({
session,
openRequest,
active,
onPreviewOpen,
}: {
session: SessionInfo;
/** External navigation command (from clicking a file chip in a message): navigates to the
* directory and previews that path. Triggers again whenever the object reference changes,
* even if path is the same as last time (clicking the same file again must still re-locate it). */
openRequest?: { path: string } | null;
/** Whether the panel is visible: when collapsed in the docked state, the component stays
* mounted (width 0), during which the list can go stale as the Agent writes files; a refresh
* is issued right at the moment it transitions from hidden to visible. */
active?: boolean;
/** Callback when entering file preview (used by the mobile Sheet to raise its snap point to full). */
onPreviewOpen?: () => void;
}) {
const [path, setPath] = useState("");
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [reloadTick, setReloadTick] = useState(0);
const [showPath, setShowPath] = useState(false);
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
const [richView, setRichView] = useState<"rendered" | "source">("rendered");
useEffect(() => {
let cancelled = false;
setError(null);
api
.listWorkspaceFiles(session.sessionId, path)
.then((res) => {
if (!cancelled) setData(res);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof ApiError ? e.message : S.files.loadFailed);
});
return () => {
cancelled = true;
};
}, [session.sessionId, path, reloadTick]);
// Returns to the root directory and clears the preview when the Session changes.
useEffect(() => {
setPath("");
setPreview(null);
setData(null);
setNotice(null);
}, [session.sessionId]);
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
const prevActive = useRef(active);
useEffect(() => {
if (active && !prevActive.current) setReloadTick((t) => t + 1);
prevActive.current = active;
}, [active]);
/** The preview callback goes through a ref: keeps previewPath's useCallback dependency stable,
* so even if the parent passes an inline arrow function, the openRequest locate effect doesn't
* replay just because previewPath's reference changed. */
const onPreviewOpenRef = useRef(onPreviewOpen);
onPreviewOpenRef.current = onPreviewOpen;
const previewPath = useCallback(
async (filePath: string) => {
onPreviewOpenRef.current?.();
const name = filePath.includes("/")
? filePath.slice(filePath.lastIndexOf("/") + 1)
: filePath;
const ext = extOf(name);
setRichView("rendered");
if (IMAGE_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "image" });
return;
}
// PDF: the server returns it inline as application/pdf, embedded directly in an iframe and rendered by the browser.
if (ext === "pdf") {
setPreview({ path: filePath, name, kind: "pdf" });
return;
}
const isHtml = HTML_EXTS.has(ext);
const isMd = ext === "md";
if (!isHtml && !TEXT_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "unsupported" });
return;
}
try {
// The server downgrades html/svg served inline to text/plain (a same-origin XSS
// defense); this fetches the raw content back, and the HTML rendered view is placed in a
// sandboxed iframe (without allow-scripts), so scripts don't execute.
const res = await fetch(api.workspaceFileUrl(session.sessionId, filePath), {
credentials: "same-origin",
});
if (!res.ok) throw new Error(String(res.status));
const full = await res.text();
const truncated = full.length > TEXT_PREVIEW_LIMIT;
// Oversized Markdown defaults to the source view (benefiting from the unhighlighted
// highlight=false path): feeding the whole block to remark for parsing is a one-time
// main-thread cost; the user can still manually switch to "rendered view" as an informed choice.
if (isMd && full.length > HIGHLIGHT_LIMIT) setRichView("source");
setPreview({
path: filePath,
name,
kind: isHtml ? "html" : isMd ? "md" : "text",
content: truncated ? full.slice(0, TEXT_PREVIEW_LIMIT) : full,
truncated,
});
} catch {
setPreview({ path: filePath, name, kind: "unsupported" });
}
},
[session.sessionId],
);
// External navigation command (clicking a file chip in a message / a file card): navigates to
// the directory and previews the target path. Also refreshes the list: the target is most
// likely a file the Agent just wrote, so the cached list is very likely stale; and when it's
// the same directory, setPath is a same-value no-op that won't trigger the fetch effect, so it must be explicitly bumped.
useEffect(() => {
if (!openRequest) return;
const target = openRequest.path;
const dir = target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : "";
setPath(dir);
setReloadTick((t) => t + 1);
void previewPath(target);
}, [openRequest, previewPath]);
const openEntry = (name: string) => {
void previewPath(joinPath(path, name));
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setNotice(null);
setError(null);
void (async () => {
try {
for (const file of files) {
const b64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const url = reader.result as string;
resolve(url.slice(url.indexOf(",") + 1)); // Strip the data:...;base64, prefix
};
reader.onerror = () => reject(new Error("read failed"));
reader.readAsDataURL(file);
});
await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64);
}
setNotice(S.files.uploaded);
setReloadTick((t) => t + 1);
} catch (err) {
setError(err instanceof ApiError ? err.message : S.common.unknownError);
} finally {
setUploading(false);
}
})();
e.target.value = "";
};
const crumbs = path === "" ? [] : path.split("/");
if (preview !== null) {
return (
<div className="flex h-full min-h-0 flex-col">
{/* flex-wrap: the panel can be dragged down to a 320px minimum width, narrower than this
row's uncompressible content (back + view toggle + download ~= 370px+); without
wrapping, the panel's overflow-hidden would clip the right-side buttons off. */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<button
type="button"
onClick={() => {
// Refreshes in passing when returning to the list: the Agent may have written new files during the preview.
setPreview(null);
setReloadTick((t) => t + 1);
}}
title={S.files.backToList}
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-sm 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"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M15 18l-6-6 6-6" />
</svg>
{S.files.backToList}
</button>
{/* Shows only the filename (full path goes into the title hover tooltip): the
directory prefix and extension badge are both information the filename already
carries, and on a narrow panel they'd just crowd out the title space. */}
<span
className="min-w-0 flex-1 truncate font-mono text-sm font-semibold"
title={preview.path}
>
{preview.name}
</span>
{/* HTML / Markdown: rendered view / source toggle */}
{(preview.kind === "html" || preview.kind === "md") && (
<div className="flex shrink-0 rounded-md bg-gray-100 p-0.5 dark:bg-gray-800">
{(
[
["rendered", S.files.htmlRendered],
["source", S.files.htmlSource],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setRichView(key)}
className={`rounded px-2 py-0.5 text-xs transition-colors duration-150 ${
richView === key
? "bg-white font-medium text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
: "text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{label}
</button>
))}
</div>
)}
<a
href={api.workspaceFileUrl(session.sessionId, preview.path, true)}
download={preview.name}
className="inline-flex shrink-0 items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm font-medium transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800"
>
{S.files.download}
</a>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
{preview.kind === "image" ? (
<img
src={api.workspaceFileUrl(session.sessionId, preview.path)}
alt={preview.name}
className="max-w-full rounded-md border border-gray-200 dark:border-gray-800"
/>
) : preview.kind === "pdf" ? (
<iframe
src={api.workspaceFileUrl(session.sessionId, preview.path)}
title={preview.name}
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 dark:border-gray-800"
/>
) : preview.kind === "html" && richView === "rendered" ? (
// sandbox allows scripts but **without allow-same-origin**: the iframe has an opaque
// origin, so scripts can run to fully render the page, yet can't read the app's
// same-origin cookies / DOM (an XSS defense). The storage shim is injected to avoid
// a SecurityError when a script accesses localStorage from an opaque origin.
<iframe
srcDoc={withStorageShim(preview.content ?? "")}
title={preview.name}
sandbox="allow-scripts"
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 bg-white dark:border-gray-800"
/>
) : preview.kind === "md" && richView === "rendered" ? (
// Markdown's default rendered view: uses the same md-body layout as message bodies
// (ReactMarkdown outputs pure static HTML with no script execution surface, so no iframe sandbox is needed).
<>
<div className="md-body text-base leading-relaxed text-gray-800 dark:text-gray-100">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Relative images are resolved against the md file's directory into the file API (otherwise resolving against the app's origin would always 404).
img: ({ src, alt }) => (
<img
src={
typeof src === "string" && !EXTERNAL_REF_RE.test(src)
? api.workspaceFileUrl(
session.sessionId,
resolveRelative(dirOf(preview.path), src),
)
: src
}
alt={alt ?? ""}
loading="lazy"
className="max-w-full"
/>
),
// External links open in a new tab; relative links point to a Workspace
// file, clicking switches the preview directly; in-page anchors keep default behavior.
a: ({ href, children }) => {
if (typeof href !== "string" || href.startsWith("#")) {
return <a href={href}>{children}</a>;
}
if (EXTERNAL_REF_RE.test(href)) {
return (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
);
}
const target = resolveRelative(dirOf(preview.path), href);
return (
<a
href={api.workspaceFileUrl(session.sessionId, target)}
onClick={(e) => {
e.preventDefault();
void previewPath(target);
}}
>
{children}
</a>
);
},
}}
>
{preview.content ?? ""}
</ReactMarkdown>
</div>
{preview.truncated && (
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
)}
</>
) : preview.kind === "text" || preview.kind === "html" || preview.kind === "md" ? (
// The source view reuses the message stream's CodeBlock: Shiki dual-theme
// highlighting + language label + copy button, no line wrapping, horizontal scroll
// instead (wrapping code is a disaster for readability, see the old mobile styling).
<>
<CodeBlock
language={langForExt(extOf(preview.name))}
code={preview.content ?? ""}
highlight={(preview.content?.length ?? 0) <= HIGHLIGHT_LIMIT}
/>
{preview.truncated && (
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
)}
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{S.files.previewUnsupported}</p>
)}
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
{/* Toolbar: breadcrumbs + actions */}
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<button
type="button"
onClick={() => {
setPath("");
}}
className="rounded px-1.5 py-0.5 text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.files.root}
</button>
{crumbs.map((seg, i) => (
<span key={i} className="flex items-center gap-1">
<span className="text-gray-300 dark:text-gray-700">/</span>
<button
type="button"
onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}
className="max-w-32 truncate rounded px-1 py-0.5 text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{seg}
</button>
</span>
))}
<span className="flex-1" />
{/* Details: a popup card showing the full absolute Workspace path (break-all wraps in full, never truncated). */}
<Dropdown
open={showPath}
setOpen={setShowPath}
menuClass="right-0 top-full mt-1 w-max max-w-72 origin-top-right"
button={
<Button
size="sm"
variant={showPath ? "primary" : "ghost"}
onClick={() => setShowPath((v) => !v)}
>
{S.files.details}
</Button>
}
>
<div className="px-3.5 py-2.5">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.files.workspacePath}
</p>
<p className="mt-1 break-all font-mono text-xs leading-5">{session.workspace}</p>
</div>
</Dropdown>
<Button size="sm" variant="ghost" onClick={() => setReloadTick((t) => t + 1)}>
{S.files.refresh}
</Button>
{/* Matches the same visual style and font size (sm = text-xs) as the adjacent ghost Buttons (Details/Refresh): no border, light background on hover. */}
<label className="inline-flex cursor-pointer items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 focus-within:ring-2 focus-within:ring-gray-400/30 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100">
{/* sr-only rather than hidden: keyboard users can still Tab-focus it (display:none would remove it from the focus order). */}
<input
type="file"
multiple
className="sr-only"
onChange={onUpload}
disabled={uploading}
/>
{uploading ? S.common.saving : S.files.upload}
</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{error ? (
<p className="px-3 py-3 text-sm text-red-600 dark:text-red-400">{error}</p>
) : data === null ? (
<SkeletonList rows={6} />
) : data.entries.length === 0 ? (
<p className="px-3 py-3 text-sm text-gray-400">{S.files.empty}</p>
) : (
// No "up a level" row: going up a level is done via the toolbar breadcrumbs (root / any segment is clickable).
<ul className="divide-y divide-gray-100 dark:divide-gray-800/60">
{data.entries.map((entry) => (
<li key={entry.name}>
<div className="group flex items-center gap-2 px-3 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<button
type="button"
onClick={() =>
entry.kind === "dir"
? setPath(joinPath(path, entry.name))
: openEntry(entry.name)
}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
title={entry.name}
>
<span className="shrink-0 text-gray-400" aria-hidden>
{entry.kind === "dir" ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
>
<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-2z" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
>
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
</svg>
)}
</span>
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
</button>
<span className="shrink-0 font-mono text-xs text-gray-400 dark:text-gray-500">
{entry.kind === "file" ? formatBytes(entry.sizeBytes) : ""}
</span>
<span className="hidden shrink-0 font-mono text-xs text-gray-400 sm:block dark:text-gray-500">
{entry.mtime ? formatDateTime(entry.mtime) : ""}
</span>
{entry.kind === "file" && (
<a
href={api.workspaceFileUrl(
session.sessionId,
joinPath(path, entry.name),
true,
)}
download={entry.name}
title={S.files.download}
className="shrink-0 rounded p-1 text-gray-300 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 group-hover:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" />
</svg>
</a>
)}
</div>
</li>
))}
</ul>
)}
{notice && (
<p className="px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>
)}
</div>
</div>
);
}