- 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>
85 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|