Files
DeepSeek-Reasonix/desktop/frontend-next/src/ui/Composer.tsx
T

800 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
import type { AgentPort, ChipCall, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup";
import { WorktreeSetupPage } from "./WorktreeSetupPage";
import { Policy } from "./Policy";
import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
import { useModelHealth } from "./modelhealth";
import { effortMenu, effortReading, effortShort, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
import { EffortColumn } from "./EffortColumn";
import { hasVendorMark, VendorMark } from "./vendormarks";
import { CompletionMenu, useCompletion } from "./Completion";
import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
import { usePlaceholderFit } from "./placeholderfit";
import { useTypedPlaceholder } from "./firstRise";
import { useFitHeight } from "./fitHeight";
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
import { runningSendIntent, sendAria, sendHint, sendsOnKey } from "./sendkey";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
import { useIntake } from "./useIntake";
import type { Dropped } from "./filedrop";
import type { Quote } from "./cards/SayCard";
import { StudioIcon } from "./StudioIcon";
import { usePromptRefine } from "./PromptRefine";
import { useProviderOrder } from "../state/providerorder";
import { useModelFilter, writeModelFilter } from "../state/modelfilter";
import { useDraft } from "./useDraft";
import type { Submitted } from "./usesubmit";
import { touchKeyboard } from "./touchKeyboard";
import { ImagePreview } from "./ImagePreview";
import { SideAskButton } from "./SideAskButton";
import { chipId, chipName, isPicture, quoteBlock, releaseChip, type Chip } from "./composerChips";
interface Props {
worktreeTarget?: HTMLElement | null;
onWorktreePhase?: (phase: string | null) => void;
fresh?: boolean;
port: AgentPort;
status: SessionStatus | null;
running: boolean;
queued?: boolean;
// Text a card asked to be quoted, and a counter that makes the same text
// twice two requests. Quoting the same reply again is an ordinary thing to
// do, and comparing the string alone would drop the second one.
quote?: Quote;
// A line taken back from the queue. The counter makes the same text twice
// two requests, as it does for a quote.
restore?: { n: number; text: string };
// Text another surface hands the composer, such as a file reference; it goes
// in at the caret with a space on either side.
inject?: { n: number; text: string; session?: { id: string; title: string } };
// Resolves false when the line never left, so what was typed comes back
// rather than being lost to a refusal the user could not have prevented.
// Bumped when something outside asks for the cursor — answering a plan card
// with "revise" is a request to say what to change, and the saying happens here.
focus?: number;
onSubmit: (text: string, chips?: ChipCall) => Promise<Submitted>;
onChanged: () => void;
onError: (e: unknown) => void;
onSettings?: (section?: string) => void;
side?: boolean;
onSide?: () => void;
sidePanel?: ReactNode;
queueStrip?: ReactNode;
changeCount?: number;
countElsewhere?: boolean;
// Git state follows changeCount's refresh path (turn boundaries and writes).
// null is unread; repo:false is a workspace without a repository.
git?: WorkspaceGit | null;
workspace?: WorkspaceInfo | null;
// The switch already answers with the workspace's Git state.
onTreeChanged?: (git: WorkspaceGit) => void;
// Bumped when settings change; a source edited there can change the ladder.
pulse?: number;
draftKey?: string;
}
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, queueStrip, changeCount = 0, countElsewhere = false, git = null, workspace = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
const touch = touchKeyboard();
const providerOrder = useProviderOrder();
const modelGroups = useModelFilter();
const [submitting, setSubmitting] = useState(false);
const worktree = useWorktreeSetup(port, fresh && !!git?.repo, status?.workspaceRoot ?? "", onChanged, onTreeChanged, status?.sessionPath ?? "");
const [setupDraft, setSetupDraft] = useState<{ text: string; files: string[] }>({ text: "", files: [] });
const setupPhase = worktree.stage?.phase ?? null;
useEffect(() => { onWorktreePhase?.(setupPhase); }, [onWorktreePhase, setupPhase]);
const { text, setText, beginSubmit, finishSubmit } = useDraft(draftKey, submitting);
// The caret decides which token is being completed, so it is state here
// rather than something read off the element when a menu happens to open.
const [caret, setCaret] = useState(0);
const [shots, setShots] = useState<Chip[]>([]);
const uploads = useRef(new Map<string, AbortController>());
useEffect(() => () => {
uploads.current.forEach((upload) => upload.abort());
uploads.current.clear();
}, []);
const [stopping, setStopping] = useState(false);
const submittingRef = useRef(false);
const stoppingRef = useRef(false);
const picker = useRef<HTMLInputElement>(null);
const modelHealth = useModelHealth(port, pulse);
const { models, loadModels } = modelHealth;
const menuItems = modelMenu(models, providerOrder, status?.modelRef, modelHealth.readings);
const chosenModelGroups = chosenGroups(menuItems, modelGroups);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const box = useRef<HTMLTextAreaElement>(null);
const hadSetup = useRef(false);
useEffect(() => {
if (hadSetup.current && !setupPhase) box.current?.focus();
hadSetup.current = !!setupPhase;
}, [setupPhase]);
const refine = usePromptRefine(port, text, (next) => {
setText(next);
setCaret(next.length);
box.current?.focus();
});
const guide = useId();
const completionId = useId();
const attachTipId = useId();
// Set only when a completion moved the caret: the browser puts it at the end
// of a programmatic value, which is wrong for anything accepted mid-line.
const pending = useRef<number | [number, number] | null>(null);
// A drop lands where the caret is, and the handler that receives it was built
// once — so the position it reads has to be a ref, not the render's copy.
const caretRef = useRef(0);
const type = (next: string, at: number) => {
setText(next);
setCaret(at);
caretRef.current = at;
};
// A quote rides above the box like a long paste does, for the same reason: an
// answer pasted into the composer becomes the composer. It is material for
// the question, not the question, so it stays removable until the turn goes.
useEffect(() => {
if (!quote?.n) return;
setShots((prev) => [
...prev,
{ k: "quote", id: chipId(), body: quote.text, turn: quote.turn, source: quote.source, lines: countLines(quote.text) },
]);
queueMicrotask(() => box.current?.focus());
}, [quote?.n]);
const restoredAt = useRef(restore?.n ?? 0);
useEffect(() => {
if (!restore?.n || restore.n === restoredAt.current) return;
restoredAt.current = restore.n;
setText((prev) => {
const next = !prev.trim() || prev.trim() === restore.text.trim()
? restore.text : `${prev.replace(/\s+$/, "")}\n${restore.text}`;
pending.current = next.length;
return next;
});
queueMicrotask(() => box.current?.focus());
}, [restore?.n]);
const injectedAt = useRef(inject?.n ?? 0);
useEffect(() => {
if (!inject?.n || inject.n === injectedAt.current) return;
injectedAt.current = inject.n;
if (inject.session) {
addSession(inject.session);
queueMicrotask(() => box.current?.focus());
return;
}
const where = caret;
setText((prev) => {
const at = Math.min(where, prev.length);
const lead = at > 0 && !/\s$/.test(prev.slice(0, at)) ? " " : "";
const piece = lead + inject.text + " ";
pending.current = at + piece.length;
return prev.slice(0, at) + piece + prev.slice(at).replace(/^ /, "");
});
queueMicrotask(() => box.current?.focus());
}, [inject?.n]);
const moveTo = useCallback((next: string, at: number) => {
pending.current = at;
type(next, at);
}, []);
const chips = useSkillChips(box, text, moveTo);
const addSession = useCallback((s: { id: string; title: string }) => setShots((prev) => prev.some((c) => c.k === "session" && c.session === s.id) ? prev : [...prev, { k: "session", id: chipId(), session: s.id, title: s.title }]), []);
const menu = useCompletion(port, text, caret, (next, at) => {
moveTo(next, at);
box.current?.focus();
}, chips, addSession);
const ime = useIme();
const sendShortcut = useSendShortcut();
const runningIntent = runningSendIntent(useRunningMessageIntent(), chips.list.length > 0);
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
useEffect(() => {
if (focus) box.current?.focus();
}, [focus]);
// The kernel's own report that no turn is live ends a stop even when the
// turn-done event never reached this window.
const kernelIdle = status?.running === false;
useEffect(() => {
if (running && !kernelIdle) return;
stoppingRef.current = false;
setStopping(false);
}, [running, kernelIdle]);
const placeCaret = useCallback((el: HTMLTextAreaElement) => {
if (pending.current === null) return;
const [from, to] = typeof pending.current === "number" ? [pending.current, pending.current] : pending.current;
el.setSelectionRange(from, to);
pending.current = null;
}, []);
useFitHeight(box, text, placeCaret);
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("composer.keep_typing_to_queue_further"), t("composer.keep_typing"), "…"] : running ? [t("composer.you_can_keep_typing_while"), t("composer.keep_typing"), "…"] : [t("composer.describe_a_task_question_or"), t("composer.describe_a_task"), "…"]);
const typedPlaceholder = useTypedPlaceholder(fittedPlaceholder, text.length > 0);
const placeholder = showHint ? typedPlaceholder : "";
// Attachments ride into the turn as path references, exactly as they do from
// the CLI — the host saved the bytes, the turn parser resolves the token. A
// held-back paste follows the typed text, which is where it reads as the
// material the message is about rather than as part of the sentence.
const keepPaste = (chip: PasteChip) => keepPasteAsFile(port, chip, setShots);
const send = () => {
if (submittingRef.current || shots.some((c) => c.k === "attachment" && c.state !== "ready")) return;
const v = text.trim();
if (!v && shots.length === 0) return;
const refs = shots.flatMap((c) => (c.k === "attachment" && c.a ? [c.a.ref] : c.k === "session" ? ["@session:" + c.session] : []));
const pastes = shots.flatMap((c) => (c.k === "paste" ? [c.saved ? c.saved.pointer.trim() : c.body] : []));
// A quote is what the question is about, so it leads; a held-back paste is
// the material the answer needs and follows what was typed.
const quotes = shots.flatMap((c) => (c.k === "quote" ? [quoteBlock(c)] : []));
const compose = (t: string) => [...quotes, [...refs, t.trim()].filter(Boolean).join(" "), ...pastes].filter(Boolean).join("\n\n");
const line = compose(v);
const call = chips.call(compose);
const draft = { text, shots, caret: caretRef.current, chips: chips.held() };
if (worktree.selection || worktree.stage) setSetupDraft({ text: v, files: shots.map(chipName) });
const restoreDraft = (select = false) => {
finishSubmit(false);
pending.current = select ? [0, draft.text.length] : draft.caret;
type(draft.text, draft.caret);
chips.restore(draft.chips);
setShots((current) => [...draft.shots, ...current]);
if (!worktree.selection && !worktree.stage) queueMicrotask(() => box.current?.focus());
};
beginSubmit(text);
submittingRef.current = true;
setSubmitting(true);
type("", 0);
setShots([]);
void (async () => {
try {
if (worktree.selection || worktree.stage) {
const prepared = await worktree.prepare(draft.shots.flatMap((chip) => chip.k === "attachment" && chip.a ? [chip.a.path] : chip.k === "paste" && chip.saved ? [chip.saved.path] : []));
if (!prepared) { restoreDraft(); return; }
}
const sent = await (call ? onSubmit(line, call) : onSubmit(line));
if (sent === true) {
finishSubmit(true);
worktree.dismiss();
draft.shots.forEach(releaseChip);
}
else {
// A command this connection will never run is left selected, so the
// next thing typed replaces it instead of joining it.
restoreDraft(sent === "shell_unavailable");
worktree.dismiss();
}
} catch (e) {
restoreDraft();
if (!(e instanceof WorktreeSetupFailure)) onError(e);
} finally {
submittingRef.current = false;
setSubmitting(false);
}
})();
};
const insert = useCallback((snippet: string) => {
const el = box.current;
const at = el ? el.selectionStart : caretRef.current;
setText((prev) => {
const cut = Math.min(at, prev.length);
const next = prev.slice(0, cut) + snippet + prev.slice(cut);
pending.current = cut + snippet.length;
return next;
});
}, []);
const discard = useCallback((c: Chip) => {
uploads.current.get(c.id)?.abort();
uploads.current.delete(c.id);
releaseChip(c);
setShots((prev) => prev.filter((x) => x !== c));
}, []);
const upload = useCallback(
(id: string, blob: File) => {
const controller = new AbortController();
uploads.current.get(id)?.abort();
uploads.current.set(id, controller);
void port
.attach(blob, blob.name, controller.signal)
.then((a) =>
setShots((prev) => prev.map((c) => (c.k === "attachment" && c.id === id ? { ...c, a, state: "ready", error: "" } : c))),
)
.catch((e: unknown) =>
setShots((prev) => prev.map((c) => (c.k === "attachment" && c.id === id ? { ...c, state: "failed", error: reason(e) } : c))),
)
.finally(() => {
if (uploads.current.get(id) === controller) uploads.current.delete(id);
});
},
[port],
);
const attach = useCallback(
(blobs: File[]) => {
if (blobs.length === 0 || submittingRef.current) return;
const added: Chip[] = blobs.map((blob) => ({
k: "attachment",
id: chipId(),
name: blob.name,
state: "adding",
blob,
url: blob.type.startsWith("image/") ? previewURL(blob) : undefined,
}));
setShots((prev) => [...prev, ...added]);
for (const c of added) if (c.k === "attachment" && c.blob) upload(c.id, c.blob);
},
[upload],
);
// A dropped file is referenced where it lives. Copying it in is what let a
// turn edit the copy and report the edit as done while the file the user
// pointed at never changed. Only the host can mint the token: whether a path
// is inside the workspace compares two spellings of one location.
const refIn = useCallback(
(paths: string[]) => {
port
.dropRefs(paths)
.then((refs) => {
const took = refs.flatMap((r) =>
r.ref
? [{
k: "attachment" as const,
id: chipId(),
name: nameOf(r.path ?? r.ref),
state: "ready" as const,
a: { path: r.path ?? "", ref: r.ref, image: r.image },
}]
: [],
);
if (took.length > 0) setShots((prev) => [...prev, ...took]);
const refused = refs.flatMap((r) => (r.error ? [r.error] : []));
if (refused.length > 0) onError(new Error(refused.join("\n")));
})
.catch(onError);
},
[port, onError],
);
// One place decides what a payload becomes, whichever channel carried it.
const receive = useCallback(
(d: Dropped) => {
if (d.paths.length > 0) refIn(d.paths);
else if (d.files.length > 0) {
attach(d.files);
}
if (d.text) insert(d.text + " ");
// A drop is the start of typing, not the end of it.
box.current?.focus();
},
[refIn, attach, insert],
);
const { plan: drag, ref: dropzone } = useIntake({
root: status?.workspaceRoot ?? status?.cwd,
onReceive: receive,
});
const efforts = effortsFor(models, status?.modelRef);
const currentMark = models.find((m) => m.ref === status?.modelRef)?.mark;
// Whether this model says anything about reasoning levels at all. The session
// may still carry one from a model that did, and printing that would be the
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
const modelLb = modelLabel(models, status);
// A model switch rebuilds the runtime kernel-side; other controls here may
// land at once. Each click needs its own pending state: greying the whole
// shelf says nothing can change while only one control waits on the kernel.
const busyRef = useRef(new Set<string>());
const change = (key: string, call: () => Promise<void>) => {
if (busyRef.current.has(key)) return;
busyRef.current.add(key);
setBusy((b) => ({ ...b, [key]: true }));
void Promise.resolve().then(call).then(onChanged).catch(onError).finally(() => {
busyRef.current.delete(key);
setBusy((b) => ({ ...b, [key]: false }));
});
};
const adding = shots.some((c) => c.k === "attachment" && c.state === "adding");
const failed = shots.some((c) => c.k === "attachment" && c.state === "failed");
const hasDraft = text.trim().length > 0 || shots.some((c) => c.k !== "attachment" || c.state === "ready");
const lines = countLines(text);
const showCount = text.length >= 240 || lines > 3;
const sendDisabled = submitting || stopping || adding || failed || !hasDraft || !!worktree.stage;
const warnPictures = status?.vision === false && shots.some((c) => c.k === "attachment" && c.state === "ready" && isPicture(c));
const setupPage = worktree.stage && <WorktreeSetupPage stage={worktree.stage} task={setupDraft.text} files={setupDraft.files} busy={submitting} onRetry={send} onCancel={() => void worktree.cancel()} onEdit={worktree.dismiss}/>;
return (<>
{setupPage && (worktreeTarget ? createPortal(setupPage, worktreeTarget) : setupPage)}
<div className="dropzone" ref={dropzone} data-worktree-setup={worktree.stage ? "" : undefined} inert={!!worktree.stage}>
{menu.open && (
<CompletionMenu
id={completionId}
items={menu.completion.items}
active={menu.active}
kind={menu.completion.kind}
query={menu.completion.query ?? ""}
kb={menu.kb}
onPick={menu.accept}
onHover={menu.hover}
/>
)}
<div role="status" aria-live="polite" aria-label={t("common.completions")}>
{menu.loading && !menu.open && <div className="menu slashmenu">
<div className="mnone">{t("composer.loading_commands_and_skills")}</div>
</div>}
</div>
{/* What letting go will do, said before it happens. A drop that only
reports afterwards is the pattern this replaces. */}
{drag && (
<p className="intake" data-tone={planTone(drag)} role="status" aria-live="polite">
{planVerb(drag)}
</p>
)}
{refine.card}
{shots.length > 0 && (
<ul className="shots">
{shots.map((c, i) => (
<li
className="shot"
key={c.id}
data-state={c.k === "attachment" ? c.state : undefined}
style={{ "--i": i } as React.CSSProperties}
>
{c.k === "attachment" ? (
<>
{isPicture(c) ? (
<ImagePreview src={c.url} name={c.name}
className="thumb"
>
<span aria-hidden="true" style={c.url ? { backgroundImage: `url(${c.url})` } : undefined} />
</ImagePreview>
) : (
<span className="glyph" aria-hidden="true">{kindOf(c.a?.path ?? c.name)}</span>
)}
<span className="meta">
<span className="nm" title={c.a?.path ?? c.name}>{c.name}</span>
{c.state === "adding" && <span className="sz live">{t("composer.adding")}</span>}
{c.state === "ready" && <span className="sz">{isPicture(c) ? t("composer.image") : t("common.files")}</span>}
{c.state === "failed" && c.error && <span className="why" title={c.error}>{c.error}</span>}
{c.state === "failed" && (
<button
className="retry"
data-action="session.attach"
onClick={() => {
if (!c.blob) return;
setShots((prev) => prev.map((x) => (x === c ? { ...c, state: "adding", error: "" } : x)));
upload(c.id, c.blob);
}}
>
{t("composer.could_not_add_retry")}
</button>
)}
</span>
</>
) : c.k === "session" ? (
<>
<span className="glyph" aria-hidden="true">{"#"}</span>
<span className="meta">
<span className="nm" title={c.title}>{c.title}</span>
<span className="sz">{t("composer.past_conversation_read_only_reference")}</span>
</span>
</>
) : (
<>
<span className="glyph" aria-hidden="true">{c.k === "quote" ? "❝" : "TXT"}</span>
<span className="meta">
<span className="nm" title={c.body}>{chipName(c)}</span>
<button
className="undo"
onClick={() => {
discard(c);
insert(c.k === "quote" ? quoteBlock(c) : c.body);
}}
>
{t("composer.n_lines_put_it_back", { n: c.lines })}
</button>
{c.k === "paste" && <PasteKeep chip={c} onKeep={keepPaste} />}
</span>
</>
)}
<button
className="x"
aria-label={t("common.remove_name", { name: chipName(c) })}
onClick={() => discard(c)}
>
×
</button>
</li>
))}
</ul>
)}
{/* A warning cannot be the last horizontally scrolling attachment: that
is exactly where it disappears when there are enough files to matter. */}
{warnPictures && (
<p className="shotwarn" role="status">
{status?.visionDeclared === false
? t("composer.nobody_has_said_whether_this")
: t("composer.this_model_does_not_read")}
</p>
)}
{/* The prompt glyph sits beside the box rather than in it, so it cannot be
dragged into a selection of what was typed. */}
{queueStrip}
<div className="fmain">
<span className="prompt" aria-hidden="true">
›
</span>
<textarea
data-action-keydown="session.send" data-window-keys=""
ref={box}
rows={1}
value={text}
data-chips={chips.list.length > 0 ? "" : undefined}
placeholder={placeholder}
role="combobox"
aria-label={t("composer.task_input")}
aria-describedby={guide}
aria-keyshortcuts={sendAria(sendShortcut, touch)}
aria-busy={submitting}
readOnly={submitting}
aria-expanded={menu.open}
aria-controls={menu.open ? completionId : undefined}
aria-autocomplete="list"
aria-activedescendant={menu.open ? `${completionId}-${menu.active}` : undefined}
onChange={(e) => type(e.target.value, e.target.selectionStart)}
onBlur={() => menu.dismiss()}
// Arrow keys and clicks move the caret without changing the text, and
// the caret is what decides which token the menu is completing.
onKeyUp={(e) => setCaret(chips.snap(e.currentTarget, caret))}
onClick={(e) => setCaret(chips.snap(e.currentTarget, caret))}
data-action-paste="session.attach"
// Clipboard files are copied into host-owned attachments. Their native
// paths can belong to a source application's restricted or short-lived cache.
onPaste={(e) => {
if (submitting) {
e.preventDefault();
return;
}
const files = [...e.clipboardData.files];
if (files.length > 0) {
e.preventDefault();
attach(files.map((file) => new File([file], file.name, { type: file.type })));
return;
}
const body = e.clipboardData.getData("text/plain");
if (!pasteIsLong(body)) return;
// Long enough to bury the composer. It still goes with the turn — it
// is just held beside the box instead of becoming it.
e.preventDefault();
setShots((prev) => [...prev, { k: "paste", id: chipId(), body, lines: countLines(body) }]);
}}
{...ime.handlers}
onKeyDown={(e) => {
if (!ime.isIme(e.nativeEvent) && refine.onKey(e)) return;
// Picking a word from an input method is not typing in this box: its
// Enter confirms a candidate, and acting on it would send a
// half-written message or accept a completion nobody asked for.
if (ime.isIme(e.nativeEvent)) {
// Esc dismisses the candidate window; letting it through would
// cancel the running turn as a side effect of closing an IME.
if (e.key === "Escape") e.stopPropagation();
// That Enter belongs to the input method, so it must do nothing —
// returning without stopping it left the textarea to insert a
// newline, which is why the first Enter after a word broke the line
// and only the second one sent.
if (e.key === "Enter") e.preventDefault();
return;
}
const sendKey = sendsOnKey(e, sendShortcut, touch);
if (menu.open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
menu.move(e.key === "ArrowDown" ? 1 : -1);
return;
}
// Tab descends into directories; Enter confirms the highlighted reference.
// The configured modifier chord sends the draft even with a menu open.
if (menu.open && (e.key === "Tab" || (e.key === "Enter" && !menu.typedChosen && !(sendShortcut === "modifier_enter" && sendKey))) && !e.shiftKey) {
e.preventDefault();
menu.accept(undefined, e.key === "Tab" ? "descend" : "confirm");
return;
}
// Esc closes the menu and stops there: reaching the app would cancel
// the running turn, which is not what dismissing a menu means.
if ((menu.open || menu.loading) && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
menu.dismiss();
return;
}
if (sendKey) {
e.preventDefault();
send();
}
}}
/>
<ChipMirror box={box} text={text} chips={chips.list} />
</div>
<div className="composeguide" id={guide}>
<span className="composecaps" aria-hidden="true">
<span><kbd>/</kbd>{t("composer.commands_and_skills")}</span>
<span><kbd>@</kbd>{t("composer.reference_files")}</span>
</span>
<span className="composehint" aria-live="polite">
{stopping
? t("composer.stopping")
: submitting
? t("common.sending")
: adding
? t("composer.adding_attachments")
: failed
? t("composer.an_attachment_failed_retry_or")
: sendHint(sendShortcut, running, touch, runningIntent)}
</span>
{showCount && <span className="fcount">{t("composer.n_characters_lines_lines", { n: text.length, lines })}</span>}
</div>
<div className="row">
{/* 拖进来和粘贴都走同一条路,但那两个都得先有一张图在手边。点开系统
选择器是唯一不需要预备动作的入口。 */}
<input
ref={picker}
data-action-change="session.attach"
type="file"
multiple
hidden
onChange={(e) => {
attach([...(e.target.files ?? [])]);
// 同一张图再选一次也要能进来,所以每次用完清空。
e.target.value = "";
}}
/>
<div className="turntools">
<button
className="mode plain attach studio-attach"
aria-label={t("composer.add_an_attachment")}
aria-describedby={attachTipId}
onClick={() => picker.current?.click()}
>
<StudioIcon name="plus" />
<span className="studio-control-tip" id={attachTipId} role="tooltip">
<b>{t("composer.add_an_attachment")}</b>
<span>{t("composer.you_can_also_drop_or")}</span>
</span>
</button>
{onSide && <SideAskButton open={side} onToggle={onSide} panel={sidePanel} />}
{refine.button}
<div className="studio-mode-control">
<Picker
className="mode studio-mode-picker"
triggerAction="plan.mode"
ariaPressed={status?.plan ?? false}
place="bottom"
title={t("composer.work_mode")}
menuClassName="composer-menu mode-menu"
current={status?.plan ? "plan" : "agent"}
pending={busy["plan"]}
items={[
{ value: "__mode", label: t("composer.work_mode"), right: t("common.current_task"), header: true },
{ value: "agent", label: "Agent", desc: t("composer.run_tasks_with_enabled_tools") },
{ value: "plan", label: "Plan", desc: t("composer.plan_steps_before_changing_files") },
]}
onPick={(value) => change("plan", () => port.setPlanMode(value === "plan"))}
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("common.plan")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
kernel turns off the moment a plan is approved. The lifecycle is a
separate reading — an approved plan is still running, and saying so
here is not the same as offering to turn planning back on. */}
{status?.planPhase === "executing" && (
<span className="mode plain" data-plan-phase="executing" title={t("composer.executing_an_approved_plan")}>
<span className="lb">{t("composer.executing_plan")}</span>
</span>
)}
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
<div className="studio-model-group">
<Picker
wrapClassName="studio-model-control"
className="mode model-picker"
data-action="model.select"
place="bottom"
align="end"
title={modelTooltip(models, status?.modelRef, modelLb, status && declared ? effortReading(status.effort, status.modes) : undefined)}
current={status?.modelRef}
items={narrowToGroups(menuItems, chosenModelGroups)}
head={<ModelGroupChips headings={groupHeadings(menuItems)} chosen={chosenModelGroups} onChange={writeModelFilter} />}
menuClassName="studio-model-menu composer-menu"
searchAlways
searchPlaceholder={t("composer.search_models_or_providers")}
menuTitle={<><b>{t("composer.choose_model")}</b><small>{t("composer.for_future_tasks")}</small></>}
onOpen={() => { void modelHealth.check(loadModels()); loadModels(); }}
pending={busy["model"] || busy["effort"] || busy["mode"]}
asideClassName="studio-effort-menu"
stayOpen={(ref) => ref !== "__manage-models"}
onPick={(ref) => ref === "__manage-models" ? onSettings("providers") : change("model", () => port.setModel(ref))}
aside={status ? (close) => (
<EffortColumn
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes, forcedThinking)}
current={declared ? status.effort || "auto" : ""}
onPick={(value) => {
close();
routeEffortPick(value, status.modes, {
declare: () => onSettings("providers:effort-declare"),
effort: (level) => change("effort", () => port.setEffort(level)),
mode: (mode) => change("mode", () => port.setModelMode(mode)),
});
}}
/>
) : undefined}
label={<>{hasVendorMark(currentMark) && <VendorMark slug={currentMark} size={14} />}<span className="nm">{modelLb}</span>{status && <><i className="studio-model-sep" aria-hidden="true" /><span className="studio-effort-reading"><StudioIcon name="brain" />{declared ? effortShort(status.effort, status.modes) : t("common.not_declared")}</span></>}<StudioIcon name="down" /></>}
/>
</div>
</div>
<span className="go">
{running && (
<button
className="btn stop"
data-primary
data-action="session.stop"
data-pending={stopping ? "" : undefined}
aria-label={t(stopping ? "composer.stopping" : "composer.stop")}
disabled={stopping}
aria-busy={stopping}
onClick={() => {
if (stoppingRef.current) return;
stoppingRef.current = true;
setStopping(true);
void port.cancel().then(onChanged, (e: unknown) => {
stoppingRef.current = false;
setStopping(false);
onError(e);
});
}}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
<rect x="4.8" y="4.8" width="6.4" height="6.4" rx="1.3" />
</svg>
</span>
<span>{t(stopping ? "composer.stopping" : "composer.stop")}</span>
</button>
)}
<button
className="btn send"
aria-label={t(submitting ? "common.sending" : running ? runningIntent === "followup" ? "composer.queue" : "common.steer" : "common.send")}
data-primary={running ? undefined : ""}
data-action="session.send"
data-running={running ? "" : undefined}
disabled={sendDisabled}
aria-busy={submitting}
onClick={send}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
</svg>
</span>
<span>{t(submitting ? "common.sending" : running ? runningIntent === "followup" ? "composer.queue" : "common.steer" : "common.send")}</span>
</button>
</span>
</div>
</div></>
);
}