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

+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 = ""))} />