Files
codecraft/web/src/components/Activity.tsx
T
Richard ZinkandClaude Opus 5.5 df1924b0a1 Add web terminal, git credentials, OpenSCAD, review loops, automations
- 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>
2026-11-08 22:00:38 +01:00

178 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]);
}