- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
618 lines
26 KiB
TypeScript
618 lines
26 KiB
TypeScript
import { takePrefill } from "../share";
|
|
import { Icon } from "./Icon";
|
|
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { del, get, patch, post } from "../api";
|
|
import { useModels } from "../hooks";
|
|
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
|
import { navigate } from "../router";
|
|
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
|
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
|
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
|
import { useReasoningMode } from "../prefs";
|
|
import { Composer } from "./Composer";
|
|
import { ShareDialog } from "./ShareDialog";
|
|
import { RenameDialog } from "./RenameDialog";
|
|
import { RegenerateButton } from "./Regenerate";
|
|
import { WorktreeChip } from "./WorktreeChip";
|
|
import { QueueList } from "./QueueList";
|
|
import { BudgetChip } from "./Budget";
|
|
import { PermissionCard, QuestionCard } from "./Interaction";
|
|
import { ImageRefs, Markdown, projectFileUrl } from "./Markdown";
|
|
import { UsageOverlay } from "./UsageOverlay";
|
|
import { RewindDialog } from "./RewindDialog";
|
|
import { ShellsChip } from "./ShellsPanel";
|
|
import { TodoList, ToolCard } from "./ToolCard";
|
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
|
|
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
|
|
|
const EMPTY: Message[] = [];
|
|
|
|
interface SessionStats {
|
|
total: Aggregate;
|
|
by_model: (Aggregate & { model: string })[];
|
|
currency: string;
|
|
}
|
|
|
|
function Reasoning({ text, live }: { text: string; live: boolean }) {
|
|
const t = useT();
|
|
const mode = useReasoningMode();
|
|
const [toggled, setToggled] = useState<boolean | null>(null);
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
const open = toggled ?? (mode === "expanded" || (mode === "live" && live));
|
|
// Follow the stream while the model is thinking.
|
|
useLayoutEffect(() => {
|
|
if (live && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
}, [text, live]);
|
|
if (mode === "hidden" || !text.trim()) return null;
|
|
const words = text.trim().split(/\s+/).length;
|
|
return (
|
|
<div className={`reasoning ${live ? "live" : ""}`}>
|
|
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
|
{live ? <Dots /> : <ThinkIcon />}
|
|
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
|
<Icon name={open ? "chevron-down" : "chevron-right"} />
|
|
</button>
|
|
{open && (
|
|
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
|
|
{text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
|
|
return (
|
|
<>
|
|
{parts.map((p, i) => {
|
|
switch (p.type) {
|
|
case "text":
|
|
return (
|
|
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
|
|
<Markdown text={p.text} baseDir={baseDir} />
|
|
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
|
|
</div>
|
|
);
|
|
case "reasoning":
|
|
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
|
case "tool_call":
|
|
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const BG_RE = /^<background-task-result agent="([^"]*)" session="([^"]*)" description="([^"]*)">\n?([\s\S]*?)\n?<\/background-task-result>$/;
|
|
|
|
/** Result of a background subagent, delivered as a message. */
|
|
function BackgroundResult({ text }: { text: string }) {
|
|
const t = useT();
|
|
const m = text.match(BG_RE);
|
|
if (!m) return null;
|
|
const [, agent, session, description, body] = m;
|
|
return (
|
|
<details className="tool" style={{ borderColor: "var(--aqua)" }}>
|
|
<summary>
|
|
<span className="tool-name">{agent}</span>
|
|
<span className="tool-title">
|
|
{t("chat.backgroundDone")}: {description}
|
|
</span>
|
|
<a href={`#/s/${session}`} onClick={(e) => e.stopPropagation()}>
|
|
{t("tool.openSubagent")}
|
|
</a>
|
|
</summary>
|
|
<div className="tool-body">
|
|
<Markdown text={body} />
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
|
|
|
/** Findings of the review loop, sent back to the agent. */
|
|
function RefineFeedback({ text }: { text: string }) {
|
|
const t = useT();
|
|
const m = text.match(REFINE_RE);
|
|
if (!m) return null;
|
|
const [, round, reviewer, body] = m;
|
|
return (
|
|
<details className="tool refine-card" open>
|
|
<summary>
|
|
<Icon name="shield" />
|
|
<span className="tool-title">{t("chat.reviewRound", { round })}</span>
|
|
<span className="chip">{reviewer}</span>
|
|
</summary>
|
|
<div className="tool-body">
|
|
<Markdown text={body} />
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
|
const t = useT();
|
|
const session = useStore((s) => s.sessionById[sessionId]);
|
|
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
|
const draft = useStore((s) => s.drafts[sessionId]);
|
|
const busy = useStore((s) => !!s.busy[sessionId]);
|
|
const errors = useStore((s) => s.errors[sessionId]);
|
|
const allPermissions = useStore((s) => s.permissions);
|
|
const allQuestions = useStore((s) => s.questions);
|
|
const waiting = useStore((s) => s.waiting[sessionId]);
|
|
const compacting = useStore((s) => s.compacting[sessionId]);
|
|
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
|
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
|
const [sharing, setSharing] = useState(false);
|
|
const models = useModels();
|
|
const [loadError, setLoadError] = useState<string>();
|
|
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
|
const [stats, setStats] = useState<SessionStats>();
|
|
const [showStats, setShowStats] = useState(false);
|
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
// Follow the newest message until the user scrolls up; the ↓ button resumes.
|
|
const stick = useRef(true);
|
|
const [following, setFollowing] = useState(true);
|
|
const lastScroll = useRef({ top: 0, height: 0 });
|
|
const follow = (on: boolean) => {
|
|
stick.current = on;
|
|
setFollowing(on);
|
|
};
|
|
const jumpToLatest = () => {
|
|
follow(true);
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
};
|
|
|
|
// Every chat starts following.
|
|
useEffect(() => follow(true), [sessionId]);
|
|
|
|
useEffect(() => {
|
|
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
|
}, [sessionId]);
|
|
|
|
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
|
const questions = allQuestions.filter((q) => q.session_id === sessionId || q.root_session_id === sessionId);
|
|
|
|
// Session cost/tokens; refreshed when messages arrive.
|
|
useEffect(() => {
|
|
get<SessionStats>(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined);
|
|
}, [sessionId, messages.length]);
|
|
|
|
const results = useMemo(() => {
|
|
const m = new Map<string, ToolResult>();
|
|
for (const msg of messages) for (const p of msg.parts) if (p.type === "tool_result") m.set(p.call_id, p);
|
|
return m;
|
|
}, [messages]);
|
|
|
|
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
|
useLayoutEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
|
});
|
|
|
|
if (loadError) return <div className="empty">{loadError}</div>;
|
|
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
|
|
|
const update = async (p: Partial<Session>) => {
|
|
try {
|
|
await patch(`/sessions/${sessionId}`, p);
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
}
|
|
};
|
|
|
|
const forkSession = async () => {
|
|
try {
|
|
const fork = await post<Session>(`/sessions/${sessionId}/fork`, {});
|
|
await loadSessions();
|
|
toast(t("chat.forked"));
|
|
navigate(`/s/${fork.id}`);
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
}
|
|
};
|
|
|
|
const send = async (raw: string, images: ImageData[], effort?: string) => {
|
|
const txt = raw.trim();
|
|
if (txt === "/new") {
|
|
setText("");
|
|
return navigate("/new");
|
|
}
|
|
try {
|
|
await post(`/sessions/${sessionId}/prompt`, { text: txt, images, effort: effort || undefined });
|
|
setText("");
|
|
follow(true);
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
}
|
|
};
|
|
|
|
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
|
const [rewinding, setRewinding] = useState<Message>();
|
|
const rewind = (m: Message) => setRewinding(m);
|
|
const rewindDone = async () => {
|
|
const m = rewinding!;
|
|
setRewinding(undefined);
|
|
await loadSession(sessionId);
|
|
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
|
await del(`/sessions/${sessionId}`);
|
|
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
|
};
|
|
|
|
// Context usage of the last request relative to the model's window.
|
|
const [prov, ...rest] = session.model.split("/");
|
|
const modelInfo = models.find((p) => p.provider === prov)?.models.find((m) => m.id === rest.join("/"));
|
|
const u = session.last_usage;
|
|
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
|
|
const ctxShare = modelInfo?.context && ctxTokens ? ctxTokens / modelInfo.context : undefined;
|
|
const todos = Array.isArray(session.todos) ? session.todos : [];
|
|
const openTodos = todos.filter((x) => x.status !== "completed");
|
|
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
|
|
const lastMsg = messages[messages.length - 1];
|
|
const pendingCall = lastIsAssistant
|
|
? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract<Part, { type: "tool_call" }> | undefined)
|
|
: undefined;
|
|
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
|
|
|
|
return (
|
|
<>
|
|
<div className="topbar">
|
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
|
<Icon name="menu" />
|
|
</button>
|
|
<div className="grow">
|
|
<h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
|
|
{session.parent_id && (
|
|
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
|
|
<Icon name="undo" />
|
|
</a>
|
|
)}
|
|
{session.title || t("session.untitled")}
|
|
{!readOnly && (
|
|
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
|
<Icon name="edit" />
|
|
</button>
|
|
)}
|
|
</h1>
|
|
<div className="path">{session.cwd}</div>
|
|
</div>
|
|
{ctxShare !== undefined && (
|
|
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
|
{t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)}
|
|
</span>
|
|
)}
|
|
{stats && (
|
|
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
|
|
<span className="hide-mobile">
|
|
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
|
|
{fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
|
|
</span>
|
|
{fmtCost(stats.total.cost, stats.currency)}
|
|
</button>
|
|
)}
|
|
<WorktreeChip session={session} busy={busy} />
|
|
<ShellsChip sessionId={sessionId} busy={busy} />
|
|
{isAdmin && <BudgetChip />}
|
|
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
|
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
|
{!readOnly && (
|
|
<>
|
|
{!session.parent_id && (
|
|
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
|
<Icon name="share" />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="small ghost hide-mobile"
|
|
onClick={() => void tabs.openTerminal()}
|
|
title={t("terminal.here")}
|
|
>
|
|
<Icon name="terminal" />
|
|
</button>
|
|
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
|
<Icon name="folder" />
|
|
</button>
|
|
{!session.parent_id && (
|
|
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
|
<Icon name="fork" />
|
|
</button>
|
|
)}
|
|
<details className="menu-pop">
|
|
<summary className="small ghost" title={t("chat.more")} aria-label={t("chat.more")}>
|
|
<Icon name="more" />
|
|
</summary>
|
|
<div className="menu-pop-body">
|
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
|
<Icon name="download" /> {t("chat.exportMd")}
|
|
</a>
|
|
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
|
|
<Icon name="download" /> {t("chat.exportJson")}
|
|
</a>
|
|
{!session.parent_id && !session.worktree && (
|
|
<a
|
|
href="#"
|
|
onClick={async (e) => {
|
|
e.preventDefault();
|
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
|
try {
|
|
await post(`/sessions/${sessionId}/worktree`, { action: "create" });
|
|
toast(t("worktree.created"), "success");
|
|
} catch (err: any) {
|
|
toast(err.message);
|
|
}
|
|
}}
|
|
>
|
|
<Icon name="fork" /> {t("worktree.create")}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</details>
|
|
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
|
<Icon name="compact" />
|
|
</button>
|
|
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{onClosePane && (
|
|
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ChatTabBar state={tabs} />
|
|
<ChatTabPanes state={tabs} />
|
|
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
|
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
|
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
|
<div
|
|
className="chat-scroll"
|
|
ref={scrollRef}
|
|
onScroll={(e) => {
|
|
const el = e.currentTarget;
|
|
const last = lastScroll.current;
|
|
const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
// Scrolling up (not content shrinking) stops following …
|
|
if (el.scrollTop < last.top - 2 && el.scrollHeight >= last.height && stick.current) follow(false);
|
|
// … scrolling back to the very end resumes it.
|
|
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
|
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
|
}}
|
|
onWheel={(e) => {
|
|
if (e.deltaY < 0 && stick.current) follow(false);
|
|
}}
|
|
>
|
|
<div className="chat">
|
|
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
|
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
|
{messages.map((m, idx) => {
|
|
if (m.role === "assistant") {
|
|
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
|
return (
|
|
<div key={m.id} className="msg">
|
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
|
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
|
</div>
|
|
);
|
|
}
|
|
const summary = m.parts.find((p) => p.type === "summary");
|
|
if (summary && summary.type === "summary") {
|
|
return (
|
|
<details key={m.id} className="summary-divider">
|
|
<summary>
|
|
{summary.auto
|
|
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
|
: summary.tokens
|
|
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
|
: t("chat.compacted")}
|
|
</summary>
|
|
<Markdown text={summary.text} />
|
|
</details>
|
|
);
|
|
}
|
|
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
|
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
|
|
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
|
|
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
|
if (texts.length === 0 && imgs.length === 0) {
|
|
return background.length ? (
|
|
<div key={m.id} className="msg">
|
|
{background.map((p, i) => (
|
|
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
|
))}
|
|
</div>
|
|
) : null;
|
|
}
|
|
return (
|
|
<div key={m.id} className="msg">
|
|
{background.map((p, i) => (
|
|
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
|
))}
|
|
<div className="msg-user">
|
|
{!busy && !readOnly && (
|
|
<div className="msg-actions">
|
|
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
|
<Icon name="edit" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
|
{imgs.length > 0 && (
|
|
<div className="user-files">
|
|
{imgs.map((p, i) => (
|
|
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
|
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
|
</FileFigure>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{draft && (
|
|
<div className="msg">
|
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
|
</div>
|
|
)}
|
|
{compacting && (
|
|
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
|
|
<Spinner small />
|
|
<div>
|
|
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
|
|
{compacting.auto && compacting.tokens && compacting.limit && (
|
|
<div className="hint">
|
|
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(busy || waiting) && (
|
|
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
|
)}
|
|
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
|
<div className="todos">
|
|
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
|
<TodoList todos={todos} />
|
|
</div>
|
|
)}
|
|
{permissions.map((p) => (
|
|
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
|
))}
|
|
{questions.map((q) => (
|
|
<QuestionCard key={q.id} req={q} currentId={sessionId} />
|
|
))}
|
|
{errors?.map((e) => (
|
|
<div key={e.id} className="error-box" role="alert">
|
|
<span>{e.message}</span>
|
|
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{session.agent === "plan" && !busy && lastIsAssistant && (
|
|
<div className="row" style={{ justifyContent: "center", margin: "12px 0" }}>
|
|
<button
|
|
className="primary"
|
|
onClick={async () => {
|
|
await update({ agent: "build" });
|
|
await send(t("plan.implementPrompt"), []);
|
|
}}
|
|
>
|
|
{t("plan.implement")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!following && (
|
|
<div className="jump-latest">
|
|
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
|
<Icon name="arrow-down" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
|
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
|
{readOnly ? (
|
|
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
|
) : (
|
|
<Composer
|
|
session={session}
|
|
busy={busy}
|
|
text={text}
|
|
setText={setText}
|
|
onSend={send}
|
|
onAbort={() => post(`/sessions/${sessionId}/abort`)}
|
|
onUpdate={update}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Attached-file lines ("- /path (12.3 KB)") of a user message become file chips. */
|
|
const ATTACHED_RE = /^- (\/\S+) \(([\d.]+ (?:B|KB|MB))\)$/;
|
|
|
|
function UserText({ text }: { text: string }) {
|
|
const lines = text.split("\n");
|
|
const files: { path: string; size: string }[] = [];
|
|
const rest: string[] = [];
|
|
for (const l of lines) {
|
|
const m = l.match(ATTACHED_RE);
|
|
if (m) files.push({ path: m[1], size: m[2] });
|
|
else rest.push(l);
|
|
}
|
|
// Drop the heading line ("Attached files …:") right before the list.
|
|
while (files.length && rest.length && (rest[rest.length - 1].trim() === "" || rest[rest.length - 1].trim().endsWith(":"))) {
|
|
const last = rest.pop()!;
|
|
if (last.trim().endsWith(":")) break;
|
|
}
|
|
return (
|
|
<>
|
|
{rest.join("\n").trimEnd()}
|
|
{files.length > 0 && (
|
|
<div className="user-files">
|
|
{files.map((f) => (
|
|
<FileChip key={f.path} url={projectFileUrl(f.path)} name={f.path} sizeText={f.size} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Token counts like "221k" / "1.2M" (same in every language). */
|
|
function fmtK(n: number) {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`;
|
|
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
|
return String(n);
|
|
}
|
|
|
|
/** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */
|
|
function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
|
const t = useT();
|
|
const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"];
|
|
const cur = phase ? phases.indexOf(phase) : -1;
|
|
return (
|
|
<div className="phase-bar" aria-label="Craft progress">
|
|
{phases.map((p, i) => {
|
|
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
|
|
return (
|
|
<span key={p} style={{ display: "contents" }}>
|
|
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
|
|
<span className={`phase ${state}`}>
|
|
<span className="dot">
|
|
{state === "done" && (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="m5 12 4.5 4.5L19 7" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{t(`phase.${p}` as Key)}
|
|
</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|