From 195e048b551b7d3392b3a3bb05a78983d0e91b0c Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 01:09:14 -0800 Subject: [PATCH] =?UTF-8?q?feat(studio):=20steer=20and=20queued=20lines=20?= =?UTF-8?q?follow=20the=20composer=20board=20=E2=80=94=20a=20waiting=20ste?= =?UTF-8?q?er=20is=20a=20dashed=20bubble=20at=20the=20end=20of=20the=20con?= =?UTF-8?q?versation,=20a=20queued=20line=20is=20one=20pill=20row=20with?= =?UTF-8?q?=20make-steer,=20send-now=20and=20withdraw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/frontend-next/src/design/queue.css | 20 +- .../frontend-next/src/i18n/msg/composer.ts | 3 + desktop/frontend-next/src/styles/app.css | 2 +- desktop/frontend-next/src/ui/Pane.tsx | 15 +- desktop/frontend-next/src/ui/Queue.tsx | 323 ++++-------------- desktop/frontend-next/src/ui/Transcript.tsx | 12 +- .../frontend-next/src/ui/cards/UserCard.tsx | 17 + .../changed-files-pill.interaction.test.tsx | 4 +- .../ui/queue-composer.interaction.test.tsx | 20 +- ...queue-edit-read-order.interaction.test.tsx | 85 ----- .../src/ui/queue-escape.interaction.test.tsx | 80 ----- .../queue-image-reason.interaction.test.tsx | 2 +- .../ui/queue-send-now.interaction.test.tsx | 35 +- .../src/ui/queue.interaction.test.tsx | 170 ++------- desktop/frontend-next/src/ui/queue.test.tsx | 83 ++--- desktop/frontend-next/src/ui/queueactions.ts | 33 +- .../src/ui/steer-wait.interaction.test.tsx | 44 +++ .../src/ui/touch-enter.interaction.test.tsx | 78 +---- 18 files changed, 300 insertions(+), 726 deletions(-) delete mode 100644 desktop/frontend-next/src/ui/queue-edit-read-order.interaction.test.tsx delete mode 100644 desktop/frontend-next/src/ui/queue-escape.interaction.test.tsx create mode 100644 desktop/frontend-next/src/ui/steer-wait.interaction.test.tsx diff --git a/desktop/frontend-next/src/design/queue.css b/desktop/frontend-next/src/design/queue.css index 909ccdcef6..d613078a51 100644 --- a/desktop/frontend-next/src/design/queue.css +++ b/desktop/frontend-next/src/design/queue.css @@ -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; } diff --git a/desktop/frontend-next/src/i18n/msg/composer.ts b/desktop/frontend-next/src/i18n/msg/composer.ts index 6eda87ac24..17a6b01bd4 100644 --- a/desktop/frontend-next/src/i18n/msg/composer.ts +++ b/desktop/frontend-next/src/i18n/msg/composer.ts @@ -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…" }, }; diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 1702827849..6d7c6fcb46 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -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 } diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx index fb216b98fd..5d0d7e14f6 100644 --- a/desktop/frontend-next/src/ui/Pane.tsx +++ b/desktop/frontend-next/src/ui/Pane.tsx @@ -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 diff --git a/desktop/frontend-next/src/ui/Queue.tsx b/desktop/frontend-next/src/ui/Queue.tsx index 0d27e686cf..ecb3390200 100644 --- a/desktop/frontend-next/src/ui/Queue.tsx +++ b/desktop/frontend-next/src/ui/Queue.tsx @@ -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; - onEdit: (id: string, text: string) => void; - onMove: (id: string, to: number) => void; - onCancel: (id: string) => void | Promise; + // Entries whose guidance already stands in the transcript as its own bubble. + shown?: ReadonlySet; onSendNow: (item: QueueItem) => void; + onToSteer: (item: QueueItem) => void; + onCancel: (id: string) => void | Promise; 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(null); - const readEpoch = useRef(0); - useFitHeight(box, editing ? draft : "", undefined, listRoom); - useLayoutEffect(() => { - const row = box.current?.closest(".qi"); - const list = row?.closest(".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 (
-
- {t("common.to_send")} - {items.length} - - {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")} - - {queue.readonly && {t("common.read_only")}} - {/* The kernel pauses itself when it recovers entries: something was - said that no one has since re-confirmed. */} - {!!queue.recoveredCount && {t("composer.n_recovered", { n: queue.recoveredCount })}} - {/* 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 && - {cap.maxItems > 0 && - {t("composer.items")} {cap.items}/{cap.maxItems} - - } - - {t("composer.bytes")} {size(cap.bytes)}/{size(cap.maxBytes)} - - - } - -
- -
- {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 ( -
- {/* The chip is the answer to "did that land". Its wording says - which turn the line goes into; the title says when. */} - {ordinary(it) ? ( -