- opencode Go reports plan requests as cost 0 with the usage in allowance;
ModelConfig::cost values them at the model's list prices (charged amounts
and free requests stay as reported). Rows booked at 0 are valued once on
start (idempotent).
- opencode's price list now has per-duration cache prices for some models
({"30m": ..., "12h": ...}): the shortest duration is used, also in tiers.
Saving the provider's models failed with "untagged enum ModelEntry".
- Each answer shows tokens sent/received and the cost of the turn's requests
(usage.cost filled when messages are saved and loaded).
- The chat title keeps a minimum width in a crowded top bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1047 lines
43 KiB
TypeScript
1047 lines
43 KiB
TypeScript
import { takePrefill } from "../share";
|
||
import { Icon } from "./Icon";
|
||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||
import { del, get, patch, post } from "../api";
|
||
import { useFetch, useModels } from "../hooks";
|
||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||
import { navigate } from "../router";
|
||
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||
import { Composer } from "./Composer";
|
||
import { ShareDialog } from "./ShareDialog";
|
||
import { RenameDialog } from "./RenameDialog";
|
||
import { InstructionsDialog } from "./InstructionsDialog";
|
||
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";
|
||
import { MediaGallery } from "./MediaGallery";
|
||
|
||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||
|
||
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="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
|
||
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
|
||
/** Messages from the harness shown as cards instead of user bubbles. */
|
||
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
|
||
|
||
/** A todo passed its review, or the agent was sent back to its open todos. */
|
||
function HarnessNote({ text }: { text: string }) {
|
||
const t = useT();
|
||
const m = text.match(NOTE_RE);
|
||
if (!m) return null;
|
||
const [, kind, reviewer, body] = m;
|
||
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
|
||
const approved = kind === "refine-approved";
|
||
return (
|
||
<div className={`harness-note ${approved ? "ok" : ""}`}>
|
||
<Icon name={approved ? "check" : "refresh"} />
|
||
<span className="grow">
|
||
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
|
||
</span>
|
||
{reviewer && <span className="chip">{reviewer}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HarnessMessage({ text }: { text: string }) {
|
||
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
|
||
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
|
||
return <BackgroundResult text={text} />;
|
||
}
|
||
|
||
/** 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>
|
||
);
|
||
}
|
||
|
||
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||
const t = useT();
|
||
const [q, setQ] = useState("");
|
||
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||
useEffect(() => {
|
||
const query = q.trim();
|
||
if (query.length < 2) return setHits(undefined);
|
||
let alive = true;
|
||
const h = setTimeout(() => {
|
||
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||
.then((r) => alive && setHits(r ?? []))
|
||
.catch(() => alive && setHits([]));
|
||
}, 250);
|
||
return () => {
|
||
alive = false;
|
||
clearTimeout(h);
|
||
};
|
||
}, [q, sessionId]);
|
||
return (
|
||
<div className="chat-search" role="search">
|
||
<div className="row">
|
||
<Icon name="search" />
|
||
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||
<Icon name="close" />
|
||
</button>
|
||
</div>
|
||
{hits && (
|
||
<div className="chat-search-hits">
|
||
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||
{hits.map((h) => (
|
||
<button key={h.id} onClick={() => onPick(h.id)}>
|
||
<span className="who">
|
||
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||
</span>
|
||
<span className="snippet">{h.snippet}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||
|
||
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||
|
||
/** Group the messages into user messages and the agent's work between them. */
|
||
function blocksOf(messages: Message[]): Block[] {
|
||
const out: Block[] = [];
|
||
for (const m of messages) {
|
||
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||
const last = out[out.length - 1];
|
||
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||
else out.push({ kind, messages: [m] });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||
* chat view do not touch it – only new messages, tool results or the busy state.
|
||
*/
|
||
const MessageList = memo(function MessageList({
|
||
messages,
|
||
results,
|
||
busy,
|
||
canEdit,
|
||
canRegenerate,
|
||
sessionId,
|
||
model,
|
||
baseDir,
|
||
onRewind,
|
||
highlight,
|
||
currency,
|
||
}: {
|
||
messages: Message[];
|
||
currency: string;
|
||
results: Map<string, ToolResult>;
|
||
busy: boolean;
|
||
canEdit: boolean;
|
||
canRegenerate: boolean;
|
||
sessionId: string;
|
||
model: string;
|
||
baseDir: string;
|
||
onRewind: (m: Message) => void;
|
||
highlight?: string;
|
||
}) {
|
||
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||
return (
|
||
<>
|
||
{blocks.map((b, i) => {
|
||
// Stable while older messages are loaded above (they may join the first block):
|
||
// the last message; the block still growing at the end keeps its first one.
|
||
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||
const items = b.messages.map((m) => (
|
||
<AssistantOrNote
|
||
key={m.id}
|
||
m={m}
|
||
results={results}
|
||
busy={busy}
|
||
baseDir={baseDir}
|
||
highlight={highlight}
|
||
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||
/>
|
||
));
|
||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||
if (i === lastWork || steps.length < 2)
|
||
return (
|
||
<div key={key}>
|
||
{items}
|
||
{usage}
|
||
</div>
|
||
);
|
||
return (
|
||
<div key={key}>
|
||
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||
{items}
|
||
</WorkBlock>
|
||
{usage}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
});
|
||
|
||
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||
const t = useT();
|
||
let inp = 0;
|
||
let out = 0;
|
||
let cost = 0;
|
||
let requests = 0;
|
||
for (const m of messages) {
|
||
const u = m.role === "assistant" ? m.usage : undefined;
|
||
if (!u) continue;
|
||
requests++;
|
||
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||
out += u.output_tokens;
|
||
cost += u.cost ?? 0;
|
||
}
|
||
if (!requests) return null;
|
||
return (
|
||
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||
{fmtCompact(inp)}
|
||
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||
const t = useT();
|
||
const [open, setOpen] = useState(false);
|
||
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||
// The answer: text of the last assistant message of the turn.
|
||
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||
if (open || forced) {
|
||
return (
|
||
<div className="work-block open">
|
||
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||
</button>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="msg work-block" data-mid={answer?.id}>
|
||
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||
</button>
|
||
{answerText && (
|
||
<>
|
||
<Markdown text={answerText} baseDir={baseDir} />
|
||
<ImageRefs text={answerText} baseDir={baseDir} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||
const AssistantOrNote = memo(function AssistantOrNote({
|
||
m,
|
||
results,
|
||
busy,
|
||
baseDir,
|
||
highlight,
|
||
regenerate,
|
||
}: {
|
||
m: Message;
|
||
results: Map<string, ToolResult>;
|
||
busy: boolean;
|
||
baseDir: string;
|
||
highlight?: string;
|
||
regenerate?: { sessionId: string; model: string };
|
||
}) {
|
||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||
if (m.role === "assistant") {
|
||
return (
|
||
<div className={cls} data-mid={m.id}>
|
||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||
</div>
|
||
);
|
||
}
|
||
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||
if (!notes.length) return null;
|
||
return (
|
||
<div className={cls} data-mid={m.id}>
|
||
{notes.map((p, i) => (
|
||
<HarnessMessage key={i} text={p.text} />
|
||
))}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/** A user message or a summary of the history before it. */
|
||
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||
const t = useT();
|
||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||
const summary = m.parts.find((p) => p.type === "summary");
|
||
if (summary && summary.type === "summary") {
|
||
return (
|
||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||
<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>
|
||
{summaryOpen && <Markdown text={summary.text} />}
|
||
</details>
|
||
);
|
||
}
|
||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||
return (
|
||
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||
{background.map((p, i) => (
|
||
<HarnessMessage key={i} text={p.text} />
|
||
))}
|
||
<div className="msg-user">
|
||
{canEdit && (
|
||
<div className="msg-actions">
|
||
<button className="small ghost" onClick={() => onRewind(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={imageSize(p.image)}>
|
||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||
</FileFigure>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||
*/
|
||
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||
const t = useT();
|
||
const [loading, setLoading] = useState(false);
|
||
const sentinel = useRef<HTMLDivElement>(null);
|
||
const load = useCallback(async () => {
|
||
const el = scrollRef.current;
|
||
if (loading || !el) return;
|
||
setLoading(true);
|
||
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||
try {
|
||
// Scrolling stops at a summary; its button loads the history before it.
|
||
await loadOlder(sessionId, !afterSummary);
|
||
requestAnimationFrame(() => {
|
||
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [loading, sessionId, scrollRef, afterSummary]);
|
||
useEffect(() => {
|
||
const node = sentinel.current;
|
||
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||
io.observe(node);
|
||
return () => io.disconnect();
|
||
}, [afterSummary, load, scrollRef]);
|
||
return (
|
||
<div ref={sentinel} className="older-loader">
|
||
{loading ? (
|
||
<Spinner small />
|
||
) : (
|
||
<button className="small ghost" onClick={() => void load()}>
|
||
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 win = useStore((s) => s.windows[sessionId]);
|
||
// Message to show (search hit, media): scrolled into view and marked.
|
||
const [highlight, setHighlight] = useState<string>();
|
||
const [searching, setSearching] = useState(false);
|
||
const [gallery, setGallery] = useState(false);
|
||
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);
|
||
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
||
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
||
const [editingInstructions, setEditingInstructions] = 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 (only when the
|
||
// content changes – reading the height forces a layout).
|
||
const permCount = permissions.length + questions.length;
|
||
useLayoutEffect(() => {
|
||
const el = scrollRef.current;
|
||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
|
||
|
||
// Stable callbacks for the (memoised) message list.
|
||
const rewindRef = useRef<(m: Message) => void>(() => undefined);
|
||
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
|
||
const showLatest = useCallback(async () => {
|
||
setHighlight(undefined);
|
||
await loadSession(sessionId);
|
||
follow(true);
|
||
}, [sessionId]);
|
||
/** Load the part of the chat around a message and scroll to it. */
|
||
const jumpTo = useCallback(
|
||
async (messageId: string) => {
|
||
follow(false);
|
||
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
|
||
setHighlight(messageId);
|
||
},
|
||
[sessionId],
|
||
);
|
||
useEffect(() => {
|
||
if (!highlight) return;
|
||
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
|
||
return () => clearTimeout(h);
|
||
}, [highlight, messages]);
|
||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||
const hasSession = !!session;
|
||
useEffect(() => {
|
||
const el = scrollRef.current;
|
||
if (!el || typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(() => {
|
||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||
});
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [hasSession, sessionId]);
|
||
|
||
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 [rewinding, setRewinding] = useState<Message>();
|
||
const rewind = (m: Message) => setRewinding(m);
|
||
rewindRef.current = rewind;
|
||
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>}
|
||
{!!session.instructions && (
|
||
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
|
||
<Icon name="edit" /> {t("instructions.chip")}
|
||
</button>
|
||
)}
|
||
{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 ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||
<Icon name="search" />
|
||
</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="#"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||
setEditingInstructions(true);
|
||
}}
|
||
>
|
||
<Icon name="edit" /> {t("instructions.title")}
|
||
</a>
|
||
<a
|
||
href="#"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||
setGallery(true);
|
||
}}
|
||
>
|
||
<Icon name="image" /> {t("media.title")}
|
||
</a>
|
||
{telegramPaired && !session.parent_id && (
|
||
<a
|
||
href="#"
|
||
role="menuitemcheckbox"
|
||
aria-checked={!session.telegram_quiet}
|
||
title={t("chat.telegramHint")}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
void update({ telegram_quiet: !session.telegram_quiet });
|
||
}}
|
||
>
|
||
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
|
||
</a>
|
||
)}
|
||
<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>
|
||
{gallery && (
|
||
<MediaGallery
|
||
sessionId={sessionId}
|
||
onClose={() => setGallery(false)}
|
||
onJump={(id) => {
|
||
setGallery(false);
|
||
void jumpTo(id);
|
||
}}
|
||
/>
|
||
)}
|
||
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||
<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>}
|
||
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||
<MessageList
|
||
messages={messages}
|
||
results={results}
|
||
busy={busy}
|
||
canEdit={!busy && !readOnly}
|
||
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||
sessionId={sessionId}
|
||
model={session.model}
|
||
baseDir={session.cwd}
|
||
onRewind={onRewind}
|
||
highlight={highlight}
|
||
currency={stats?.currency ?? "USD"}
|
||
/>
|
||
{win?.newer && (
|
||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||
</button>
|
||
)}
|
||
{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) && (
|
||
<TodoPanel todos={todos} />
|
||
)}
|
||
{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>
|
||
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||
{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;
|
||
}
|
||
const body = rest.join("\n").trim();
|
||
return (
|
||
<>
|
||
{body && <Markdown text={body} breaks />}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/** Tasks of the model above the composer; folds to one line (current task, progress). */
|
||
function TodoPanel({ todos }: { todos: Todo[] }) {
|
||
const t = useT();
|
||
const [collapsed, setCollapsed] = useTodosCollapsed();
|
||
const done = todos.filter((x) => x.status === "completed").length;
|
||
const current = todos.find((x) => x.status === "in_progress");
|
||
return (
|
||
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
|
||
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
|
||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
|
||
<span>{t("tool.todos")}</span>
|
||
<span className="todos-count">
|
||
{done}/{todos.length}
|
||
</span>
|
||
{collapsed && current && <span className="todos-current">{current.content}</span>}
|
||
</button>
|
||
{!collapsed && <TodoList todos={todos} />}
|
||
</div>
|
||
);
|
||
}
|