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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:29:58 +01:00
1 parent df1924b0a1
commit fca1c41087
22 files changed
+821 -32

No files matched your search

+30 -7
View File
@@ -12,6 +12,8 @@ import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { RegenerateButton } from "./Regenerate";
import { WorktreeChip } from "./WorktreeChip";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
@@ -215,14 +217,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
}
};
const send = async (raw: string, images: ImageData[]) => {
const send = async (raw: string, images: ImageData[], effort?: string) => {
const txt = raw.trim();
if (txt === "/new") {
setText("");
return navigate("/new");
}
try {
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
await post(`/sessions/${sessionId}/prompt`, { text: txt, images, effort: effort || undefined });
setText("");
follow(true);
} catch (e: any) {
@@ -230,6 +232,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
}
};
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m);
const rewindDone = async () => {
@@ -296,6 +299,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
<WorktreeChip session={session} busy={busy} />
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
@@ -323,16 +327,33 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</button>
)}
<details className="menu-pop">
<summary className="small ghost" title={t("chat.export")}>
<Icon name="download" />
<summary className="small ghost" title={t("chat.more")} aria-label={t("chat.more")}>
<Icon name="more" />
</summary>
<div className="menu-pop-body">
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
{t("chat.exportMd")}
<Icon name="download" /> {t("chat.exportMd")}
</a>
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
{t("chat.exportJson")}
<Icon name="download" /> {t("chat.exportJson")}
</a>
{!session.parent_id && !session.worktree && (
<a
href="#"
onClick={async (e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
try {
await post(`/sessions/${sessionId}/worktree`, { action: "create" });
toast(t("worktree.created"), "success");
} catch (err: any) {
toast(err.message);
}
}}
>
<Icon name="fork" /> {t("worktree.create")}
</a>
)}
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
@@ -366,11 +387,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<div className="chat">
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m) => {
{messages.map((m, idx) => {
if (m.role === "assistant") {
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
</div>
);
}
+15 -4
View File
@@ -2,10 +2,12 @@ import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks";
import { useAgents, useFetch, useModels } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
@@ -49,7 +51,7 @@ export function Composer({
busy: boolean;
text: string;
setText: (s: string) => void;
onSend: (text: string, images: ImageData[]) => void;
onSend: (text: string, images: ImageData[], effort?: string) => void;
onAbort: () => void;
onUpdate: (patch: Partial<Session>) => void;
}) {
@@ -60,6 +62,12 @@ export function Composer({
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
const [uploading, setUploading] = useState(0);
const [active, setActive] = useState(0);
// Reasoning effort for the next message only ("" = model default).
const [effort, setEffort] = useState("");
const [interim, setInterim] = useState("");
const models = useModels();
const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
// @file mentions: token before the caret and matching files.
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
@@ -130,7 +138,8 @@ export function Composer({
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
// Files are referenced by path; the agent reads them with its tools.
const refs = files.length ? `\n\n${t("composer.attachedFiles")}\n${files.map((f) => `- ${f.path} (${formatSize(f.size)})`).join("\n")}` : "";
onSend(`${text}${refs}`, images);
onSend(`${text}${refs}`, images, effort);
setEffort("");
setImages([]);
setFiles([]);
};
@@ -258,7 +267,7 @@ export function Composer({
ref={ref}
rows={1}
value={text}
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
placeholder={interim ? `${interim} …` : busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
onChange={(e) => {
setText(e.target.value);
setActive(0);
@@ -312,6 +321,8 @@ export function Composer({
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
{reasoning && <EffortSelect value={effort} onChange={setEffort} compact />}
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" />
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
+40
View File
@@ -17,3 +17,43 @@ export function Diff({ diff }: { diff: string }) {
</div>
);
}
/** Split a unified diff into its file header and hunks (at "@@" lines). */
export function splitHunks(diff: string): { header: string[]; hunks: string[][] } {
const lines = diff.replace(/\n+$/, "").split("\n");
const header: string[] = [];
const hunks: string[][] = [];
for (const l of lines) {
if (l.startsWith("@@")) hunks.push([l]);
else if (hunks.length) hunks[hunks.length - 1].push(l);
else header.push(l);
}
return { header, hunks };
}
/** Diff with a checkbox per hunk (review a change piece by piece). */
export function HunkDiff({ diff, selected, onToggle }: { diff: string; selected: boolean[]; onToggle: (i: number) => void }) {
const { header, hunks } = splitHunks(diff);
return (
<div className="diff hunk-diff">
{header.map((l, i) => (
<div key={`h${i}`} className="meta">
{l}
</div>
))}
{hunks.map((h, i) => (
<div key={i} className={`hunk-block ${selected[i] ? "" : "off"}`}>
<label className="hunk-head">
<input type="checkbox" checked={!!selected[i]} onChange={() => onToggle(i)} />
<span>{h[0]}</span>
</label>
{h.slice(1).map((l, j) => (
<div key={j} className={l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : ""}>
{l || " "}
</div>
))}
</div>
))}
</div>
);
}
+9
View File
@@ -268,6 +268,15 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
</div>
<div className="crumbs">
<button
className="small ghost crumbs-up"
disabled={!listing?.parent}
onClick={() => listing?.parent && void confirmDiscard().then((ok): unknown => ok && load(listing.parent!))}
title={t("files.up")}
aria-label={t("files.up")}
>
<Icon name="folder-up" />
</button>
{crumbs.map((c, i) => {
const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`;
return (
+6
View File
@@ -193,6 +193,12 @@ const P: Record<string, ReactNode> = {
),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
+17 -6
View File
@@ -5,7 +5,7 @@ import { useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import type { PermissionRequest, QuestionRequest } from "../types";
import { Diff } from "./Diff";
import { Diff, HunkDiff, splitHunks } from "./Diff";
function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) {
const t = useT();
@@ -25,11 +25,17 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
const t = useT();
const [feedback, setFeedback] = useState("");
const [busy, setBusy] = useState(false);
// File edits with several hunks can be accepted piece by piece.
const hunkCount = req.diff && (req.tool === "edit" || req.tool === "write") ? splitHunks(req.diff).hunks.length : 0;
const [selected, setSelected] = useState<boolean[]>(() => Array(hunkCount).fill(true));
const chosen = selected.filter(Boolean).length;
const partial = hunkCount > 1 && chosen < hunkCount;
const reply = async (r: "once" | "always" | "reject") => {
setBusy(true);
try {
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" ? feedback : undefined });
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" || partial ? feedback || undefined : undefined, hunks });
} finally {
setBusy(false);
}
@@ -49,7 +55,12 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
<SubagentHint sessionId={req.session_id} currentId={currentId} />
<div style={{ margin: "6px 0" }}>{req.title}</div>
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
{req.diff ? (
{req.diff && hunkCount > 1 ? (
<>
<div className="hint">{t("permission.hunksHint", { n: hunkCount })}</div>
<HunkDiff diff={req.diff} selected={selected} onToggle={(i) => setSelected((s) => s.map((v, j) => (j === i ? !v : v)))} />
</>
) : req.diff ? (
<Diff diff={req.diff} />
) : (
!command && (
@@ -69,10 +80,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
/>
<div className="actions">
<button className="primary" disabled={busy} onClick={() => reply("once")} autoFocus>
{t("permission.allowOnce")}
<button className="primary" disabled={busy || (hunkCount > 1 && chosen === 0)} onClick={() => reply("once")} autoFocus>
{partial ? t("permission.applySelected", { k: chosen, n: hunkCount }) : t("permission.allowOnce")}
</button>
<button disabled={busy} onClick={() => reply("always")}>
<button disabled={busy || partial} onClick={() => reply("always")}>
{alwaysLabel}
</button>
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
+14
View File
@@ -169,6 +169,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
// Folder: a new one (name) inside a root, or an existing one (picked).
const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new");
const [repoUrl, setRepoUrl] = useState("");
const [isolated, setIsolated] = useState(false);
// Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks.
const [originals, setOriginals] = useState<File[]>([]);
const [progress, setProgress] = useState<Record<string, number>>({});
@@ -323,6 +324,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
if (isolated && folderMode !== "new") {
setCreating(t("worktree.creating"));
await post(`/sessions/${s.id}/worktree`, { action: "create" });
}
const msgs = prompts(uploaded).filter((m) => m.trim());
for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] });
onCreated(s.id);
@@ -547,6 +552,15 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
</div>
)}
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
{folderMode !== "new" && (
<label className="wiz-check-row">
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
<span>
<b>{t("worktree.isolate")}</b>
<span className="hint"> – {t("worktree.isolateHint")}</span>
</span>
</label>
)}
{error && <div className="notice err">{error}</div>}
<div className="wiz-start-row">
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
+62
View File
@@ -0,0 +1,62 @@
import { useState } from "react";
import { post } from "../api";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
import { ModelSelect } from "./ModelSelect";
export const EFFORTS = ["low", "medium", "high", "max"] as const;
/** Effort choice for one turn ("" = the model's default). */
export function EffortSelect({ value, onChange, compact }: { value: string; onChange: (v: string) => void; compact?: boolean }) {
const t = useT();
return (
<select className={compact ? "effort-select" : ""} value={value} onChange={(e) => onChange(e.target.value)} title={t("effort.title")} aria-label={t("effort.title")}>
<option value="">{compact ? t("effort.autoShort") : t("effort.auto")}</option>
{EFFORTS.map((e) => (
<option key={e} value={e}>
{t(`effort.${e}` as any)}
</option>
))}
</select>
);
}
/** Redo the last turn, optionally with another model and effort. */
export function RegenerateButton({ sessionId, messageId, model }: { sessionId: string; messageId: string; model: string }) {
const t = useT();
const [open, setOpen] = useState(false);
const [m, setM] = useState(model);
const [effort, setEffort] = useState("");
const [busy, setBusy] = useState(false);
const go = async () => {
setBusy(true);
try {
await post(`/sessions/${sessionId}/regenerate`, { message_id: messageId, model: m !== model ? m : undefined, effort: effort || undefined });
setOpen(false);
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="regen">
<button className="small ghost" onClick={() => setOpen(!open)} title={t("regen.title")}>
<Icon name="refresh" /> {t("regen.button")}
</button>
{open && (
<div className="regen-panel">
<div className="hint">{t("regen.hint")}</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<ModelSelect value={m} onChange={setM} />
<EffortSelect value={effort} onChange={setEffort} />
<button className="small primary" disabled={busy} onClick={go}>
<Icon name="refresh" /> {t("regen.go")}
</button>
</div>
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
type Recognition = {
lang: string;
continuous: boolean;
interimResults: boolean;
start(): void;
stop(): void;
onresult: ((e: any) => void) | null;
onend: (() => void) | null;
onerror: ((e: any) => void) | null;
};
const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition;
export const voiceSupported = () => !!Impl;
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null);
useEffect(() => () => rec.current?.stop(), []);
if (!Impl) return null;
const toggle = () => {
if (listening) {
rec.current?.stop();
return;
}
const r = new Impl!();
r.lang = LOCALES[getLang()] ?? navigator.language;
r.continuous = true;
r.interimResults = true;
r.onresult = (e: any) => {
let interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const res = e.results[i];
if (res.isFinal) onText(res[0].transcript.trim());
else interim += res[0].transcript;
}
onInterim(interim);
};
r.onerror = (e: any) => {
if (e.error === "not-allowed" || e.error === "service-not-allowed") toast(t("voice.denied"));
else if (e.error !== "no-speech" && e.error !== "aborted") toast(`${t("voice.error")}: ${e.error}`);
};
r.onend = () => {
setListening(false);
onInterim("");
};
rec.current = r;
r.start();
setListening(true);
};
return (
<button type="button" className={`ghost small voice-btn ${listening ? "on" : ""}`} onClick={toggle} title={t(listening ? "voice.stop" : "voice.start")} aria-label={t(listening ? "voice.stop" : "voice.start")} aria-pressed={listening}>
<Icon name="mic" />
</button>
);
}
+84
View File
@@ -0,0 +1,84 @@
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>
);
}