f460fff406
- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo status strip, Needs you, Mute / Clear alerts); SagiPanel removed. - Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu). - pty-attach: resize/write after a Windows attach exited, and node-pty's pipe errors, no longer throw out of the WebSocket handler. - restart-helper: the server started by an update logs to data/agenthub-server.log instead of discarding its output.
592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
/**
|
||
* @file PetChat.tsx
|
||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
||
* who are you…) is answered right here; anything else — and every message once
|
||
* a chat runs — goes to the AI: the first message starts a
|
||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||
* reopening the window continues it; **New chat** STOPS that session (kills
|
||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||
* is gone, the next message starts a new one.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||
import { api } from "../../lib/api";
|
||
import type { TranscriptMessage } from "../../lib/types";
|
||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||
import {
|
||
answerRunMenu,
|
||
chooseRunOption,
|
||
interruptRun,
|
||
readRunMenu,
|
||
submitToRun,
|
||
type PaneMenu,
|
||
} from "../../lib/panePaste";
|
||
import { isSubmitEnter } from "../../lib/keys";
|
||
import { confirmDialog } from "../../lib/confirm";
|
||
import { toast } from "../../lib/toast";
|
||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||
import { TerminalView } from "../run/TerminalView";
|
||
import { PaneMenuCard } from "../run/PaneChat";
|
||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||
|
||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||
const CHAT_KEY = "agenthub.pet.chat";
|
||
const POLL_MS = 1500;
|
||
const MENU_POLL_MS = 700;
|
||
// A follow-up waits this long for the off-screen pane to attach.
|
||
const SEND_TRIES = 20;
|
||
const SEND_RETRY_MS = 250;
|
||
|
||
/** The session the chat is talking to. */
|
||
export interface PetChatRef {
|
||
laneId: number;
|
||
runId: string;
|
||
tool: string;
|
||
}
|
||
|
||
export function readPetChat(): PetChatRef | null {
|
||
try {
|
||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||
if (
|
||
v &&
|
||
typeof v.laneId === "number" &&
|
||
typeof v.runId === "string" &&
|
||
typeof v.tool === "string"
|
||
)
|
||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||
} catch {
|
||
/* unreadable: no chat */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function writePetChat(ref: PetChatRef | null) {
|
||
try {
|
||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||
else localStorage.removeItem(CHAT_KEY);
|
||
} catch {
|
||
/* best-effort */
|
||
}
|
||
}
|
||
|
||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||
export function petChatTool(): string | null {
|
||
const tool = readDefaultTool() ?? "claude";
|
||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||
}
|
||
|
||
type Line =
|
||
| { key: string; role: "user" | "assistant"; text: string }
|
||
| { key: string; role: "tools"; names: string[] };
|
||
|
||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||
* `<system-reminder>`…) are not something the user said. */
|
||
const humanText = (m: TranscriptMessage) =>
|
||
m.content
|
||
.filter((c) => c.type === "text" && c.text)
|
||
.map((c) => c.text!.trim())
|
||
.filter((t) => t && !t.startsWith("<"))
|
||
.join("\n\n");
|
||
|
||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||
* each run of tool calls between them folded into one chip. */
|
||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||
const out: Line[] = [];
|
||
messages.forEach((m, i) => {
|
||
const sender = m.sender ?? m.type;
|
||
if (sender === "user") {
|
||
const text = humanText(m);
|
||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||
return;
|
||
}
|
||
if (m.type !== "assistant") return;
|
||
m.content.forEach((c, j) => {
|
||
if (c.type === "text" && c.text?.trim()) {
|
||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||
} else if (c.type === "tool_use" && c.name) {
|
||
const last = out[out.length - 1];
|
||
if (last?.role === "tools") last.names.push(c.name);
|
||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||
}
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const m = messages[i]!;
|
||
const sender = m.sender ?? m.type;
|
||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||
if (sender === "user" && humanText(m)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** "Read ×2 · Bash" */
|
||
function toolSummary(names: string[]): string {
|
||
const counts = new Map<string, number>();
|
||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||
}
|
||
|
||
/** What the container made of a message: an answer the pet gives itself, or
|
||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
||
|
||
interface PetChatProps {
|
||
/** The pet's shown name, for the header and greeting. */
|
||
name: string;
|
||
/** Shown in place of the plain name (the rename button). */
|
||
title?: ReactNode;
|
||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||
headerActions?: ReactNode;
|
||
/** Under the header: what the pet knows (status, sessions that need you). */
|
||
info?: ReactNode;
|
||
/** Asked first while no chat runs. */
|
||
onLocal?: (text: string) => PetLocalReply;
|
||
onClose: () => void;
|
||
/** Opens the chat's pane in its workspace. */
|
||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||
/** The thinking mood while a reply is being written. */
|
||
onBusy?: (busy: boolean) => void;
|
||
}
|
||
|
||
export function PetChat({
|
||
name,
|
||
title,
|
||
headerActions,
|
||
info,
|
||
onLocal,
|
||
onClose,
|
||
onOpenWorkspace,
|
||
onBusy,
|
||
}: PetChatProps) {
|
||
const { t } = useTranslation("settings");
|
||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||
// Sent but not in the transcript yet.
|
||
const [pending, setPending] = useState<string | null>(null);
|
||
const [starting, setStarting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [draft, setDraft] = useState("");
|
||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||
// The pet's own answers, given before any chat ran.
|
||
const [local, setLocal] = useState<Line[]>([]);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
|
||
const remember = (ref: PetChatRef | null) => {
|
||
writePetChat(ref);
|
||
setChat(ref);
|
||
setSessionId(null);
|
||
setMessages([]);
|
||
setMenu(null);
|
||
};
|
||
|
||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||
useEffect(() => {
|
||
if (!chat) return;
|
||
let live = true;
|
||
const check = () =>
|
||
api.run
|
||
.get(chat.runId)
|
||
.then((h) => {
|
||
if (!live) return;
|
||
if (h.status !== "running") remember(null);
|
||
else if (h.sessionId) setSessionId(h.sessionId);
|
||
})
|
||
.catch(() => live && remember(null));
|
||
void check();
|
||
// Codex learns its session only after a while; Claude's is known at start.
|
||
const timer = window.setInterval(() => {
|
||
if (!sessionId) void check();
|
||
}, POLL_MS);
|
||
return () => {
|
||
live = false;
|
||
window.clearInterval(timer);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [chat?.runId, sessionId]);
|
||
|
||
// The conversation, polled while the window is open.
|
||
useEffect(() => {
|
||
if (!sessionId) return;
|
||
let live = true;
|
||
const load = () =>
|
||
api.sessions
|
||
.transcript(sessionId, { limit: 60 })
|
||
.then((r) => {
|
||
if (!live) return;
|
||
setMessages((prev) =>
|
||
prev.length === r.messages.length &&
|
||
JSON.stringify(prev[prev.length - 1]) ===
|
||
JSON.stringify(r.messages[r.messages.length - 1])
|
||
? prev
|
||
: r.messages
|
||
);
|
||
})
|
||
.catch(() => undefined); // not started yet: the next poll asks again
|
||
void load();
|
||
const timer = window.setInterval(load, POLL_MS);
|
||
return () => {
|
||
live = false;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [sessionId]);
|
||
|
||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||
useEffect(() => {
|
||
if (!chat) return;
|
||
const read = () =>
|
||
setMenu((prev) => {
|
||
const next = readRunMenu(chat.runId);
|
||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||
});
|
||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||
return () => window.clearInterval(timer);
|
||
}, [chat]);
|
||
|
||
const lines = [...local, ...chatLines(messages)];
|
||
// The pending line shows until the transcript has it.
|
||
useEffect(() => {
|
||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||
setPending(null);
|
||
}, [lines, pending]);
|
||
const busy = starting || !!pending || isBusy(messages);
|
||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||
|
||
// Keep the newest in sight.
|
||
useEffect(() => {
|
||
const el = listRef.current;
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}, [lines.length, pending, busy, menu]);
|
||
|
||
const send = useCallback(
|
||
async (text: string) => {
|
||
const prompt = text.trim();
|
||
if (!prompt || starting) return;
|
||
setError(null);
|
||
if (!chat && onLocal) {
|
||
const reply = onLocal(prompt);
|
||
if (reply && "handled" in reply) return;
|
||
if (reply) {
|
||
const n = Date.now();
|
||
setLocal((prev) => [
|
||
...prev,
|
||
{ key: `lu${n}`, role: "user", text: prompt },
|
||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
setPending(prompt);
|
||
if (chat) {
|
||
// Typed into the attached pane; it may still be attaching.
|
||
for (let i = 0; i < SEND_TRIES; i++) {
|
||
if (submitToRun(chat.runId, prompt)) return;
|
||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||
}
|
||
setPending(null);
|
||
setDraft(prompt);
|
||
setError(t("sagi.chat.notSent"));
|
||
return;
|
||
}
|
||
const tool = petChatTool() ?? "claude";
|
||
setStarting(true);
|
||
try {
|
||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||
const r = await startInFreeTabDetailed(lane.id, {
|
||
prompt,
|
||
...(tool !== lane.tool ? { tool } : {}),
|
||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||
});
|
||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||
} catch (err) {
|
||
setPending(null);
|
||
setDraft(prompt);
|
||
setError(
|
||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||
);
|
||
} finally {
|
||
setStarting(false);
|
||
}
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[chat, starting, t, onLocal]
|
||
);
|
||
|
||
// New chat: the old one's session is stopped, not left running unseen —
|
||
// asking first only while it is still writing a reply.
|
||
const newChat = async () => {
|
||
if (chat) {
|
||
if (
|
||
busy &&
|
||
!(await confirmDialog({
|
||
title: t("sagi.chat.endTitle"),
|
||
message: t("sagi.chat.endBody"),
|
||
confirmLabel: t("sagi.chat.endConfirm"),
|
||
destructive: true,
|
||
}))
|
||
)
|
||
return;
|
||
try {
|
||
await api.run.kill(chat.runId, { archive: true });
|
||
} catch (err) {
|
||
toast.error(err);
|
||
return;
|
||
}
|
||
}
|
||
remember(null);
|
||
setLocal([]);
|
||
setPending(null);
|
||
setError(null);
|
||
};
|
||
|
||
const submit = (e?: FormEvent) => {
|
||
e?.preventDefault();
|
||
if (!draft.trim()) return;
|
||
const text = draft;
|
||
setDraft("");
|
||
void send(text);
|
||
};
|
||
|
||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||
const empty = lines.length === 0 && !pending;
|
||
|
||
return (
|
||
<div
|
||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||
role="dialog"
|
||
aria-label={t("sagi.chat.label", { name })}
|
||
>
|
||
{/* header */}
|
||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||
🐾
|
||
</span>
|
||
<div className="min-w-0 flex-1 px-1">
|
||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||
<span
|
||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||
busy
|
||
? "animate-pulse bg-status-warning"
|
||
: chat
|
||
? "bg-status-success"
|
||
: "bg-fg-muted"
|
||
}`}
|
||
aria-hidden
|
||
/>
|
||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||
</div>
|
||
</div>
|
||
{headerActions}
|
||
{chat && (
|
||
<IconButton
|
||
label={t("sagi.chat.openWorkspace")}
|
||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||
>
|
||
<ExternalLink size={15} />
|
||
</IconButton>
|
||
)}
|
||
<IconButton
|
||
label={t("sagi.chat.new")}
|
||
onClick={() => void newChat()}
|
||
disabled={(!chat && !local.length) || starting}
|
||
>
|
||
<SquarePen size={15} />
|
||
</IconButton>
|
||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||
<X size={16} />
|
||
</IconButton>
|
||
</div>
|
||
|
||
{info}
|
||
|
||
{/* conversation */}
|
||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||
{empty && (
|
||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||
<span className="text-3xl" aria-hidden>
|
||
🐾
|
||
</span>
|
||
<p className="text-sm font-medium text-fg-primary">
|
||
{t("sagi.chat.emptyTitle", { name })}
|
||
</p>
|
||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||
</div>
|
||
)}
|
||
{lines.map((l) =>
|
||
l.role === "tools" ? (
|
||
<div key={l.key} className="flex justify-start">
|
||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||
<span className="truncate">{toolSummary(l.names)}</span>
|
||
</span>
|
||
</div>
|
||
) : l.role === "user" ? (
|
||
<div key={l.key} className="flex justify-end">
|
||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||
{l.text}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div key={l.key} className="flex justify-start">
|
||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||
<MarkdownContent text={l.text} dense prose />
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
{pending && (
|
||
<div className="flex justify-end">
|
||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||
{pending}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{busy && !menu && (
|
||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||
<span className="pet-chat-dot" />
|
||
<span className="pet-chat-dot" />
|
||
<span className="pet-chat-dot" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{starting && (
|
||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||
</p>
|
||
)}
|
||
{chat && !sessionId && !starting && (
|
||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{/* prompt, or the question the CLI asks */}
|
||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||
{menu && chat ? (
|
||
<div className="max-h-56 overflow-y-auto">
|
||
<PaneMenuCard
|
||
menu={menu}
|
||
onChoose={(i) => {
|
||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||
else setError(t("sagi.chat.notSent"));
|
||
}}
|
||
onAnswer={(text) => {
|
||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||
else setError(t("sagi.chat.notSent"));
|
||
}}
|
||
onCancel={() => {
|
||
if (interruptRun(chat.runId)) setMenu(null);
|
||
else setError(t("sagi.chat.notSent"));
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||
<textarea
|
||
rows={1}
|
||
autoFocus
|
||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||
placeholder={t("sagi.chat.placeholder", { name })}
|
||
aria-label={t("sagi.chat.placeholder", { name })}
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||
e.preventDefault();
|
||
submit();
|
||
} else if (e.key === "Escape") {
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
onClose();
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={!draft.trim() || starting}
|
||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||
aria-label={t("sagi.panel.send")}
|
||
>
|
||
<ArrowUp size={16} />
|
||
</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
|
||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||
{chat && (
|
||
<div className="pet-chat-term" aria-hidden>
|
||
<TerminalView
|
||
runId={chat.runId}
|
||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||
autoFocus={false}
|
||
// The pane ended: the conversation stays on screen, the next
|
||
// message starts a new session.
|
||
onExit={() => {
|
||
writePetChat(null);
|
||
setChat(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IconButton({
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
title={label}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|