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

+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>
);
}