- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
import { Icon } from "./Icon";
|
||
import { useEffect, useState } from "react";
|
||
import { fmtCompact, useT } from "../i18n";
|
||
import { useStore, type Draft } from "../store";
|
||
import type { Part } from "../types";
|
||
|
||
|
||
/** Animated three-dot pulse. */
|
||
export function Dots() {
|
||
return (
|
||
<span className="dots" aria-hidden="true">
|
||
<i />
|
||
<i />
|
||
<i />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function Spinner({ small }: { small?: boolean }) {
|
||
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
||
}
|
||
|
||
/** Animated spark (SVG, looks the same on every system): rays light up in turn while it turns. */
|
||
export function Star() {
|
||
const rays = Array.from({ length: 8 }, (_, i) => i);
|
||
return (
|
||
<svg className="star" viewBox="-12 -12 24 24" aria-hidden="true">
|
||
<g className="star-spin">
|
||
{rays.map((i) => (
|
||
<line
|
||
key={i}
|
||
x1="0"
|
||
y1="-4"
|
||
x2="0"
|
||
y2={i % 2 ? "-8.5" : "-10.5"}
|
||
transform={`rotate(${i * 45})`}
|
||
style={{ animationDelay: `${(i * 0.9) / 8 - 0.9}s` }}
|
||
/>
|
||
))}
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** Thought bubble icon for model reasoning (SVG instead of an emoji). */
|
||
export function ThinkIcon() {
|
||
return (
|
||
<svg className="think-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M7 15.5a4.5 4.5 0 0 1-.6-8.96A5.5 5.5 0 0 1 16.6 5.2 4.25 4.25 0 0 1 18 13.5a4 4 0 0 1-4 2Z" />
|
||
<circle cx="7.5" cy="19" r="1.6" />
|
||
<circle cx="4" cy="21.6" r="1" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function useNow(active: boolean) {
|
||
const [now, setNow] = useState(Date.now());
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const h = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(h);
|
||
}, [active]);
|
||
return now;
|
||
}
|
||
|
||
function fmtElapsed(ms: number) {
|
||
const s = Math.max(0, Math.floor(ms / 1000));
|
||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
|
||
}
|
||
|
||
/** Generated tokens so far, estimated from the streamed characters. */
|
||
function generatedTokens(draft?: Draft) {
|
||
if (!draft) return 0;
|
||
let chars = 0;
|
||
for (const p of draft.parts) {
|
||
if (p.type === "text" || p.type === "reasoning") chars += p.text.length;
|
||
else if (p.type === "tool_call") chars += JSON.stringify(p.input ?? {}).length;
|
||
}
|
||
return Math.round(chars / 4);
|
||
}
|
||
|
||
/** What the agent is doing right now – playful text, elapsed time and tokens. */
|
||
export function ActivityLine({
|
||
sessionId,
|
||
draft,
|
||
pendingTool,
|
||
waiting,
|
||
permissionPending,
|
||
}: {
|
||
sessionId: string;
|
||
draft?: Draft;
|
||
/** A tool call of the last (finished) model step that has no result yet. */
|
||
pendingTool?: { name: string; input: any; since: number };
|
||
waiting?: string;
|
||
permissionPending: boolean;
|
||
}) {
|
||
const t = useT();
|
||
const step = useStore((s) => s.steps[sessionId]);
|
||
const now = useNow((!!step || !!pendingTool) && !permissionPending);
|
||
|
||
if (permissionPending) {
|
||
return (
|
||
<div className="activity paused" role="status">
|
||
<span className="pause-icon" aria-hidden="true" />
|
||
<span>{t("activity.waitingForYou")}</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (waiting) {
|
||
const [kind, ...rest] = waiting.split(":");
|
||
const what = rest.join(":");
|
||
return (
|
||
<div className="activity" role="status">
|
||
<Dots />
|
||
<span>
|
||
{kind === "review"
|
||
? t("activity.reviewing", { round: what })
|
||
: kind === "model"
|
||
? t("chat.waitingModel", { model: what })
|
||
: t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// A tool is running between two model steps.
|
||
if (!draft && pendingTool) {
|
||
const label = pendingTool.name === "task" ? t("activity.subagent", { name: pendingTool.input?.description ?? "" }) : t("activity.tool", { tool: pendingTool.name });
|
||
return (
|
||
<div className="activity" role="status" aria-live="polite">
|
||
<Star />
|
||
<span className="activity-text">{label}</span>
|
||
<span className="activity-meta">({fmtElapsed(now - pendingTool.since)})</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const live = draft && step && draft.id === step.messageId ? draft : undefined;
|
||
const generated = generatedTokens(live);
|
||
const phrases = t("activity.phrases").split("|");
|
||
// A stable starting phrase per step, changing every 4 seconds.
|
||
const seed = step ? [...step.messageId].reduce((a, c) => a + c.charCodeAt(0), 0) : 0;
|
||
const elapsed = step ? now - step.startedAt : 0;
|
||
const label = `${phrases[(seed + Math.floor(elapsed / 4000)) % phrases.length]}…`;
|
||
const prefill = step && generated === 0;
|
||
|
||
return (
|
||
<div className="activity" role="status" aria-live="polite">
|
||
<Star />
|
||
<span className="activity-text">{label}</span>
|
||
{step && (
|
||
<span className="activity-meta">
|
||
({fmtElapsed(elapsed)} · <Icon name="arrow-up" className="tiny" /> {step.estimated ? "~" : ""}
|
||
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · <Icon name="arrow-down" className="tiny" /> {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Thin animated bar shown under the top bar while a session works. */
|
||
export function WorkBar({ active }: { active: boolean }) {
|
||
return <div className={`workbar ${active ? "on" : ""}`} aria-hidden="true" />;
|
||
}
|
||
|
||
/** Prefix the browser tab title while agents work or wait for input. */
|
||
export function useTitleActivity() {
|
||
const t = useT();
|
||
const busyCount = useStore((s) => s.sessions.filter((x) => s.busy[x.id]).length);
|
||
const waiting = useStore((s) => s.permissions.length + s.questions.length);
|
||
useEffect(() => {
|
||
const base = "CodeCraft";
|
||
if (waiting > 0) document.title = `(!) ${t("activity.titleInput")} – ${base}`;
|
||
else if (busyCount > 0) document.title = `(${busyCount}) ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
|
||
else document.title = base;
|
||
}, [busyCount, waiting, t]);
|
||
}
|