mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
b1106b98f3
commit
195e048b55
18 files changed
+300
-726
No files matched your search
@@ -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…" },
|
||||
};
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user