feat(studio): steer and queued lines follow the composer board — a waiting steer is a dashed bubble at the end of the conversation, a queued line is one pill row with make-steer, send-now and withdraw

This commit is contained in:
yhh committed 2026-11-06 01:09:14 -08:00
1 parent b1106b98f3
commit 195e048b55
18 files changed
+300 -726

No files matched your search

+18 -2
View File
@@ -1,2 +1,18 @@
.qacts button.qnow { height: 24px; margin-left: 3px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-micro) var(--ui); white-space: nowrap; }
.qacts button.qnow:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
.queue { display: flex; flex-direction: column; gap: 6px; margin: 0 0 8px; padding: 0; border: 0; }
.queue .qi { display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 0; padding: 6px 8px 6px 10px; border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--page); font: var(--w-reg) var(--fs-meta)/1.35 var(--ui); color: var(--text); }
.queue .qi:hover, .queue .qi:focus-within { background: var(--page); }
.queue .ql { flex: none; font-weight: var(--w-med); color: var(--muted); white-space: nowrap; }
.queue .ql[data-tone="warn"] { color: var(--warn-ink); }
.queue .pv { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue .qwhy { flex: 0 1 24%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.queue .qacts { display: flex; align-items: center; gap: 6px; flex: none; }
.queue .qb { height: 20px; padding: 0 7px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; cursor: pointer; }
.queue .qb:hover { background: var(--overlay); }
.queue .qb:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.steerwait { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; margin: 0 0 10px; }
.steerwait .sw-hd { display: flex; align-items: center; gap: 6px; font: var(--w-med) var(--fs-micro)/1.35 var(--ui); color: var(--faint); }
.steerwait .sw-undo { height: 20px; padding: 0 7px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-micro)/1 var(--ui); cursor: pointer; }
.steerwait .sw-undo:hover { background: var(--overlay); }
.steerwait .sw-undo:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.steerwait .sw-txt { max-width: 80%; padding: 8px 12px; border: 1px dashed var(--border); border-radius: var(--r-md); background: var(--overlay); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); opacity: .8; white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -126,6 +126,7 @@ export const COMPOSER: Messages = {
"composer.live": { zh: "实时更新", en: "Live" },
"composer.loading_commands_and_skills": { zh: "正在加载命令与技能…", en: "Loading commands and skills…" },
"composer.local_branches": { zh: "本地分支", en: "Local branches" },
"composer.make_it_a_steer": { zh: "改为插话", en: "Make it a steer" },
"composer.make_name_the_default_model": { zh: "将 {name} 设为默认模型", en: "Make {name} the default model" },
"composer.manage_mcp_and_tools": { zh: "管理 MCP 与工具", en: "Manage MCP and tools" },
"composer.manage_models_and_connections": { zh: "管理模型与连接", en: "Manage models and connections" },
@@ -256,6 +257,7 @@ export const COMPOSER: Messages = {
"composer.source": { zh: "起点", en: "Source" },
"composer.steer_accepted": { zh: "插话已收", en: "Steer accepted" },
"composer.steer_queued": { zh: "插话排队", en: "Steer queued" },
"composer.steer_waiting": { zh: "插话 · 等待插入", en: "Steer · waiting to land" },
"composer.stop": { zh: "停下", en: "Stop" },
"composer.stop_the_current_response_and": { zh: "停止当前回复,把这条作为新一轮立即发出", en: "Stop the current response and send this as a new turn" },
"composer.stopping": { zh: "正在停止…", en: "Stopping…" },
@@ -318,6 +320,7 @@ export const COMPOSER: Messages = {
"composer.waiting_to_connect": { zh: "等待连接", en: "Waiting to connect" },
"composer.work_mode": { zh: "工作模式", en: "Work mode" },
"composer.you_can_also_drop_or": { zh: "也可直接拖入或粘贴", en: "You can also drop or paste files" },
"composer.withdraw": { zh: "撤回", en: "Withdraw" },
"composer.you_can_also_type_the": { zh: "可以直接输入完整模型 ID 添加。", en: "You can also type the full model ID to add it." },
"composer.you_can_keep_typing_while": { zh: "运行中也可以继续输入…", en: "You can keep typing while it runs…" },
};
+1 -1
View File
@@ -380,7 +380,7 @@
.queue {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px;
min-height: 0; padding-bottom: 10px; border-bottom: 1px solid var(--hair);
font: var(--w-reg) var(--fs-meta) var(--mono);
font: var(--w-reg) var(--fs-meta) var(--ui);
animation: m-card var(--m-card) both;
}
.qhd { display: flex; align-items: center; gap: 7px; min-width: 0 }
+10 -5
View File
@@ -414,7 +414,13 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const submit = useSubmit({ port, running, dispatch, trajDispatch, refreshStatus, fail });
const [inserted, setInserted] = useState<{ n: number; text: string; session?: { id: string; title: string } }>({ n: 0, text: "" });
const { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
// Guidance said from this window stands in the transcript as its own bubble
// until the turn reads it, so the strip leaves it out.
const steerShown = useMemo(
() => new Set(s.items.flatMap((i) => (i.t === "user" && i.pending && i.queued === "steer" && i.itemId ? [i.itemId] : []))),
[s.items],
);
const { queue, restored, onRestoreText, onQueueRetry, onQueuePause, onQueueToSteer, onQueueSendNow, onQueueCancel } = useQueueActions({
port,
dispatch,
fail,
@@ -530,6 +536,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
turnRunning={running}
reply={fileReply}
onResend={onResend}
onWithdraw={onQueueCancel}
items={s.items}
entering={s.entranceOwed}
onEntered={(ids) => dispatch({ kind: "__entered", ids } as never)}
@@ -630,13 +637,11 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<Queue
queue={queue}
running={running}
onRead={onQueueRead}
shown={steerShown}
onSendNow={onQueueSendNow}
onEdit={onQueueEdit}
onMove={onQueueMove}
onToSteer={onQueueToSteer}
onCancel={onQueueCancel}
onRetry={onQueueRetry}
onRefresh={onQueueRefresh}
onPause={onQueuePause}
/>
<PeerHeldBar port={port} project={rt.host ? undefined : { path: rt.root, name: rt.name }} />
+59 -264
View File
@@ -1,56 +1,44 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Meter } from "./Meter";
import type { Queue as QueueSnapshot, QueueItem } from "../port/port";
import { t } from "../i18n";
import { useFitHeight } from "./fitHeight";
import { reason } from "../i18n/kernel";
import { BLOCK_WHY } from "../i18n/queue_why";
import { useSendShortcut } from "../state/prefs";
import { sendAria, sendsOnKey } from "./sendkey";
import { useIme } from "./ime";
import { Overflow } from "./Overflow";
import { StudioIcon } from "./StudioIcon";
import { touchKeyboard } from "./touchKeyboard";
interface Props {
queue: QueueSnapshot | null;
// A turn holding the session is the whole reason these are waiting, and it is
// what "send now" has to interrupt. Without it the action is meaningless: an
// idle queue dispatches on its own.
// A turn holding the session is what these lines wait on, and what "send now"
// has to interrupt. Without it the action is meaningless: an idle queue
// dispatches on its own.
running: boolean;
onRead: (id: string) => Promise<string>;
onEdit: (id: string, text: string) => void;
onMove: (id: string, to: number) => void;
onCancel: (id: string) => void | Promise<void>;
// Entries whose guidance already stands in the transcript as its own bubble.
shown?: ReadonlySet<string>;
onSendNow: (item: QueueItem) => void;
onToSteer: (item: QueueItem) => void;
onCancel: (id: string) => void | Promise<void>;
onRetry: (id: string) => void;
onRefresh: (id: string) => void;
onPause: (paused: boolean) => void;
}
// The kernel has taken the line: it is in the transcript now, and the entry is
// swept only when the whole turn ends. This panel is what has not happened yet,
// swept only when the whole turn ends. This strip is what has not happened yet,
// so a taken entry leaves it rather than standing there inert as a second copy.
const taken = (s: QueueItem["state"]) => s === "steer_consumed" || s === "running";
// "To send" is what the person said and has not sent yet. A host continuation —
// a finished background job asking the session to pick the work back up — is
// not theirs and belongs with the work it came from. It appears here only when
// it needs a decision, which is the one case nobody else can make for them.
// A host continuation — a finished background job asking the session to pick
// the work back up — is not the person's and belongs with the work it came
// from. It appears here only when it needs a decision.
const theirs = (it: QueueItem) => it.origin !== "host" || it.state === "blocked" || it.state === "uncertain";
// Codes whose entry the kernel knows never reached the model, so sending it
// again cannot repeat an effect. Every other uncertain code may have run.
const RETRY_SAFE = new Set(["steer_unapplied"]);
// The kernel edits, reorders and re-freezes only a line nobody has taken yet; a
// line already accepted as guidance can only be withdrawn or sent now.
const editable = (it: QueueItem) => it.state === "queued" || it.state === "blocked" || it.state === "uncertain";
const retryable = (it: QueueItem) =>
it.state === "blocked" || (it.state === "uncertain" && !!it.blockCode && RETRY_SAFE.has(it.blockCode));
// Only plain text can become guidance: a picture or a frozen file reference
// travels with a whole turn.
const steerable = (it: QueueItem) =>
it.origin !== "host" && it.state === "queued" && it.intent === "followup" && !it.images && !it.refs?.length;
/** What the panel draws: the lines nobody has acted on yet. The header's count
* of guidance in flight reads this too, so it can never name a line the panel
/** What the strip draws: the lines nobody has acted on yet. The header's count
* of guidance in flight reads this too, so it can never name a line the strip
* is not showing. */
export const waiting = (queue: QueueSnapshot | null): QueueItem[] =>
queue ? queue.items.filter((it) => !taken(it.state) && theirs(it)) : [];
@@ -58,7 +46,7 @@ export const waiting = (queue: QueueSnapshot | null): QueueItem[] =>
// Each arm calls t() with its own literal: the catalogue is built by reading
// these call sites, and a table of strings looked up later is invisible to it —
// which is how a row would render Chinese inside an English window.
function label(it: QueueItem): string {
function label(it: QueueItem, n: number): string {
if (it.origin === "host") {
if (it.state === "blocked") return t("composer.background_blocked");
if (it.state === "uncertain") return t("composer.background_uncertain");
@@ -72,252 +60,59 @@ function label(it: QueueItem): string {
case "uncertain":
return t("composer.uncertain");
default:
// The kernel's own reason for not delivering mid-turn, read from what the
// entry froze rather than guessed from its text.
if (it.images && it.state === "queued") return t("composer.has_an_image_sent_when");
// A steer the kernel could not admit is converted to a follow-up, so an
// entry still calling itself one is waiting on a held queue, not refused.
return it.intent === "steer" ? t("composer.steer_queued") : t("common.queued");
if (it.intent === "steer") return t("composer.steer_queued");
return `${t("common.queued")} ${n}`;
}
}
function tone(state: QueueItem["state"]): string | undefined {
switch (state) {
case "steer_accepted":
return "accent";
case "blocked":
case "uncertain":
return "warn";
default:
return undefined;
}
}
// Each side of the ratio picks its own unit. The ceiling is 64 MiB and what a
// queue actually holds is a few typed lines, so a shared unit would print every
// real queue as 0 — the bar is what carries the proportion.
function size(n: number): string {
if (n < 1024) return `${n}B`;
if (n < 1 << 20) return `${n < 10240 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}k`;
const m = n / (1 << 20);
return `${m < 10 ? m.toFixed(1) : Math.round(m)}M`;
}
// The list scrolls inside a height `.composeaux` caps, so an editor taller than
// what the list shows is cut off with no way to reach its edge.
function listRoom(el: HTMLTextAreaElement): number {
const list = el.closest(".qitems");
const row = el.closest(".qi");
return list && row ? list.clientHeight - (row.getBoundingClientRect().height - el.offsetHeight) : Infinity;
}
export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSendNow, onRetry, onRefresh, onPause }: Props) {
const [editing, setEditing] = useState("");
const [draft, setDraft] = useState("");
// Which row could not be read back, and why. Not a draft: the editor stays
// shut, because the row's own text is the only thing that may fill it.
const [unread, setUnread] = useState<{ id: string; why: string } | null>(null);
const box = useRef<HTMLTextAreaElement>(null);
const readEpoch = useRef(0);
useFitHeight(box, editing ? draft : "", undefined, listRoom);
useLayoutEffect(() => {
const row = box.current?.closest<HTMLElement>(".qi");
const list = row?.closest<HTMLElement>(".qitems");
if (!row || !list) return;
const over = row.getBoundingClientRect().bottom - list.getBoundingClientRect().bottom;
const under = list.getBoundingClientRect().top - row.getBoundingClientRect().top;
list.scrollTop += under > 0 ? -under : Math.max(0, over);
}, [editing, draft]);
const shortcut = useSendShortcut();
const ime = useIme();
// The body arrives after the click, so focus waits for the field to exist.
useEffect(() => {
if (editing) box.current?.focus();
}, [editing]);
const open = useCallback(
async (id: string) => {
// Opening on the preview would put a cut-off line in the box and save it
// back as the whole instruction.
const epoch = ++readEpoch.current;
setUnread(null);
let body: string;
try {
body = await onRead(id);
} catch (e) {
if (epoch !== readEpoch.current) return;
// A read that failed is not an empty instruction. Filling the box with
// "" would have the user retype a line they never saw, and the save
// replaces the whole entry with it.
setUnread({ id, why: reason(e) });
return;
}
if (epoch !== readEpoch.current) return;
setDraft(body);
setEditing(id);
},
[onRead],
);
const commit = useCallback(() => {
const text = draft.trim();
if (editing && text) onEdit(editing, text);
setEditing("");
}, [draft, editing, onEdit]);
// No pending work means no pending UI. A persisted pause is an internal
// queue policy; drawing a full strip for it above an empty composer makes it
// look like a stuck task. If a new item arrives while held, the strip returns
// with both the item and the action needed to release it.
export function Queue({ queue, running, shown, onSendNow, onToSteer, onCancel, onRetry, onPause }: Props) {
if (!queue) return null;
const items = waiting(queue);
const items = waiting(queue).filter((it) => !shown?.has(it.id));
if (items.length === 0) return null;
const cap = queue.capacity;
const fullItems = cap.maxItems > 0 && cap.items >= cap.maxItems;
const fullBytes = cap.maxBytes > 0 && cap.bytes >= cap.maxBytes;
const capacityPressure = fullItems || fullBytes ||
(cap.maxItems > 0 && cap.items / cap.maxItems >= 0.75) ||
(cap.maxBytes > 0 && cap.bytes / cap.maxBytes >= 0.75);
const ordinary = (it: QueueItem) => it.origin !== "host" && it.state === "queued" && it.intent === "followup";
const sendable = running && !queue.paused;
return (
<div className="queue" role="group" aria-label={t("composer.pending")}>
<div className="qhd">
<span className="lb">{t("common.to_send")}</span>
<span className="qcount" aria-label={t("composer.n_to_send", { n: items.length })}>{items.length}</span>
<span className="qsummary">
{queue.readonly
? t("composer.this_conversation_is_temporarily_read")
: queue.paused
? t("composer.sending_is_paused")
: running
? t("composer.not_delivered_yet_edit_it")
: t("composer.sending")}
</span>
{queue.readonly && <span className="qflag" data-ro="">{t("common.read_only")}</span>}
{/* The kernel pauses itself when it recovers entries: something was
said that no one has since re-confirmed. */}
{!!queue.recoveredCount && <span className="qflag" data-warn="">{t("composer.n_recovered", { n: queue.recoveredCount })}</span>}
{/* Both limits refuse on their own, so a header that showed one of them
would be wrong about the other exactly when it bites. */}
{capacityPressure && <span className="qcap" aria-label={t("composer.queue_capacity")}>
{cap.maxItems > 0 && <span data-full={fullItems || undefined}>
{t("composer.items")} {cap.items}/{cap.maxItems}
<Meter value={cap.items} max={cap.maxItems} label={t("composer.items")} full={fullItems} />
</span>}
<span data-full={fullBytes || undefined}>
{t("composer.bytes")} {size(cap.bytes)}/{size(cap.maxBytes)}
<Meter value={cap.bytes} max={cap.maxBytes} label={t("composer.bytes")} full={fullBytes} />
</span>
</span>}
<button className="qhold" data-action="queue.pause" onClick={() => onPause(!queue.paused)} disabled={queue.readonly}>
{queue.paused ? t("composer.release") : t("composer.hold")}
</button>
</div>
<div className="qitems">
{items.map((it, i) => {
const live = !queue.readonly && editing !== it.id;
const coded = it.blockCode ? BLOCK_WHY[it.blockCode] : undefined;
const why = coded ? t(coded) : it.blockReason;
return (
<div key={it.id} className="qi" data-state={it.state} title={why || undefined}>
{/* The chip is the answer to "did that land". Its wording says
which turn the line goes into; the title says when. */}
{ordinary(it) ? (
<span className="qdot" aria-hidden="true" />
) : (
<span className="pill" data-tone={tone(it.state)} title={it.state === "steer_accepted" ? t("composer.goes_in_at_the_next") : it.origin === "host" ? t("composer.background_job_follow_on") : undefined}>
{label(it)}
</span>
{items.map((it, i) => {
const coded = it.blockCode ? BLOCK_WHY[it.blockCode] : undefined;
const why = coded ? t(coded) : it.blockReason;
const held = it.state === "blocked" || it.state === "uncertain";
return (
<div key={it.id} className="qi" data-state={it.state} title={why || undefined}>
<span className="ql" data-tone={held ? "warn" : undefined} title={it.state === "steer_accepted" ? t("composer.goes_in_at_the_next") : it.origin === "host" ? t("composer.background_job_follow_on") : undefined}>
{label(it, i + 1)}
</span>
<span className="pv">{it.preview}</span>
{held && why && <span className="qwhy" title={why}>{why}</span>}
<span className="qacts">
{queue.paused && i === 0 && !queue.readonly && (
<button className="qb" data-action="queue.pause" onClick={() => onPause(false)}>{t("composer.release")}</button>
)}
{editing === it.id ? (
<textarea
data-action-keydown="queue.edit"
ref={box}
className="qedit"
value={draft}
rows={1}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
{...ime.handlers}
aria-keyshortcuts={sendAria(shortcut, touchKeyboard())}
onKeyDown={(e) => {
if (e.key === "Escape") { e.preventDefault(); setEditing(""); return; }
if (ime.isIme(e.nativeEvent)) {
if (e.key === "Enter") e.preventDefault();
return;
}
if (sendsOnKey(e, shortcut, touchKeyboard())) {
e.preventDefault();
commit();
}
}}
/>
) : (
// 「改」走 onRead 读回整条正文,但已结算的行没有那个按钮。
// 被截断时看得见全文,是每一行都该有的,而不是可编辑的那些。
<Overflow className="pv" text={it.preview} />
)}
{!!it.refs?.length && <span className="rf">{t("composer.n_frozen", { n: it.refs.length })}</span>}
{why && (it.state === "blocked" || it.state === "uncertain") && <span className="qwhy" title={why}>{why}</span>}
{unread?.id === it.id && (
<span className="qwhy" data-err="" role="alert">
{t("composer.could_not_read_this_entry", { why: unread.why })}
</span>
)}
{live && (
<span className="qacts">
{items.length > 1 && editable(it) && <>
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("common.move_up")} aria-label={t("common.move_up")}>
<StudioIcon name="arrow" />
</button>
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("common.move_down")} aria-label={t("common.move_down")}>
<StudioIcon name="arrow" data-flip="" />
</button>
</>}
{editable(it) && (
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("common.edit")} aria-label={t("composer.edit")}>
<StudioIcon name="edit" />
</button>
)}
{/* Waiting is the normal case and needs no button. This is the
other one: the turn in front of this line is not worth
finishing, and nothing else can end it. */}
{running && !queue.paused && (it.state === "queued" || it.state === "steer_accepted") && (
<button
className="qnow"
data-action="queue.send-now"
data-target={it.id}
onClick={() => onSendNow(it)}
title={t("composer.stop_the_current_response_and")}
>
{t("composer.send_now")}
</button>
)}
{retryable(it) && (
<button data-action="queue.retry" data-target={it.id} onClick={() => onRetry(it.id)} title={t("common.retry")}>
{t("common.retry")}
</button>
)}
{/* Only an entry that quoted files has anything to re-freeze. */}
{editable(it) && !!it.refs?.length && (
<button data-action="queue.refresh" data-target={it.id} onClick={() => onRefresh(it.id)} title={t("composer.re_freeze_the_files_it")}>
{t("common.re_freeze")}
</button>
)}
</span>
)}
{live && (
<button className="x" data-action="queue.cancel" data-target={it.id} onClick={() => onCancel(it.id)} title={t("composer.take_it_back")} aria-label={t("composer.take_it_back")}>
<StudioIcon name="rewind" />
{sendable && steerable(it) && (
<button className="qb" data-action="queue.to-steer" data-target={it.id} onClick={() => onToSteer(it)}>
{t("composer.make_it_a_steer")}
</button>
)}
</div>
);
})}
</div>
{sendable && (it.state === "queued" || it.state === "steer_accepted") && (
<button className="qb" data-action="queue.send-now" data-target={it.id} title={t("composer.stop_the_current_response_and")} onClick={() => onSendNow(it)}>
{t("composer.send_now")}
</button>
)}
{retryable(it) && (
<button className="qb" data-action="queue.retry" data-target={it.id} title={t("common.retry")} onClick={() => onRetry(it.id)}>
{t("common.retry")}
</button>
)}
{!queue.readonly && (
<button className="qb" data-action="queue.cancel" data-target={it.id} title={t("composer.take_it_back")} onClick={() => onCancel(it.id)}>
{t("composer.withdraw")}
</button>
)}
</span>
</div>
);
})}
</div>
);
}
+7 -5
View File
@@ -67,8 +67,6 @@ interface Props {
onAnswer: (itemId: string, id: string, answers: { questionId: string; selected: string[]; value?: Record<string, unknown> }[]) => Promise<void>;
onForget: (itemId: string, name: string) => void;
onSettings?: (at?: string) => void;
// Takes a still-queued line back. Only rows the kernel has given an
// item id can offer it, and only until the turn reads them.
onExtInvoke: (name: string) => void;
// Views published against a tool call, keyed by anchor. A card looks itself
// up here rather than being handed one, so an arriving takeover repaints the
@@ -79,6 +77,7 @@ interface Props {
reply?: ReplyActions;
// Rewriting a message: the turn it takes back, and what to send instead.
onResend?: (turn: number, text: string) => Promise<void>;
onWithdraw?: (itemId: string) => void | Promise<void>;
onExtSubmit: (pluginId: string, surfaceId: string, values: Record<string, unknown>) => void | Promise<void>;
// The checkpoint each user card can return to, keyed by item id. Absent for a
// card whose turn could not be matched — see state/checkpoints.
@@ -104,7 +103,7 @@ interface Props {
brought?: { ids: string[]; bring: (id: string) => void; dismiss: (id: string) => void };
}
export function Transcript({ items: every, port, historyPaging, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, onSuggest, foldWork = false, turnRunning = false, scope = "" }: Props) {
export function Transcript({ items: every, port, historyPaging, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, onWithdraw, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, onSuggest, foldWork = false, turnRunning = false, scope = "" }: Props) {
// A block the selection touches must not leave the DOM. Unmounting the node a
// selection is anchored to makes the browser remap that selection onto
// whatever is still mounted — which reads as "I selected up there and the
@@ -472,7 +471,7 @@ export function Transcript({ items: every, port, historyPaging, entering, onEnte
const bring = brought?.bring;
const onBring = useMemo(() => (bring ? (id: string) => { bring(id); resume(); } : undefined), [bring, resume]);
const rowProps = { onBring, owed, echoes, port, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
const rowProps = { onBring, owed, echoes, port, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend, onWithdraw };
const marks = railMarks(blocks, (id) => checkpoints.get(id)?.files ?? 0);
@@ -509,6 +508,7 @@ export function Transcript({ items: every, port, historyPaging, entering, onEnte
/>
))}
{live && <Row it={live} {...rowProps} cp={checkpoints.get(live.id)} />}
{every.filter((it) => it.t === "user" && it.pending && it.queued === "steer").map((it) => <Row key={it.id} it={it} {...rowProps} />)}
{brought && <BroughtResults items={items} ids={brought.ids} onDismiss={brought.dismiss} takeovers={takeovers} onExtInvoke={onExtInvoke} onPrepareFileRevert={onPrepareFileRevert} onCommitFileRevert={onCommitFileRevert} />}
{waiting.ttftSince && <Await since={waiting.ttftSince} retry={waiting.retry} />}
<div ref={end} className="flow-end" aria-hidden="true" />
@@ -626,6 +626,7 @@ interface RowHandlers {
onPrepareFileRevert: Props["onPrepareFileRevert"];
reply: Props["reply"];
onResend: Props["onResend"];
onWithdraw: Props["onWithdraw"];
onCommitFileRevert: Props["onCommitFileRevert"];
}
@@ -701,7 +702,7 @@ const Row = memo(function Row({
onAnswer,
onExtInvoke,
reply,
onResend,
onResend, onWithdraw,
takeovers,
onExtSubmit,
cp,
@@ -731,6 +732,7 @@ const Row = memo(function Row({
port={port}
cp={cp}
onResend={onResend}
onWithdraw={onWithdraw}
onPrepareRewind={onPrepareRewind}
onCommitRewind={onCommitRewind}
/>
@@ -33,6 +33,7 @@ export function UserCard({
port,
cp,
onResend,
onWithdraw,
onPrepareRewind,
onCommitRewind,
}: {
@@ -40,6 +41,7 @@ export function UserCard({
port?: Pick<AgentPort, "workspaceImageURL">;
cp?: Checkpoint;
onResend?: (turn: number, text: string) => Promise<void>;
onWithdraw?: (itemId: string) => void | Promise<void>;
onPrepareRewind?: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
onCommitRewind?: (planId: string, text?: string) => Promise<RewindResult>;
}) {
@@ -78,6 +80,21 @@ export function UserCard({
.finally(() => setSending(false));
};
if (item.pending && item.queued === "steer" && item.itemId && onWithdraw) {
const itemId = item.itemId;
return (
<div className="steerwait" data-pending="">
<div className="sw-hd">
{t("composer.steer_waiting")}
<button type="button" className="sw-undo" data-action="steer.withdraw" data-target={itemId} onClick={() => void onWithdraw(itemId)}>
{t("composer.withdraw")}
</button>
</div>
<div className="sw-txt" dir="auto">{item.text}</div>
</div>
);
}
return (
<div className="call" data-k="me" data-pending={item.pending ? "" : undefined}>
<div className="g">
@@ -144,11 +144,11 @@ describe("the changed-files pill above the input box", () => {
vi.spyOn(pane.port, "steer").mockImplementation((text) => pane.port.queueFollowup(text));
fireEvent.change(box(), { target: { value: "next thing", selectionStart: 10 } });
fireEvent.click(await screen.findByRole("button", { name: "插话" }));
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
pane.send({ kind: "notice", level: "warn", audience: "operator", code: "x.y", text: "运行时提示" });
await screen.findByText("运行时提示");
expect(pill()).toBeTruthy();
expect(screen.getByRole("button", { name: "取回" }).closest(".composeaux")).toBeTruthy();
expect(screen.getByRole("button", { name: "撤回" }).closest(".composeaux")).toBeTruthy();
expect(pill()!.closest(".composeaux, .composenotes")).toBeNull();
expect(document.querySelector(".composenotes")!.contains(screen.getByText("运行时提示"))).toBe(true);
});
@@ -43,11 +43,11 @@ describe("queued submission and withdrawal", () => {
type(BODY);
expect(screen.getByText("Enter 排队 · Shift+Enter 换行")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
expect(followup).toHaveBeenCalledWith(BODY, undefined);
expect(steer).not.toHaveBeenCalled();
expect((await port.queue()).items[0].intent).toBe("followup");
fireEvent.click(screen.getByRole("button", { name: "取回" }));
fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
fireEvent.click(screen.getByRole("radio", { name: "下个工具边界插话" }));
expect(screen.getByRole("button", { name: "插话" })).toBeTruthy();
@@ -64,10 +64,10 @@ describe("queued submission and withdrawal", () => {
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
cleanup();
render(<Pane {...props} port={port} />);
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
expect((await port.queue()).items[0].intent).toBe("followup");
fireEvent.click(screen.getByRole("button", { name: "取回" }));
fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
});
@@ -76,10 +76,10 @@ describe("queued submission and withdrawal", () => {
vi.spyOn(port, "steer").mockImplementation((text) => port.queueFollowup(text));
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "插话" }));
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
type(draft);
fireEvent.click(screen.getByRole("button", { name: "取回" }));
fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(draft && draft !== BODY ? `${draft}\n${BODY}` : BODY));
});
@@ -165,20 +165,20 @@ describe("queued submission and withdrawal", () => {
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "插话" }));
await waitFor(() => expect(box().value).toBe(BODY));
expect(screen.queryByRole("button", { name: "取回" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤回" })).toBeNull();
});
it("pulls the queue after reload without filling the composer", async () => {
const port = new MockPort();
await port.queueFollowup(BODY);
await open(port);
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
cleanup();
render(<Pane {...props} port={port} />);
await screen.findByRole("button", { name: "取回" });
await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
fireEvent.click(screen.getByRole("button", { name: "取回" }));
fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
});
@@ -1,85 +0,0 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockPort } from "../port/mock";
import { HttpError } from "../port/port";
import { Queue } from "./Queue";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const FIRST = "Check the unit tests.";
const SECOND = "Check the packaged application.";
const DRAFT = "Check the packaged application on Windows as well.";
function deferred() {
let resolve!: (body: string) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<string>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function openQueue() {
const port = new MockPort();
await port.setQueuePaused(true);
const stamp = vi.spyOn(Date, "now").mockReturnValueOnce(1).mockReturnValueOnce(2);
const first = await port.queueFollowup(FIRST);
const second = await port.queueFollowup(SECOND);
stamp.mockRestore();
expect(first.itemId).not.toBe(second.itemId);
const reads = new Map([[first.itemId, deferred()], [second.itemId, deferred()]]);
const edit = vi.spyOn(port, "editQueued");
render(<Queue queue={await port.queue()} running
onRead={(id) => reads.get(id)!.promise} onEdit={(id, body) => void port.editQueued(id, body)}
onMove={() => {}} onCancel={() => {}} onSendNow={() => {}}
onRetry={() => {}} onRefresh={() => {}} onPause={() => {}} />);
const choose = async (body: string) => {
const row = screen.getByText(body).closest(".qi")! as HTMLElement;
await userEvent.click(within(row).getByRole("button", { name: "改" }));
};
await choose(FIRST);
await choose(SECOND);
return { port, first, second, earlier: reads.get(first.itemId)!, latest: reads.get(second.itemId)!, edit };
}
const box = () => document.querySelector<HTMLTextAreaElement>(".qedit");
it.each(["body", "refusal"])("keeps the latest queued draft when an earlier read returns a late %s", async (result) => {
const { port, first, second, earlier, latest, edit } = await openQueue();
await act(async () => { latest.resolve(await port.readQueued(second.itemId)); });
expect(box()?.value).toBe(SECOND);
fireEvent.change(box()!, { target: { value: DRAFT } });
await act(async () => {
if (result === "body") earlier.resolve(await port.readQueued(first.itemId));
else earlier.reject(new HttpError(409, "gone", { code: "inbox.not_found" }));
});
expect(box()?.value).toBe(DRAFT);
expect(document.activeElement).toBe(box());
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.keyDown(box()!, { key: "Enter" });
expect(edit).toHaveBeenCalledExactlyOnceWith(second.itemId, DRAFT);
expect(await port.readQueued(first.itemId)).toBe(FIRST);
expect(await port.readQueued(second.itemId)).toBe(DRAFT);
});
it("waits for the latest selected row even when the earlier row answers first", async () => {
const { port, first, second, earlier, latest } = await openQueue();
await act(async () => { earlier.resolve(await port.readQueued(first.itemId)); });
expect(box()).toBeNull();
await act(async () => { latest.resolve(await port.readQueued(second.itemId)); });
expect(box()?.value).toBe(SECOND);
expect(document.activeElement).toBe(box());
});
it("keeps the latest read's refusal after an older body arrives", async () => {
const { port, first, earlier, latest } = await openQueue();
await act(async () => { latest.reject(new HttpError(409, "gone", { code: "inbox.not_found" })); });
const failure = screen.getByRole("alert");
expect(failure.textContent).toContain("该条已不在待送达队列中");
expect(box()).toBeNull();
await act(async () => { earlier.resolve(await port.readQueued(first.itemId)); });
expect(box()).toBeNull();
expect(screen.getByRole("alert")).toBe(failure);
});
@@ -1,80 +0,0 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
afterEach(cleanup);
const BODY = "Check the failing tests after the current task.";
async function openWindow() {
const hub = new MockHub();
const [rt] = await hub.runtimes();
const port = hub.portFor(rt);
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
const status = await port.status();
vi.spyOn(port, "status").mockResolvedValue({ ...status, running: true });
const cancel = vi.spyOn(port, "cancel").mockResolvedValue(undefined);
const edit = vi.spyOn(port, "editQueued");
await port.setQueuePaused(true);
const queued = await port.queueFollowup(BODY);
await act(async () => { render(<App hub={hub} />); });
await screen.findByRole("button", { name: "改" });
await waitFor(() => expect(document.querySelector(".studio-runstate[data-running]")).not.toBeNull());
return { port, queued, cancel, edit };
}
async function openEditor() {
await userEvent.click(screen.getByRole("button", { name: "改" }));
return waitFor(() => {
const box = document.querySelector<HTMLTextAreaElement>(".qedit");
expect(box).not.toBeNull();
expect(document.activeElement).toBe(box);
return box!;
});
}
it("dismisses a queued draft with Escape without stopping the active turn", async () => {
const { port, queued, cancel, edit } = await openWindow();
const box = await openEditor();
expect(box.value).toBe(BODY);
fireEvent.change(box, { target: { value: "An unsaved replacement" } });
await userEvent.keyboard("{Escape}");
expect(document.querySelector(".qedit")).toBeNull();
expect(cancel).not.toHaveBeenCalled();
expect(edit).not.toHaveBeenCalled();
expect(await port.readQueued(queued.itemId)).toBe(BODY);
expect((await port.queue()).items.map((item) => item.id)).toContain(queued.itemId);
const reopened = await openEditor();
expect(reopened.value).toBe(BODY);
await userEvent.keyboard("{Escape}");
expect(cancel).not.toHaveBeenCalled();
const composer = document.querySelector<HTMLTextAreaElement>(".compose textarea")!;
expect(composer).not.toBeNull();
composer.focus();
await userEvent.keyboard("{Escape}");
expect(cancel).not.toHaveBeenCalled();
expect(document.querySelector(".stophint")!.textContent).toBe("再按一次 Esc 停止当前任务");
await userEvent.keyboard("{Escape}");
expect(cancel).toHaveBeenCalledTimes(1);
});
it("still saves a queued draft with Enter without stopping the active turn", async () => {
const { port, queued, cancel, edit } = await openWindow();
const box = await openEditor();
const replacement = "Check the build as well.";
fireEvent.change(box, { target: { value: replacement } });
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(document.querySelector(".qedit")).toBeNull());
expect(edit).toHaveBeenCalledExactlyOnceWith(queued.itemId, replacement);
expect(await port.readQueued(queued.itemId)).toBe(replacement);
expect(cancel).not.toHaveBeenCalled();
});
@@ -16,7 +16,7 @@ function rows(items: QueueItem[]) {
const queue: QueueSnapshot = { revision: 1, paused: false, items, capacity: { items: items.length, maxItems: 64, bytes: 10, maxBytes: 64 << 20 } };
const noop = () => {};
const { container } = render(
<Queue queue={queue} running onRead={async () => ""} onEdit={noop} onMove={noop} onSendNow={noop} onCancel={noop} onRetry={noop} onRefresh={noop} onPause={noop} />,
<Queue queue={queue} running onSendNow={noop} onToSteer={noop} onCancel={noop} onRetry={noop} onPause={noop} />,
);
return container;
}
@@ -51,9 +51,8 @@ function draw(port: MockPort) {
}, [port]);
const actions = useQueueActions({ port: port as unknown as AgentPort, dispatch, fail, moved });
return <Queue queue={actions.queue} running
onRead={actions.onQueueRead} onEdit={actions.onQueueEdit} onMove={actions.onQueueMove}
onCancel={actions.onQueueCancel} onSendNow={actions.onQueueSendNow}
onRetry={actions.onQueueRetry} onRefresh={actions.onQueueRefresh} onPause={actions.onQueuePause} />;
onCancel={actions.onQueueCancel} onSendNow={actions.onQueueSendNow} onToSteer={actions.onQueueToSteer}
onRetry={actions.onQueueRetry} onPause={actions.onQueuePause} />;
}
render(<Panel />);
return { beforeCancel, dispatch, fail };
@@ -129,3 +128,33 @@ describe("sending the selected queue entry now", () => {
expect(beforeCancel).toEqual([]);
});
});
describe("making a queued follow-up guidance", () => {
it("takes the line back and says it again as a steer, whole", async () => {
const port = await queued();
// Lines held while the queue was paused have no timer for the fixture's
// cancel to clear, so it refuses them; the kernel lets a held line go.
vi.spyOn(port, "cancelQueued").mockImplementation(async (id: string) => {
(port as unknown as { dropQueued: (id: string) => void }).dropQueued(id);
});
const { dispatch, fail } = draw(port);
await userEvent.click((await screen.findAllByRole("button", { name: "改为插话" }))[1]);
await waitFor(() => expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "steer" })));
const snapshot = await port.queue();
const bodies = await Promise.all(snapshot.items.map((item) => port.readQueued(item.id)));
expect(bodies.filter((b) => b === BODIES[1])).toHaveLength(1);
expect(snapshot.items.find((item) => item.preview.startsWith(BODIES[1]))?.intent).toBe("steer");
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__unsent" }));
expect(fail).not.toHaveBeenCalled();
});
it("keeps the line queued when it cannot be read back", async () => {
const port = await queued();
const error = new Error("read failed");
vi.spyOn(port, "readQueued").mockRejectedValue(error);
const { fail } = draw(port);
await userEvent.click((await screen.findAllByRole("button", { name: "改为插话" }))[0]);
await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
expect((await port.queue()).items).toHaveLength(3);
});
});
@@ -1,24 +1,18 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Queue } from "./Queue";
import { HttpError } from "../port/port";
import type { Queue as QueueSnapshot, QueueItem } from "../port/port";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
// The preview is cut at 120 runes, so the box has to be filled from the read
// and never from the row: saving the row back files a truncated line as the
// whole instruction.
const BODY = "把 gate_test.go 里那三个跳过的用例打开,确认它们真的在 CI 里跑到了,再把 README 里说它们被跳过的那段删掉。";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const item = (over: Partial<QueueItem> = {}): QueueItem => ({
id: "i1",
intent: "followup",
state: "queued",
preview: BODY.slice(0, 20),
preview: "把 gate_test.go 里那三个跳过的用例打开",
createdAt: "2026-08-25T10:00:00Z",
...over,
});
@@ -31,146 +25,52 @@ const snapshot = (over: Partial<QueueSnapshot> = {}): QueueSnapshot => ({
...over,
});
function draw(onRead: (id: string) => Promise<string>) {
const onEdit = vi.fn();
render(
<Queue
queue={snapshot()}
running
onRead={onRead}
onEdit={onEdit}
onMove={() => {}}
onSendNow={() => {}}
onCancel={() => {}}
onRetry={() => {}}
onRefresh={() => {}}
onPause={() => {}}
/>,
);
return { onEdit, edit: () => userEvent.click(screen.getByRole("button", { name: "改" })) };
function draw(queue: QueueSnapshot, over: Partial<Parameters<typeof Queue>[0]> = {}) {
const on = { onSendNow: vi.fn(), onToSteer: vi.fn(), onCancel: vi.fn(), onRetry: vi.fn(), onPause: vi.fn() };
render(<Queue queue={queue} running {...on} {...over} />);
return on;
}
const box = () => document.querySelector<HTMLTextAreaElement>(".qedit");
describe("editing a pending entry", () => {
// The arm the browser guard cannot reach: it needs an inbox_changed to land
// in the panel, and the bench owns the subscription the fixture emits into.
it("opens on the whole instruction, not on the row's preview", async () => {
const { edit } = draw(async () => BODY);
await edit();
expect(box()?.value).toBe(BODY);
describe("the actions on a queued line", () => {
it("hands the entry to the steer conversion", async () => {
const on = draw(snapshot());
await userEvent.click(screen.getByRole("button", { name: "改为插话" }));
expect(on.onToSteer).toHaveBeenCalledWith(expect.objectContaining({ id: "i1" }));
});
it("does not open an editor when the body cannot be read", async () => {
const { onEdit, edit } = draw(async () => {
throw new HttpError(409, "no such entry", { code: "inbox.not_found" });
});
await edit();
expect(box()).toBeNull();
// The row keeps its own text. Anything typed into a blank box would have
// replaced, in full, a line the reader never saw.
expect(screen.getByText(item().preview)).toBeTruthy();
expect(onEdit).not.toHaveBeenCalled();
it("withdraws exactly that entry", async () => {
const on = draw(snapshot({ items: [item({ id: "a" }), item({ id: "b" })] }));
await userEvent.click(screen.getAllByRole("button", { name: "撤回" })[1]);
expect(on.onCancel).toHaveBeenCalledWith("b");
});
// The refusal is the kernel's identity said in this window's language, not
// the English that rode along for the log.
it("says why, in the reader's language", async () => {
const { edit } = draw(async () => {
throw new HttpError(409, "no such entry", { code: "inbox.not_found" });
});
await edit();
const why = document.querySelector(".qwhy[data-err]");
expect(why?.textContent).toContain("该条已不在待送达队列中");
expect(why?.textContent).not.toContain("no such entry");
it("sends exactly that entry now", async () => {
const on = draw(snapshot());
await userEvent.click(screen.getByRole("button", { name: "立即发送" }));
expect(on.onSendNow).toHaveBeenCalledWith(expect.objectContaining({ id: "i1" }));
});
// A failure that outlives its click reads as the next one's answer.
it("clears the last failure when the read succeeds", async () => {
let fail = true;
const { edit } = draw(async () => {
if (fail) throw new HttpError(409, "gone", { code: "inbox.not_found" });
return BODY;
});
await edit();
expect(document.querySelector(".qwhy[data-err]")).toBeTruthy();
fail = false;
await edit();
expect(document.querySelector(".qwhy[data-err]")).toBeNull();
expect(box()?.value).toBe(BODY);
it("offers no conversion or send-now with no turn in the way", () => {
draw(snapshot(), { running: false });
expect(screen.queryByRole("button", { name: "改为插话" })).toBeNull();
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
expect(screen.getByRole("button", { name: "撤回" })).toBeTruthy();
});
it("releases a held queue", async () => {
const on = draw(snapshot({ paused: true }));
await userEvent.click(screen.getByRole("button", { name: "继续派发" }));
expect(on.onPause).toHaveBeenCalledWith(false);
});
});
describe("retrying a held entry", () => {
it("asks the kernel to retry exactly that entry", async () => {
const onRetry = vi.fn();
render(
<Queue
queue={snapshot({ paused: true, items: [item({ id: "u1", state: "uncertain", blockCode: "steer_unapplied" })] })}
running={false}
onRead={async () => BODY}
onEdit={() => {}}
onMove={() => {}}
onSendNow={() => {}}
onCancel={() => {}}
onRetry={onRetry}
onRefresh={() => {}}
onPause={() => {}}
/>,
const on = draw(
snapshot({ paused: true, items: [item({ id: "u1", state: "uncertain", blockCode: "steer_unapplied" })] }),
{ running: false },
);
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(onRetry).toHaveBeenCalledWith("u1");
expect(on.onRetry).toHaveBeenCalledWith("u1");
});
});
describe("queue send shortcut", () => {
it.each([false, true])("preserves Escape propagation when abandoning an edit (composing=%s)", async (composing) => {
const key = vi.fn();
window.addEventListener("keydown", key);
try {
const { edit, onEdit } = draw(async () => BODY);
await edit();
if (composing) fireEvent.compositionStart(box()!);
expect(fireEvent.keyDown(box()!, { key: "Escape" })).toBe(false);
expect(key).toHaveBeenCalledTimes(1);
expect(box()).toBeNull();
expect(onEdit).not.toHaveBeenCalled();
} finally { window.removeEventListener("keydown", key); }
});
it("keeps Enter for a newline and commits with Control+Enter", async () => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
const { edit, onEdit } = draw(async () => BODY);
await edit();
fireEvent.keyDown(box()!, { key: "Enter" });
expect(onEdit).not.toHaveBeenCalled();
expect(box()).not.toBeNull();
fireEvent.keyDown(box()!, { key: "Enter", ctrlKey: true });
expect(onEdit).toHaveBeenCalledWith("i1", BODY);
expect(box()).toBeNull();
});
it("does not commit during or just after IME composition", async () => {
const { edit, onEdit } = draw(async () => BODY);
await edit();
fireEvent.compositionStart(box()!);
fireEvent.keyDown(box()!, { key: "Enter", isComposing: true });
fireEvent.compositionEnd(box()!);
fireEvent.keyDown(box()!, { key: "Enter" });
expect(onEdit).not.toHaveBeenCalled();
expect(box()).not.toBeNull();
});
});
it.each(["enter", "modifier_enter"])("keeps touch Enter in the queue editor (%s)", async (mode) => {
const media = window.matchMedia("(pointer: coarse)");
vi.spyOn(window, "matchMedia").mockReturnValue({ ...media, matches: true });
localStorage.setItem("rx-send-shortcut", mode);
const { edit, onEdit } = draw(async () => BODY);
await edit();
fireEvent.keyDown(box()!, { key: "Enter", code: "Enter" });
expect(onEdit).not.toHaveBeenCalled();
expect(box()).not.toBeNull();
if (mode === "enter") expect(box()?.hasAttribute("aria-keyshortcuts")).toBe(false);
if (mode === "modifier_enter") fireEvent.keyDown(box()!, { key: "Enter", code: "Enter", ctrlKey: true });
else fireEvent.blur(box()!);
expect(onEdit).toHaveBeenCalledWith("i1", BODY);
});
+34 -49
View File
@@ -26,18 +26,16 @@ const snapshot = (over: Partial<QueueSnapshot> = {}): QueueSnapshot => ({
...over,
});
const paint = (q: QueueSnapshot, running: boolean) =>
const paint = (q: QueueSnapshot, running: boolean, shown?: ReadonlySet<string>) =>
renderToStaticMarkup(
<Queue
queue={q}
running={running}
onRead={async () => ""}
onEdit={() => {}}
onMove={() => {}}
shown={shown}
onSendNow={() => {}}
onToSteer={() => {}}
onCancel={() => {}}
onRetry={() => {}}
onRefresh={() => {}}
onPause={() => {}}
/>,
);
@@ -45,28 +43,43 @@ const paint = (q: QueueSnapshot, running: boolean) =>
const draw = (q: QueueSnapshot) => paint(q, true);
const drawIdle = (q: QueueSnapshot) => paint(q, false);
describe("compact queue presentation", () => {
it("keeps action identities and order when edit and withdraw become icons", () => {
const html = draw(snapshot({ items: [item({ state: "queued" }), item({ id: "i2", state: "queued" })] }));
expect(html).toContain('title="编辑" aria-label="改"');
expect(html).toContain('title="取回" aria-label="取回"');
describe("queue strip", () => {
it("draws one pill row per entry with the design's three actions in order", () => {
const html = draw(snapshot({ items: [item({ state: "queued", intent: "followup" }), item({ id: "i2", state: "queued", intent: "followup" })] }));
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
expect(actions).toEqual(["queue.pause", "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel",
"queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel"]);
expect(actions).toEqual(["queue.to-steer", "queue.send-now", "queue.cancel", "queue.to-steer", "queue.send-now", "queue.cancel"]);
expect(html).toContain("改为插话");
expect(html).toContain("立即发送");
expect(html).toContain("撤回");
expect(html).not.toContain("暂停派发");
expect(html).not.toContain("draggable");
});
it("offers only send-now and withdraw on guidance the turn has already accepted", () => {
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
expect(actions).toEqual(["queue.pause", "queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
expect(actions).toEqual(["queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
});
it("says paused once and preserves the resume control", () => {
it("numbers a follow-up by its place in the strip", () => {
const html = draw(snapshot({ items: [item({ state: "queued", intent: "followup" }), item({ id: "i2", state: "queued", intent: "followup" })] }));
expect(html).toContain("排队 1");
expect(html).toContain("排队 2");
});
it("offers no steer conversion on a line that carries a picture or frozen files", () => {
expect(draw(snapshot({ items: [item({ state: "queued", intent: "followup", images: true })] }))).not.toContain("queue.to-steer");
expect(draw(snapshot({ items: [item({ state: "queued", intent: "followup", refs: [{ path: "a.go" }] })] }))).not.toContain("queue.to-steer");
});
it("leaves out guidance the transcript already shows as its own bubble", () => {
expect(paint(snapshot(), true, new Set(["i1"]))).toBe("");
});
it("keeps the release control when the queue is held", () => {
const html = draw(snapshot({ paused: true }));
expect(html).toContain("发送已暂停");
expect(html).not.toContain('data-hold');
expect(html).toContain("继续派发");
expect(html).not.toContain("queue.send-now");
});
it("keeps long reasons in titles and limits inline reasons to held entries", () => {
@@ -152,19 +165,18 @@ describe("what the pending strip says back", () => {
});
it("names an accepted steer as taken, not as waiting", () => {
const html = draw(snapshot());
expect(html).toContain("插话已收");
expect(html).toContain('data-tone="accent"');
expect(draw(snapshot())).toContain("插话已收");
});
it("keeps a queued follow-up apart from a steer the kernel took", () => {
it("says a queued follow-up is queued, not accepted", () => {
const html = draw(snapshot({ items: [item({ intent: "followup", state: "queued" })] }));
expect(html).toContain("尚未送达,可以改,也可以取回");
expect(html).toContain("排队 1");
expect(html).not.toContain("插话已收");
});
it("offers taking it back while that is still true", () => {
expect(draw(snapshot())).toContain("取回");
expect(draw(snapshot())).toContain("撤回");
});
// Past the tool boundary the text is the model's, and the kernel refuses the
@@ -187,39 +199,12 @@ describe("what the pending strip says back", () => {
expect(drawIdle(snapshot())).not.toContain("立即发送");
});
it("keeps technical capacity out of the ordinary one-line queue", () => {
const html = draw(snapshot());
expect(html).not.toContain("1/64");
expect(html).not.toContain("3.1k/64M");
expect(html).toContain("尚未送达,可以改,也可以取回");
});
// Capacity becomes useful when it needs attention rather than occupying the
// primary layout for every one-line follow-up.
it("reveals both capacity limits when either is under pressure", () => {
const html = draw(snapshot({ capacity: { items: 49, maxItems: 64, bytes: 3174, maxBytes: 64 << 20 } }));
expect(html).toContain("49/64");
expect(html).toContain("3.1k/64M");
});
it("draws each capacity limit as a labelled meter with its own value and maximum", () => {
const html = draw(snapshot({ capacity: { items: 64, maxItems: 64, bytes: 3174, maxBytes: 64 << 20 } }));
expect(html).toMatch(/role="meter" aria-label="条目" aria-valuemin="0" aria-valuemax="64" aria-valuenow="64" data-full="true"/);
expect(html).toMatch(/role="meter" aria-label="字节" aria-valuemin="0" aria-valuemax="67108864" aria-valuenow="3174"/);
expect(html).not.toMatch(/aria-label="字节"[^>]*data-full/);
});
it("draws no item meter when the queue has no item limit, only the byte one under pressure", () => {
const html = draw(snapshot({ capacity: { items: 300, maxItems: 0, bytes: 60 << 20, maxBytes: 64 << 20 } }));
expect(html).not.toContain("300/0");
expect(html).not.toMatch(/aria-label="条目" aria-valuemin/);
expect(html).toMatch(/role="meter" aria-label="字节"/);
});
it("shows no capacity strip for a long unlimited queue with room in the bytes", () => {
const html = draw(snapshot({ capacity: { items: 300, maxItems: 0, bytes: 3174, maxBytes: 64 << 20 } }));
expect(html).not.toContain('class="qcap"');
});
it("renders nothing when there is nothing waiting and the queue is not held", () => {
expect(draw(snapshot({ items: [], capacity: { items: 0, maxItems: 64, bytes: 0, maxBytes: 64 << 20 } }))).toBe("");
+26 -7
View File
@@ -13,8 +13,8 @@ interface Inputs {
sessionPath?: string;
}
/** The outbox as the kernel holds it, and the eight things that can be done to
* a line still in it. Nothing here patches the snapshot: every edit is asked
/** The outbox as the kernel holds it, and what can be done to a line still in
* it: send it now, make it guidance, take it back, retry it. Nothing here patches the snapshot: every edit is asked
* of the kernel and the answer is read back whole, which is also what puts
* another window's lines in front of this one. */
export function useQueueActions({ port, dispatch, fail, moved, sessionState = "settled", sessionPath }: Inputs) {
@@ -40,12 +40,31 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionState = "s
}, [port, moved, sessionState, sessionPath]);
const taking = useRef(new Set<string>());
const onQueueEdit = useCallback((id: string, text: string) => void port.editQueued(id, text).catch(fail), [port, fail]);
const onQueueMove = useCallback((id: string, to: number) => void port.moveQueued(id, to).catch(fail), [port, fail]);
const onQueueRetry = useCallback((id: string) => void port.retryQueued(id).catch(fail), [port, fail]);
const onQueueRefresh = useCallback((id: string) => void port.refreshQueued(id).catch(fail), [port, fail]);
const onQueuePause = useCallback((on: boolean) => void port.setQueuePaused(on).catch(fail), [port, fail]);
const onQueueRead = useCallback((id: string) => port.readQueued(id), [port]);
// A follow-up becomes guidance by being taken back and said again as a steer;
// the steer the kernel cannot admit is converted to a follow-up, so the line
// is never lost. The body is read before the entry is given up.
const onQueueToSteer = useCallback(
async (item: QueueItem) => {
if (taking.current.has(item.id)) return;
taking.current.add(item.id);
try {
const text = await port.readQueued(item.id);
await port.cancelQueued(item.id);
dispatch({ kind: "__unsent", id: item.id } as never);
const id = localId();
dispatch({ kind: "__user", text, pending: true, id } as never);
const said = await port.steer(text);
if (said.itemId) dispatch({ kind: "__queued", id, itemId: said.itemId, queued: said.paused || said.disposition === "queued_followup" ? "followup" : "steer", paused: said.paused } as never);
} catch (e) {
fail(e);
} finally {
taking.current.delete(item.id);
}
},
[port, fail, dispatch],
);
// Cancellation lets the dispatcher take the queue head. Accepted guidance
// must leave the active turn and become a follow-up before that turn ends.
const onQueueSendNow = useCallback(
@@ -90,5 +109,5 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionState = "s
},
[port, fail, dispatch, onRestoreText],
);
return { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel };
return { queue, restored, onRestoreText, onQueueRetry, onQueuePause, onQueueToSteer, onQueueSendNow, onQueueCancel };
}
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import "./testkit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Item } from "../state/session";
import { UserCard } from "./cards/UserCard";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const row = (over: Partial<Extract<Item, { t: "user" }>> = {}) =>
({ t: "user", id: "row", text: "测试也要覆盖带 BOM 的文件", pending: true, queued: "steer", itemId: "i1", ...over }) as Extract<Item, { t: "user" }>;
describe("guidance said into a running turn, not read yet", () => {
it("stands at the end of the conversation as a dashed bubble that says it is waiting", () => {
render(<UserCard item={row()} onWithdraw={() => {}} />);
expect(screen.getByText("插话 · 等待插入")).toBeTruthy();
expect(screen.getByText("测试也要覆盖带 BOM 的文件").className).toContain("sw-txt");
expect(document.querySelector(".steerwait")).toBeTruthy();
});
it("withdraws the entry the kernel named", async () => {
const onWithdraw = vi.fn();
render(<UserCard item={row()} onWithdraw={onWithdraw} />);
await userEvent.click(screen.getByRole("button", { name: "撤回" }));
expect(onWithdraw).toHaveBeenCalledWith("i1");
});
it("turns into an ordinary guidance row once the turn has read it", () => {
render(<UserCard item={row({ pending: false, steer: true })} onWithdraw={() => {}} />);
expect(document.querySelector(".steerwait")).toBeNull();
expect(screen.getByText("插话")).toBeTruthy();
});
it("keeps a queued follow-up on the ordinary pending row", () => {
render(<UserCard item={row({ queued: "followup" })} onWithdraw={() => {}} />);
expect(document.querySelector(".steerwait")).toBeNull();
});
it("draws the ordinary row when nothing can withdraw it", () => {
render(<UserCard item={row()} />);
expect(document.querySelector(".steerwait")).toBeNull();
});
});
@@ -4,9 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Item } from "../state/session";
import type { Checkpoint, Queue as QueueSnapshot } from "../port/port";
import type { Checkpoint } from "../port/port";
import { UserCard } from "./cards/UserCard";
import { Queue } from "./Queue";
afterEach(() => {
cleanup();
@@ -36,34 +35,6 @@ async function openRewrite() {
return { resend, area: screen.getByRole("textbox") as HTMLTextAreaElement };
}
const queue: QueueSnapshot = {
revision: 1,
paused: false,
items: [{ id: "i1", intent: "followup", state: "queued", preview: "first", createdAt: "2026-08-25T10:00:00Z" }],
capacity: { items: 1, maxItems: 64, bytes: 5, maxBytes: 64 << 20 },
};
async function openQueueEditor() {
const onEdit = vi.fn();
render(
<Queue
queue={queue}
running
onRead={async () => "first"}
onEdit={onEdit}
onMove={() => {}}
onSendNow={() => {}}
onCancel={() => {}}
onRetry={() => {}}
onRefresh={() => {}}
onPause={() => {}}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "改" }));
const area = (await screen.findByRole("textbox")) as HTMLTextAreaElement;
return { onEdit, area };
}
describe("rewrite box", () => {
it("Enter resends with a hardware keyboard", async () => {
pointer(false);
@@ -128,50 +99,3 @@ describe("rewrite box", () => {
expect(resend).not.toHaveBeenCalled();
});
});
describe("queue editor", () => {
it("Enter commits with a hardware keyboard", async () => {
pointer(false);
const { onEdit, area } = await openQueueEditor();
await userEvent.type(area, " more{Enter}");
expect(onEdit).toHaveBeenCalledWith("i1", "first more");
});
it("Shift+Enter is a newline with a hardware keyboard", async () => {
pointer(false);
const { onEdit, area } = await openQueueEditor();
await userEvent.type(area, "{Shift>}{Enter}{/Shift}more");
expect(onEdit).not.toHaveBeenCalled();
expect(area.value).toBe("first\nmore");
});
it("Enter is a newline on a touch device", async () => {
pointer(true);
const { onEdit, area } = await openQueueEditor();
await userEvent.type(area, "{Enter}more");
expect(onEdit).not.toHaveBeenCalled();
expect(area.value).toBe("first\nmore");
});
it("leaving the box still commits the multi-line draft on a touch device", async () => {
pointer(true);
const { onEdit, area } = await openQueueEditor();
await userEvent.type(area, "{Enter}more");
fireEvent.blur(area);
expect(onEdit).toHaveBeenCalledWith("i1", "first\nmore");
});
it.each([false, true])("Enter during IME composition never commits (touch=%s)", async (coarse) => {
pointer(coarse);
const { onEdit, area } = await openQueueEditor();
fireEvent.keyDown(area, { key: "Enter", isComposing: true });
expect(onEdit).not.toHaveBeenCalled();
});
it.each([false, true])("Escape closes the editor (touch=%s)", async (coarse) => {
pointer(coarse);
const { area } = await openQueueEditor();
await userEvent.type(area, "{Escape}");
expect(screen.queryByRole("textbox")).toBeNull();
});
});