Files
codecraft/web/src/components/WorktreeChip.tsx
T
Richard ZinkandClaude Opus 5.5 fca1c41087 Add worktrees, per-hunk review, effort per message, regenerate, voice input
- Isolated git worktree per session (create, status, merge, discard)
- Permission card: accept file-edit hunks individually; agent learns what was rejected
- Reasoning effort per message; regenerate the last answer with another model/effort
- Voice input via the Web Speech API
- Folder-up button in the file browser; ssh, rsync and mc in the image

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:29:58 +01:00

85 lines
3.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import type { Session } from "../types";
interface WtStatus {
worktree: { repo: string; path: string; branch: string; base: string } | null;
dirty_files?: number;
commits?: string[];
diffstat?: string;
}
/** Chip + panel for a session working in an isolated git worktree. */
export function WorktreeChip({ session, busy }: { session: Session; busy: boolean }) {
const t = useT();
const wt = session.worktree as WtStatus["worktree"] | undefined;
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<WtStatus>();
const [working, setWorking] = useState<string>();
useEffect(() => {
if (!open) return;
get<WtStatus>(`/sessions/${session.id}/worktree`)
.then(setStatus)
.catch((e) => toast(e.message));
}, [open, session.id, busy]);
if (!wt) return null;
const act = async (action: "merge" | "remove") => {
const msg = action === "merge" ? t("worktree.mergeConfirm", { branch: wt.branch, base: wt.base }) : t("worktree.removeConfirm", { branch: wt.branch });
if (!(await confirmDialog({ message: msg, danger: action === "remove" }))) return;
setWorking(action);
try {
await post(`/sessions/${session.id}/worktree`, { action });
toast(t(action === "merge" ? "worktree.merged" : "worktree.removed", { base: wt.base }), "success");
if (action === "merge") setStatus(await get<WtStatus>(`/sessions/${session.id}/worktree`));
else setOpen(false);
} catch (e: any) {
toast(e.message);
} finally {
setWorking(undefined);
}
};
return (
<span className="wt-wrap">
<button className="chip wt-chip" onClick={() => setOpen(!open)} title={t("worktree.title")}>
<Icon name="fork" /> {wt.branch}
</button>
{open && (
<div className="menu-pop-body wt-panel">
<div className="hint">{t("worktree.hint", { base: wt.base })}</div>
{!status ? (
<Spinner small />
) : (
<>
<div className="wt-stats">
<span>{t("worktree.dirty", { n: status.dirty_files ?? 0 })}</span>
<span>{t("worktree.commits", { n: status.commits?.length ?? 0 })}</span>
{status.diffstat && <span className="mono">{status.diffstat}</span>}
</div>
{!!status.commits?.length && (
<pre className="wt-log">{status.commits.slice(0, 8).join("\n")}</pre>
)}
</>
)}
<div className="row" style={{ gap: 6, marginTop: 8 }}>
<button className="small primary" disabled={busy || !!working} onClick={() => act("merge")}>
{working === "merge" ? <Spinner small /> : <Icon name="check" />} {t("worktree.merge", { base: wt.base })}
</button>
<button className="small danger" disabled={busy || !!working} onClick={() => act("remove")}>
<Icon name="trash" /> {t("worktree.remove")}
</button>
</div>
{busy && <div className="hint">{t("worktree.busy")}</div>}
</div>
)}
</span>
);
}