feat(studio): the composer owns how the next turn runs

Three values decide how a turn executes, and they were kept in two places.
The preset sat in the title bar; effort and tool approval sat under the
box. Someone writing the next message had to look at opposite ends of the
window to know what would happen when they pressed send, and neither end
said the other existed.

They are one question, so they are one surface. The composer's shelf now
reads: attach, model, plan, and the three current values themselves —
均衡 · high · 询问 — which open one panel holding all three. Not the word
"settings": a summary that does not say what is set answers nothing, and
the shelf is where the question is asked.

Model and plan stay outside it. The model is the most-changed and most
recognisable choice on the shelf, and plan states an intent for this turn
rather than a preference that persists.

The settings sheet keeps its full version of all three and is not a second
owner: it is the complete, explained surface, and the composer is the
turn-local projection of the same canonical state.

No identity was invented for the move. The preset keeps chrome.preset,
which the settings sheet has always rendered too — where a control is
drawn was never its identity. Effort and approval reuse reasoning.effort
and tool-approval.mode, which that sheet already owned. One new id, for
opening the panel.

Three fields drawn together must not become one call. The kernel has three
effects and the panel makes three, each with its own pending value and its
own refusal, so a rung this endpoint will not take says so under the
ladder it belongs to and leaves the preset that did land alone. The shelf
itself stops greying as a whole for the same reason: "nothing here can be
changed" was never true, and only the control actually waiting is marked.

Seg is the second interaction primitive after Switch — a group whose call
site owns the action, with data-* passed through to each button and the
answer on data-value. Its handler is called onClick rather than onPick
because the action census places a primitive's call site by the DOM event
its handler stands for, and a prop the DOM has no event for makes that
call site invisible. That is not hypothetical: every Picker in the tree is
uncounted today for exactly this reason, which is a gap worth its own
look. The primitive has to be declared twice, in the vitest guard and in
tools/ui-census, because those two keep separate lists of the same fact —
also worth its own look, and not widened here for anything but a real
primitive.

Three things the move orphaned are removed rather than left: the menu
item's meter, and the level bars and gate ring the two pickers drew with.
Nothing produces them now, and a zero-consumer field is the shape this
tree keeps deleting.

Verified rather than asserted: the interaction proof that covered the
preset in the chrome moves with it and grows the two guarantees the old
placement could not have — that one field waiting leaves the others live,
and that picking one calls one setter and not the others. Seal A of the
action census returns to held; it broke at 1 while Seg was undeclared, and
Seal B is unchanged at 97.
This commit is contained in:
esengine committed 2026-09-08 02:09:57 -07:00
1 parent 38c1477679
commit 49b574687c
13 files changed
+415 -326

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ const resolve = (from: string, spec: string) => {
};
// The controls whose own internals are not entry points: a person acts on the
// call site that hands them a handler.
const PRIMITIVES = new Set(["src/ui/Switch.tsx", "src/ui/Menu.tsx"]);
const PRIMITIVES = new Set(["src/ui/Switch.tsx", "src/ui/Menu.tsx", "src/ui/Seg.tsx"]);
const census = certifyRoots(sources, { skip: (p) => PRIMITIVES.has(p), resolve });
const registered = new Set(ACTIONS.map((a) => a.id));
+7 -1
View File
@@ -48,8 +48,14 @@ export interface UIAction {
}
export const ACTIONS: UIAction[] = [
// ── The chrome: identity, and the two switches always on screen ──────────
// ── The window's own switches, and the way into the turn's policy ────────
// chrome.preset keeps the id it was registered under when the preset still
// lived in the title bar: where a control is drawn is not its identity,
// which is why the settings sheet has always rendered it too. The composer's
// panel renders it beside reasoning.effort and tool-approval.mode, which the
// settings sheet already owned — one question, three ids that already exist.
{ id: "chrome.preset", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.focus", kind: "view", target: "none", proof: "browser" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.theme", kind: "view", target: "none", proof: "browser" },
+6 -2
View File
@@ -195,8 +195,12 @@ export const EN: Record<string, string> = {
"发送": "Send",
"描述一个任务,回车发送… / 调用命令与技能,@ 引用文件": "Describe a task and press Enter… / for commands and skills, @ to reference files",
"知道了": "Got it",
"强度": "Effort",
"批准": "Approvals",
// 输入框:这一轮怎么跑
"本轮执行策略": "How this turn runs",
"这一轮怎么跑:执行方式、思考强度、工具权限": "How this turn runs: execution, effort, tool permissions",
"执行方式": "Execution",
"思考强度": "Effort",
"工具权限": "Tool permissions",
// 输入框:拖进来与粘贴
"松手 → 附上 {n} 张图": "Release to attach {n} images",
"松手 → 附上 {a} 张图,引用 {r} 个文件": "Release to attach {a} images and reference {r} files",
+29 -69
View File
@@ -195,7 +195,6 @@
[data-titlebar="app"] .chrome { --wails-draggable: drag; -webkit-app-region: drag }
[data-titlebar="app"] .chrome button,
[data-titlebar="app"] .chrome .picker,
[data-titlebar="app"] .chrome .themer { --wails-draggable: no-drag; -webkit-app-region: no-drag }
/* macOS floats its lights over the corner at a y AppKit owns — measured centre
25.5px, which is the platform's own unified-toolbar metric. The row matches
that height rather than the spec's 39px, or the lights sit 6px below the
@@ -332,7 +331,6 @@
.mi .lb { display: block; font: var(--w-med) 12.5px var(--ui); color: var(--text) }
.mi .ds { display: block; margin-top: 2px; font: var(--w-reg) 11.5px/1.5 var(--ui); color: var(--muted) }
.mi .rt { margin-left: auto; padding-left: 10px; font: var(--w-reg) 10.5px var(--mono); color: var(--faint); white-space: nowrap; flex: none }
.mi .mi-bars { margin: 5px 0 0 auto; padding-left: 16px; box-sizing: content-box }
.mi[data-on] .rt { color: var(--accent-ink) }
.menu .div { height: 1px; margin: 5px 4px; background: var(--hair) }
.mi.plain .lb { font-weight: var(--w-reg); color: var(--muted) }
@@ -432,60 +430,10 @@
color: var(--muted); font: var(--w-med) 11.5px var(--mono);
transition: color .15s; animation: modeval .34s var(--ease-spring);
}
.mode .vl[data-g] { font-size: 11px; font-family: var(--ui) }
.mode:hover .lb, .mode[aria-expanded="true"] .lb { color: var(--muted) }
.mode:hover .vl, .mode[aria-expanded="true"] .vl { color: var(--text) }
/* 默认值该安静,调离默认才该被看见 —— 分量跟着它真实的代价走 */
.mode .vl[data-lv="1"], .mode .vl[data-lv="2"], .mode .vl[data-g="0"] { color: var(--text) }
.mode .vl[data-lv="3"] { color: var(--net) }
.mode .vl[data-lv="4"], .mode .vl[data-lv="5"] { color: var(--accent-ink) }
.mode .vl[data-g="2"] { color: var(--net) }
@keyframes modeval { from { opacity: 0; transform: translateY(2.5px) } to { opacity: 1; transform: none } }
/* 电平:想得多深是有档的,就该画成有档的样子。auto 那档一格不亮,改由一道
很慢的波从左扫到右 —— 意思是这个数不是你定的。 */
.bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 9px; flex: none }
.bars i {
width: 2px; border-radius: var(--r-xs); background: var(--ghost); opacity: .42;
transition: opacity .22s var(--ease-out), background-color .22s;
}
.bars i:nth-child(1) { height: 3px }
.bars i:nth-child(2) { height: 4.5px }
.bars i:nth-child(3) { height: 6px }
.bars i:nth-child(4) { height: 7.5px }
.bars i:nth-child(5) { height: 9px }
/* 升档时逐格点亮(延迟只挂在点亮那一侧,所以降档是一起灭的) */
.bars i:nth-child(2) { transition-delay: .05s }
.bars i:nth-child(3) { transition-delay: .1s }
.bars i:nth-child(4) { transition-delay: .15s }
.bars i:nth-child(5) { transition-delay: .2s }
.bars[data-lv="1"] i:nth-child(-n+1),
.bars[data-lv="2"] i:nth-child(-n+2) { opacity: 1; background: var(--muted) }
.bars[data-lv="3"] i:nth-child(-n+3) { opacity: 1; background: var(--net) }
.bars[data-lv="4"] i:nth-child(-n+4),
.bars[data-lv="5"] i:nth-child(-n+5) { opacity: 1; background: var(--accent) }
/* auto 一格不亮 —— 一直在动的默认态是噪音,那道波只在你摸上去时走一次 */
.mode:hover .bars[data-lv="0"] i { animation: barwave 1.3s var(--ease) }
@keyframes barwave { 0%, 62%, 100% { opacity: .42 } 22% { opacity: .85 } }
/* 闸门:批准挡掉多少,就画成环缺多大一个口。最紧那档是闭环加一杠 —— 不是
「没开口」,是「一律不做」,两件事不能长一个样。 */
.gate { display: grid; place-items: center; width: 12px; height: 12px; flex: none }
.gate svg { width: 12px; height: 12px; transform: rotate(-90deg) }
.gate circle {
fill: none; stroke: var(--faint); stroke-width: 1.6; stroke-linecap: round;
stroke-dasharray: 1; stroke-dashoffset: 0;
transition: stroke-dashoffset .42s var(--ease-out), stroke .22s;
}
.gate .bar {
fill: none; stroke: var(--faint); stroke-width: 1.6; stroke-linecap: round;
transform: scaleX(0); transform-origin: center;
transition: transform .34s var(--ease-spring), stroke .22s;
}
.gate[data-g="0"] .bar { transform: none }
.gate[data-g="2"] circle { stroke: var(--net); stroke-dashoffset: .24 }
.gate[data-g="3"] circle { stroke: var(--err); stroke-dashoffset: .68 }
.mode .ic { display: grid; place-items: center; width: 12px; height: 12px; flex: none }
.mode .ic svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round }
/* 关着时清单是灰的轮廓,开的瞬间沿着笔画描一遍 —— pathLength 归一,四段各画各的不会跳 */
@@ -539,15 +487,7 @@
.thbtn[data-th="dark"] .t-dark { display: inline; fill: currentColor }
/* 分段控件只留给「三档之间没有优劣」的单选,比如执行设定 */
.themer { display: inline-flex; padding: 2px; gap: 2px; background: var(--overlay); border: 1px solid var(--border); border-radius: var(--r-sm); }
.themer button { padding: 3px 9px; border: 0; border-radius: var(--r-xs); background: transparent; color: var(--faint); font: var(--w-med) 11px var(--ui); cursor: pointer; }
.themer button:hover { color: var(--text); }
.themer button[aria-pressed="true"] { background: var(--raised); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.1); }
.themer button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* 请求发出去了、内核还没回答:既不能装作已经切换,也不能一点反应都没有 */
.themer button:disabled { cursor: default }
.themer button:disabled:not([data-asking]) { color: var(--ghost) }
.themer button[data-asking] { color: var(--text); box-shadow: inset 0 -1.5px 0 var(--accent) }
.pip { width: 6px; height: 6px; border-radius: 50%; background: var(--ghost); flex: none }
@keyframes tick { 0%, 100% { opacity: .3 } 50% { opacity: 1 } }
@@ -2296,18 +2236,40 @@
关在里面,输入框自己的描边(.compose::after)会横穿过菜单。 */
.compose .row > * { position: relative; z-index: 1; animation: rowin .32s var(--ease-out) both }
@keyframes rowin { from { opacity: 0; transform: translateY(4px) } to { opacity: 1; transform: none } }
/* A model/effort switch rebuilds the runtime; keep the row live-looking but
uninteractive rather than letting a second click queue another rebuild. */
.compose .row[data-busy] { pointer-events: none }
.compose .row[data-busy] > * { opacity: .5; transition: opacity .12s }
/* A model switch rebuilds the runtime; keep the control live-looking but
uninteractive rather than letting a second click queue another rebuild.
只标在等的那一个上:整排一起变灰说的是「现在什么都不能改」,而那从来
不是真的 —— 另外几个仍然是你的。 */
.compose .row [data-pending] { pointer-events: none; opacity: .5; transition: opacity .12s }
/* 那半秒不该看着像死了:一道光扫过这排,说明改动正在生效 */
.compose .row[data-busy]::after {
.compose .row:has([data-pending])::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -7px; height: 1px;
background: linear-gradient(90deg, transparent, var(--net), transparent);
background-size: 34% 100%; background-repeat: no-repeat;
animation: rowsweep 1.1s linear infinite;
}
@keyframes rowsweep { from { background-position: -34% 0 } to { background-position: 134% 0 } }
/* ── 这一轮怎么跑 ────────────────────────────────────────────────────────
执行方式、思考强度、工具权限是同一个问题的三个变量,所以画在一块面板里;
内核那边它们是三个独立的 effect,所以各自等、各自被拒。槽本身用树里已有
的 .seg —— 设置页画的就是这同样三个字段,两处不该长得不一样。 */
.policy .vl { font-family: var(--ui); font-size: 11.5px; animation: polval .12s var(--ease-out) both }
@keyframes polval { from { opacity: 0 } to { opacity: 1 } }
/* 开合是一整块面板就地展开,比换地方的菜单短。右对齐:这颗按钮挨着发送,
左对齐会把 300px 的面板推出窗口右缘。 */
.polmenu {
min-width: 296px; padding: 13px 14px 12px; left: auto; right: 0;
display: flex; flex-direction: column; gap: 16px;
transform-origin: bottom right; transition-duration: .2s;
}
.segf { display: flex; flex-direction: column; gap: 7px }
.seglb { font: var(--w-med) 10px var(--mono); letter-spacing: var(--track-cap); text-transform: uppercase; color: var(--faint) }
.polmenu .note { margin: 0; font: var(--w-reg) 11px/1.6 var(--ui); color: var(--faint) }
.polmenu .note.segbad { color: var(--err) }
/* 问出去了还没回话:不整组变灰,只在那一档下面压一条线。 */
.seg button[data-asking] { color: var(--text); box-shadow: inset 0 -1.5px 0 var(--accent) }
.seg button:disabled:not([data-asking]) { color: var(--ghost) }
/* 两个图标叠在同一格里交叉旋入,按钮不跳宽 */
.btn.send { display: inline-flex; align-items: center; gap: 6px; position: relative; overflow: hidden }
/* One sweep across the primary button on hover. It is the end of a sentence,
@@ -2810,7 +2772,7 @@
.reload, .reload .rdot, .rnote { animation: none !important }
.pane[data-run="done"] .rmark { animation: none !important; filter: none !important }
.bar .c::after { display: none }
.bars i, .mode .vl, .mode.danger::before, .mode.tog .ic path,
.mode .vl, .mode.danger::before, .mode.tog .ic path,
.compose .row > *, .call[data-running] > .g .sym,
.app[data-run="running"] .sessrow[data-on][data-live] .pip { animation: none !important }
/* The down marker says "receiving" through that breath alone, so stopping the
@@ -2824,7 +2786,7 @@
/* 动画停了,「正在跑」这件事还得说 —— 换成不动的实心表达 */
.call[data-running] > .g .line { background: color-mix(in srgb, var(--net) 55%, var(--hair)) }
/* 扫过去的那道光停下来就看不见了,换成一整条静止的线,「在生效」这句话还在 */
.compose .row[data-busy]::after { animation: none; background: var(--net); opacity: .5 }
.compose .row:has([data-pending])::after { animation: none; background: var(--net); opacity: .5 }
.scroll { scroll-behavior: auto }
* { transition-duration: .01ms !important }
}
@@ -5689,7 +5651,6 @@
手写 nth-child 链的问题不是难看,是列表长度一变就得回来补规则。 */
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.compose .row > *,
.mode:hover .bars[data-lv="0"] i,
.fan > .slotbox .slot,
.mgrp,
.hits > *,
@@ -5697,7 +5658,6 @@
animation-delay: calc((sibling-index() - 1) * var(--m-step));
}
.compose .row { --m-step: 30ms }
.mode .bars { --m-step: 80ms }
.fan { --m-step: 40ms }
.mgrp { --m-step: 50ms }
}
-7
View File
@@ -455,12 +455,6 @@ export function App({ hub }: { hub: HubPort }) {
// while a turn runs — so approving mode, preset and model all changed on disk
// while the screen went on showing what they were when it opened.
const [settingsPulse, setSettingsPulse] = useState(0);
// The chrome's own controls change this session's posture — its preset, its
// plan mode. Nothing about the pane list or the account moved, so reloading
// those left the button showing the posture it had when the window opened.
// What has to hear about it is the pane holding that fact.
const onSessionSettingChanged = useCallback(() => setSettingsPulse((n) => n + 1), []);
const onSettingsChanged = useCallback(() => {
reloadAccount();
setSettingsPulse((n) => n + 1);
@@ -562,7 +556,6 @@ export function App({ hub }: { hub: HubPort }) {
theme={theme}
onTheme={setTheme}
onSettings={showPrefs}
onChanged={onSessionSettingChanged}
account={account}
focus={focus}
onFocus={() => setFocus((v) => !v)}
+2 -42
View File
@@ -1,15 +1,9 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AccountState, AgentPort, Preset, SessionStatus, WorkspaceInfo } from "../port/port";
import type { AccountState, AgentPort, SessionStatus, WorkspaceInfo } from "../port/port";
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
import { chord } from "./keys";
const PRESETS: [Preset, string][] = [
["balanced", "均衡"],
["delivery", "交付"],
];
// 「跟随系统」和它当前解析出来的那一档,在屏幕上是同一个样子。按固定顺序循环
// 就意味着在浅色系统上,默认的 auto 往下一档走到 light —— 点下去一个像素都不
// 动,读起来就是这个开关坏了。下一档改从系统当前是什么推:第一下必定换掉屏幕
@@ -49,30 +43,14 @@ interface Props {
onFocus: () => void;
onSettings: (section?: string) => void;
account: AccountState | null;
onChanged: () => void;
}
export function Chrome({ port, status, title, steer, run, theme, onTheme, onSettings, onChanged, account, host, focus, onFocus }: Props) {
export function Chrome({ port, status, title, steer, run, theme, onTheme, onSettings, account, host, focus, onFocus }: Props) {
const root = status?.workspaceRoot || status?.cwd || "";
const project = root ? base(root) : "—";
// Only for the "隔离" tag: the folder list and the switch itself moved to the
// sidebar, where adding one and opening one are the same gesture.
const [ws, setWs] = useState<WorkspaceInfo | null>(null);
// The preset the kernel has not answered for yet, and why it refused the last
// one. Without both, a call that never lands leaves the pair reading as the
// state it did not reach — the settings panel already works this way.
const [asking, setAsking] = useState("");
const [refused, setRefused] = useState("");
const pick = (id: Preset) => {
if (!port || asking) return;
setAsking(id);
setRefused("");
port
.setPreset(id)
.then(onChanged)
.catch((e: unknown) => setRefused(reason(e)))
.finally(() => setAsking(""));
};
useEffect(() => {
if (!port) {
setWs(null);
@@ -110,24 +88,6 @@ export function Chrome({ port, status, title, steer, run, theme, onTheme, onSett
<i />
{t(RUN_LB[run] ?? "待命")}
</span>
<span className="badge" data-err="" role="alert" hidden={!refused}>
{refused}
</span>
<div className="themer" role="group" aria-label={t("执行设定")}>
{PRESETS.map(([id, lb]) => (
<button
key={id}
data-action="chrome.preset"
data-value={id}
aria-pressed={status?.preset === id}
disabled={!port || !!asking}
data-asking={asking === id ? "" : undefined}
onClick={() => pick(id)}
>
{t(lb)}
</button>
))}
</div>
{/* Identity sits where every app puts it, but signed out it stays an
outline in the icon cluster: an entry point, not a pitch. Reasonix
runs fine without an account and must not imply otherwise. */}
+23 -73
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort, ApprovalMode, ModelEntry, SessionStatus, Attachment } from "../port/port";
import type { AgentPort, ModelEntry, SessionStatus, Attachment } from "../port/port";
import { Picker } from "./Menu";
import { Policy } from "./Policy";
import { modelMenu } from "./modelmenu";
import { CompletionMenu, useCompletion } from "./Completion";
import { useIme } from "./ime";
@@ -9,15 +10,6 @@ import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { useIntake } from "./useIntake";
import type { Dropped } from "./filedrop";
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
const APPROVALS: [ApprovalMode, string, string][] = [
["dontAsk", "不打扰", "不弹审批;要批准才能做的一概不做。"],
["ask", "询问", "每次动手前问你。"],
["auto", "自动", "低风险自己过,写操作仍然问。"],
["yolo", "全放行", "不问了。只在你完全信任这个工作区时用。"],
];
// Only the ladder the kernel would accept for the model in hand. A fixed list
// here offered rungs a given model does not have, and picking one looked like
// the control was dead: the request was refused downstream, with nothing on the
@@ -36,11 +28,6 @@ function effortsFor(models: ModelEntry[], ref?: string): string[] {
return EFFORT_FALLBACK;
}
// 强度是有序的,批准是有序的 —— 一排全等的胶囊把这件事藏了起来。两个刻度把
// 它画回来:几格电平表示这一轮想得多深,环的缺口表示闸门开了多大。
const LEVEL: Record<string, number> = { auto: 0, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
const GATE: Record<ApprovalMode, number> = { dontAsk: 0, ask: 1, auto: 2, yolo: 3 };
interface Props {
port: AgentPort;
status: SessionStatus | null;
@@ -97,7 +84,7 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
const [shots, setShots] = useState<Chip[]>([]);
const picker = useRef<HTMLInputElement>(null);
const [models, setModels] = useState<ModelEntry[]>([]);
const [switching, setSwitching] = useState(false);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const box = useRef<HTMLTextAreaElement>(null);
// Set only when a completion moved the caret: the browser puts it at the end
// of a programmatic value, which is wrong for anything accepted mid-line.
@@ -260,15 +247,15 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
onReceive: receive,
});
const apv = status?.toolApprovalMode ?? "ask";
const eff = status?.effort || "auto";
const efforts = effortsFor(models, status?.modelRef);
const modelLb = status?.modelRef?.split("/").pop() ?? status?.label ?? "—";
// Every one of these rebuilds the runtime kernel-side (~0.4s on a real
// session). Without a pending state the click reads as a dead control.
const change = (p: Promise<void>) => {
setSwitching(true);
void p.then(onChanged).catch(onError).finally(() => setSwitching(false));
// session). Without a pending state the click reads as a dead control —— 而
// 等的是哪一个就只标哪一个:一整排一起变灰,说的是「现在什么都不能改」,
// 而实际上只有这一个在等内核回话。
const change = (key: string, p: Promise<void>) => {
setBusy((b) => ({ ...b, [key]: true }));
void p.then(onChanged).catch(onError).finally(() => setBusy((b) => ({ ...b, [key]: false })));
};
return (
@@ -433,7 +420,7 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
change(port.setPlanMode(!status?.plan));
change("plan", port.setPlanMode(!status?.plan));
}
}}
/>
@@ -441,7 +428,7 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
{text.length || ""}
</span>
</div>
<div className="row" data-busy={switching ? "" : undefined}>
<div className="row">
{/* 拖进来和粘贴都走同一条路,但那两个都得先有一张图在手边。点开系统
选择器是唯一不需要预备动作的入口。 */}
<input
@@ -478,7 +465,8 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
title={status?.modelRef ?? modelLb}
current={status?.modelRef}
items={modelMenu(models)}
onPick={(ref) => change(port.setModel(ref))}
pending={busy["model"]}
onPick={(ref) => change("model", port.setModel(ref))}
label={
<>
<span className="dot" aria-hidden="true" />
@@ -486,35 +474,13 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
</>
}
/>
{/* An endpoint that names no levels gets no control: a picker whose
every rung is refused downstream reads as a dead one. */}
{efforts.length > 0 && (
<>
<span className="sep" aria-hidden="true" />
<Picker
className="mode plain"
place="bottom"
current={eff}
items={efforts.map((v) => ({ value: v, label: v, meter: LEVEL[v] }))}
onPick={(v) => change(port.setEffort(v))}
label={
<>
<span className="bars" data-lv={LEVEL[eff] ?? 0} aria-hidden="true">
<i /><i /><i /><i /><i />
</span>
<span className="lb">{t("强度")}</span>
{/* key 让值换一次就重挂载一次 —— 这是那半秒里唯一能看出「改动生效了」的地方 */}
<span className="vl" data-lv={LEVEL[eff] ?? 0} key={eff}>{eff}</span>
</>
}
/>
</>
)}
<span className="sep" aria-hidden="true" />
<button
className="mode tog"
data-action="plan.mode"
data-pending={busy["plan"] ? "" : undefined}
aria-pressed={status?.plan ?? false}
onClick={() => change(port.setPlanMode(!status?.plan))}
onClick={() => change("plan", port.setPlanMode(!status?.plan))}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
@@ -532,34 +498,18 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
<span className="lb">{t("执行计划中")}</span>
</span>
)}
<Picker
className={apv === "yolo" ? "mode plain danger" : "mode plain"}
place="bottom"
current={apv}
items={APPROVALS.map(([v, lb, ds]) => ({ value: v, label: t(lb), desc: t(ds) }))}
onPick={(v) => change(port.setApprovalMode(v as ApprovalMode))}
label={
<>
<span className="gate" data-g={GATE[apv]} aria-hidden="true">
<svg viewBox="0 0 16 16">
<circle cx="8" cy="8" r="4.4" pathLength={1} />
{/* 闭合的环只说明「没开口」;一律不做还要再画一杠 */}
<path className="bar" d="M5.4 8h5.2" />
</svg>
</span>
<span className="lb">{t("批准")}</span>
<span className="vl" data-g={GATE[apv]} key={apv}>
{t(APPROVALS.find(([m]) => m === apv)?.[1] ?? "")}
</span>
</>
}
/>
{/* 执行方式、思考强度、工具权限说的是同一件事:这一轮怎么跑。它们原来
一个在窗口顶栏、两个在这里,要拼出下一轮会怎么执行得看两个地方。
模型和计划留在外面:一个是最常改也最认得出的选择,一个说的是这一轮
的意图,不是一项偏好。 */}
<span className="sep" aria-hidden="true" />
<Policy port={port} status={status} efforts={efforts} onChanged={onChanged} />
<span className="go">
<button
className="btn send"
data-primary
data-action={running ? "session.stop" : "session.send"}
onClick={() => (running ? change(port.cancel()) : send())}
onClick={() => (running ? change("cancel", port.cancel()) : send())}
>
<span className="ic" aria-hidden="true">
<svg className="i-send" viewBox="0 0 16 16">
+6 -9
View File
@@ -8,9 +8,6 @@ export interface MenuItem {
right?: string;
plain?: boolean;
divide?: boolean;
// 有档位的选项在行尾画出它的刻度,和输入框上那个电平是同一件事 —— 打开菜单
// 看到的就是一道从低到高的色阶,而不是六个一样的词。
meter?: number;
// A group caption. It labels the rows under it and cannot be chosen, so it
// is not a button — a menu whose headings take focus is a menu you arrow
// through twice.
@@ -30,9 +27,13 @@ interface Props {
place: "top" | "bottom";
className?: string;
title?: string;
// The choice already made is being applied and has not landed. Marked on the
// trigger alone: whatever else is on that shelf is still the reader's to
// change while this one waits.
pending?: boolean;
}
export function Picker({ label, items, current, onPick, place, className, title }: Props) {
export function Picker({ label, items, current, onPick, place, className, title, pending }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const wrap = useRef<HTMLDivElement>(null);
@@ -111,6 +112,7 @@ export function Picker({ label, items, current, onPick, place, className, title
className={className}
aria-haspopup="menu"
aria-expanded={open}
data-pending={pending ? "" : undefined}
title={title}
onClick={() => setOpen((v) => !v)}
>
@@ -160,11 +162,6 @@ export function Picker({ label, items, current, onPick, place, className, title
<span className="lb">{it.label}</span>
{it.desc && <span className="ds">{it.desc}</span>}
</span>
{it.meter !== undefined && (
<span className="bars mi-bars" data-lv={it.meter} aria-hidden="true">
<i /><i /><i /><i /><i />
</span>
)}
{it.right && <span className="rt">{it.right}</span>}
</button>
)}
+117
View File
@@ -0,0 +1,117 @@
import { useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, ApprovalMode, Preset, SessionStatus } from "../port/port";
import { useDismiss } from "./dismiss";
import { Seg } from "./Seg";
// 这一轮怎么跑,是三个变量共同回答的一个问题。它们原来分处窗口顶栏(执行方式)
// 和输入框底栏(强度、批准),要知道下一轮会怎么执行得看两个地方。
const PRESETS: [Preset, string, string][] = [
["balanced", "均衡", "做到模型认为做完为止。日常用这档"],
["delivery", "交付", "改了东西就得验证、复核、签收,少一样都不算做完"],
];
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
const APPROVALS: [ApprovalMode, string, string][] = [
["dontAsk", "不打扰", "不弹审批;要批准才能做的一概不做。"],
["ask", "询问", "每次动手前问你。"],
["auto", "自动", "低风险自己过,写操作仍然问。"],
["yolo", "全放行", "不问了。只在你完全信任这个工作区时用。"],
];
type Field = "preset" | "effort" | "approval";
interface Props {
port: AgentPort;
status: SessionStatus | null;
// The rungs this model actually has, decided upstream from the kernel's own
// list. An empty ladder means the endpoint names none, and the field is not
// drawn rather than drawn dead.
efforts: string[];
onChanged: () => void;
}
// 内核是三个独立的 effect,界面把它们画在一起不该把它们并成一个调用 —— 那会让
// 「强度没换成」连坐掉已经换好的执行方式。
export function Policy({ port, status, efforts, onChanged }: Props) {
const [open, setOpen] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btn = useRef<HTMLButtonElement>(null);
useDismiss(open, wrap, () => setOpen(false));
// 每个字段各自记「问出去的是哪一档」和「上一次为什么被拒」。合成一个就回到了
// 整排一起变灰:一个字段在等,另外两个仍然能改。
const [asking, setAsking] = useState<Partial<Record<Field, string>>>({});
const [refused, setRefused] = useState<Partial<Record<Field, string>>>({});
const apply = (field: Field, value: string, call: () => Promise<void>) => {
if (asking[field]) return;
setAsking((a) => ({ ...a, [field]: value }));
setRefused((r) => ({ ...r, [field]: "" }));
call()
.then(onChanged)
.catch((e: unknown) => setRefused((r) => ({ ...r, [field]: reason(e) })))
.finally(() => setAsking((a) => ({ ...a, [field]: "" })));
};
const preset = status?.preset;
const eff = status?.effort || "auto";
const apv = status?.toolApprovalMode ?? "ask";
// 事实本身,不是「设置」。摘要念的是内核当前的三个值,档位一律用内核自己的
// 拼法 —— 给它另配一张显示名表,就是又一处能跟内核走散的措辞。
const summary = [t(PRESETS.find(([id]) => id === preset)?.[1] ?? "") || "—", eff, t(APPROVALS.find(([m]) => m === apv)?.[1] ?? "")]
.filter(Boolean)
.join(" · ");
return (
<div className="picker policy" ref={wrap}>
<button
ref={btn}
className="mode plain"
data-action="chrome.policy"
aria-expanded={open}
title={t("这一轮怎么跑:执行方式、思考强度、工具权限")}
onClick={() => setOpen((v) => !v)}
>
<span className="vl">{summary}</span>
</button>
<div className="menu modemenu polmenu" role="group" aria-label={t("本轮执行策略")} hidden={!open}>
<Seg
label={t("执行方式")}
data-action="chrome.preset"
text
options={PRESETS.map(([value, lb, note]) => ({ value, label: t(lb), note: t(note) }))}
current={preset}
asking={asking.preset}
refused={refused.preset}
onClick={(v) => apply("preset", v, () => port.setPreset(v as Preset))}
/>
{/* An endpoint that names no levels gets no ladder: a control whose
every rung is refused downstream reads as a dead one. */}
{efforts.length > 0 && (
<Seg
label={t("思考强度")}
data-action="reasoning.effort"
options={efforts.map((value) => ({ value, label: value }))}
current={eff}
asking={asking.effort}
refused={refused.effort}
onClick={(v) => apply("effort", v, () => port.setEffort(v))}
/>
)}
<Seg
label={t("工具权限")}
data-action="tool-approval.mode"
text
options={APPROVALS.map(([value, lb, note]) => ({ value, label: t(lb), note: t(note) }))}
current={apv}
asking={asking.approval}
refused={refused.approval}
onClick={(v) => apply("approval", v, () => port.setApprovalMode(v as ApprovalMode))}
/>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
// 一段带标签的互斥选择:树里已有的 .seg 槽,外面套上字段名和「选中这档意味着
// 什么」那一行,再加上它自己的等待与拒绝。
//
// 和 Switch 一样是个基元:它自己不是入口,动作的身份写在把 onClick 交给它的那
// 个调用点上,data-* 原样透传到每一颗按钮,答案落在 data-value 上。
export interface SegOption {
value: string;
// Already in the reader's language: the call site owns the wording, because
// that is the only place a catalogue check can see it.
label: string;
// What picking it means, where there is something to say. A rung on a ladder
// explains itself by its place on it.
note?: string;
}
export function Seg({
label, options, current, asking, refused, text, onClick, ...id
}: {
label: string;
options: SegOption[];
current?: string;
// The one value asked for and not yet answered. While it is out this group
// takes no second answer — a set of one-of-N buttons has no meaning for two
// in flight — and every other group beside it stays live.
asking?: string;
// Why the last answer did not land. It sits with the group it was about and
// does not lock it: picking again is the only thing to do here.
refused?: string;
// 标签是中文:mono 栈里汉字会掉到回退字体,跟旁边的界面字对不齐。
text?: boolean;
// Named for the DOM event it stands for, as Switch is. The action census
// places a primitive's call site by that name; a prop the DOM has no event
// for makes the call site invisible to it, which is why every Picker in the
// tree is uncounted today.
onClick: (value: string) => void;
} & { [K in `data-${string}`]?: string }) {
const note = options.find((o) => o.value === current)?.note;
return (
<div className="segf">
<div className="seglb">{label}</div>
<div className="seg" data-text={text ? "" : undefined} role="group" aria-label={label}>
{options.map((o) => (
<button
key={o.value}
{...id}
data-value={o.value}
aria-pressed={current === o.value}
data-asking={asking === o.value ? "" : undefined}
disabled={!!asking}
onClick={() => onClick(o.value)}
>
{o.label}
</button>
))}
</div>
{refused ? <p className="note segbad" role="alert">{refused}</p> : note ? <p className="note">{note}</p> : null}
</div>
);
}
@@ -1,121 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Chrome } from "./Chrome";
import { HttpError } from "../port/port";
import type { AgentPort, Preset, SessionStatus } from "../port/port";
afterEach(cleanup);
function deferred<T>() {
let settle!: (v: T) => void;
let fail!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
settle = res;
fail = rej;
});
// A promise nobody rejects still counts as unhandled if the component drops
// it, so the test has to be the one holding it.
promise.catch(() => {});
return { promise, settle, fail };
}
function draw(preset: Preset, setPreset: (p: Preset) => Promise<void>) {
const onChanged = vi.fn();
const port = { setPreset, workspaces: async () => null } as unknown as AgentPort;
const view = render(
<Chrome
port={port}
status={{ preset } as SessionStatus}
steer={0}
run="idle"
theme="light"
onTheme={() => {}}
focus={false}
onFocus={() => {}}
onSettings={() => {}}
account={null}
onChanged={onChanged}
/>,
);
const group = () => within(screen.getByRole("group", { name: "执行设定" }));
const at = (name: string) => group().getByRole("button", { name });
return { onChanged, view, at, refusal: () => document.querySelector(".chrome .badge[data-err]:not([hidden])") };
}
describe("changing the execution preset from the chrome", () => {
it("asks the kernel once, and does not move the selection on its own", async () => {
const call = deferred<void>();
const setPreset = vi.fn(() => call.promise);
const { at } = draw("balanced", setPreset);
await userEvent.click(at("交付"));
expect(setPreset).toHaveBeenCalledTimes(1);
expect(setPreset).toHaveBeenCalledWith("delivery");
// Still the kernel's answer, not the click's: nothing has come back yet.
expect(at("均衡").getAttribute("aria-pressed")).toBe("true");
expect(at("交付").getAttribute("aria-pressed")).toBe("false");
});
it("shows the call is out and refuses a second one until it settles", async () => {
const call = deferred<void>();
const setPreset = vi.fn(() => call.promise);
const { at } = draw("balanced", setPreset);
await userEvent.click(at("交付"));
expect(at("交付").hasAttribute("data-asking")).toBe(true);
// A pair of one-of-two buttons has no meaning for two answers in flight.
expect((at("交付") as HTMLButtonElement).disabled).toBe(true);
expect((at("均衡") as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(at("均衡"));
await userEvent.click(at("交付"));
expect(setPreset).toHaveBeenCalledTimes(1);
});
it("hands the selection back to the kernel's own answer when it lands", async () => {
const call = deferred<void>();
const { at, onChanged, view } = draw("balanced", () => call.promise);
await userEvent.click(at("交付"));
call.settle();
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
// onChanged is what makes the shell re-read /status; the selection follows
// that read, which is why it is the prop and not local state.
view.rerender(
<Chrome
port={{ setPreset: async () => {}, workspaces: async () => null } as unknown as AgentPort}
status={{ preset: "delivery" } as SessionStatus}
steer={0}
run="idle"
theme="light"
onTheme={() => {}}
focus={false}
onFocus={() => {}}
onSettings={() => {}}
account={null}
onChanged={onChanged}
/>,
);
expect(at("交付").getAttribute("aria-pressed")).toBe("true");
expect((at("交付") as HTMLButtonElement).disabled).toBe(false);
});
it("keeps the old selection and says so when the kernel refuses", async () => {
const call = deferred<void>();
const { at, onChanged, refusal } = draw("balanced", () => call.promise);
await userEvent.click(at("交付"));
call.fail(new HttpError(409, "a turn is running", { code: "busy.switch_model" }));
await waitFor(() => expect(refusal()).toBeTruthy());
expect(refusal()?.textContent).toContain("任务正在运行");
expect(at("均衡").getAttribute("aria-pressed")).toBe("true");
// Usable again, and nothing told the shell to go and re-read a change that
// did not happen.
expect((at("交付") as HTMLButtonElement).disabled).toBe(false);
expect(onChanged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Policy } from "./Policy";
import { HttpError } from "../port/port";
import type { AgentPort, SessionStatus } from "../port/port";
afterEach(cleanup);
function deferred<T>() {
let settle!: (v: T) => void;
let fail!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
settle = res;
fail = rej;
});
// A promise nobody rejects still counts as unhandled if the component drops
// it, so the test has to be the one holding it.
promise.catch(() => {});
return { promise, settle, fail };
}
const STATUS = { preset: "balanced", effort: "medium", toolApprovalMode: "ask" } as SessionStatus;
function draw(calls: Partial<AgentPort> = {}, status: SessionStatus = STATUS) {
const onChanged = vi.fn();
const port = {
setPreset: vi.fn(async () => {}),
setEffort: vi.fn(async () => {}),
setApprovalMode: vi.fn(async () => {}),
...calls,
} as unknown as AgentPort;
const view = render(
<Policy port={port} status={status} efforts={["auto", "medium", "high"]} onChanged={onChanged} />,
);
const open = () => userEvent.click(screen.getByRole("button", { expanded: false }));
const at = (field: string, name: string) =>
within(screen.getByRole("group", { name: field })).getByRole("button", { name });
const field = (name: string) => screen.getByRole("group", { name });
return { port, onChanged, view, open, at, field, status };
}
describe("the turn policy the composer owns", () => {
it("says what this turn will actually do before it is opened", () => {
draw();
// The three current values, not the word "settings": what is on the shelf
// has to answer "how will the next turn run" without being opened.
expect(screen.getByRole("button", { expanded: false }).textContent).toBe("均衡 · medium · 询问");
});
it("asks the kernel once, and does not move the selection on its own", async () => {
const call = deferred<void>();
const setPreset = vi.fn(() => call.promise);
const { at, open, port } = draw({ setPreset });
await open();
await userEvent.click(at("执行方式", "交付"));
expect(setPreset).toHaveBeenCalledTimes(1);
expect(setPreset).toHaveBeenCalledWith("delivery");
// Still the kernel's answer, not the click's: nothing has come back yet.
expect(at("执行方式", "均衡").getAttribute("aria-pressed")).toBe("true");
expect(at("执行方式", "交付").getAttribute("aria-pressed")).toBe("false");
// One field, one effect. Drawing three fields in one panel must not turn
// three kernel operations into one call.
expect(port.setEffort).not.toHaveBeenCalled();
expect(port.setApprovalMode).not.toHaveBeenCalled();
});
it("shows the call is out and refuses a second one on that field", async () => {
const call = deferred<void>();
const setPreset = vi.fn(() => call.promise);
const { at, open } = draw({ setPreset });
await open();
await userEvent.click(at("执行方式", "交付"));
expect(at("执行方式", "交付").hasAttribute("data-asking")).toBe(true);
expect((at("执行方式", "交付") as HTMLButtonElement).disabled).toBe(true);
expect((at("执行方式", "均衡") as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(at("执行方式", "均衡"));
expect(setPreset).toHaveBeenCalledTimes(1);
});
// The reason the three live in one panel and not one control: a panel that
// goes grey while one value is in flight says "nothing here can be changed",
// and that was never true.
it("leaves the other fields alone while one is waiting", async () => {
const call = deferred<void>();
const setPreset = vi.fn(() => call.promise);
const { at, open, port } = draw({ setPreset });
await open();
await userEvent.click(at("执行方式", "交付"));
expect((at("思考强度", "high") as HTMLButtonElement).disabled).toBe(false);
expect((at("工具权限", "自动") as HTMLButtonElement).disabled).toBe(false);
await userEvent.click(at("思考强度", "high"));
await userEvent.click(at("工具权限", "自动"));
expect(port.setEffort).toHaveBeenCalledWith("high");
expect(port.setApprovalMode).toHaveBeenCalledWith("auto");
// And the one still out has not been asked a second time by either.
expect(setPreset).toHaveBeenCalledTimes(1);
});
it("hands the selection back to the kernel's own answer when it lands", async () => {
const call = deferred<void>();
const { at, open, onChanged, view, port } = draw({ setPreset: () => call.promise });
await open();
await userEvent.click(at("执行方式", "交付"));
call.settle();
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
// onChanged is what makes the pane re-read /status; the selection follows
// that read, which is why it is the prop and not local state.
view.rerender(
<Policy
port={port}
status={{ ...STATUS, preset: "delivery" } as SessionStatus}
efforts={["auto", "medium", "high"]}
onChanged={onChanged}
/>,
);
expect(at("执行方式", "交付").getAttribute("aria-pressed")).toBe("true");
expect((at("执行方式", "交付") as HTMLButtonElement).disabled).toBe(false);
});
it("keeps the old value and says why, next to the field that was refused", async () => {
const call = deferred<void>();
const { at, field, open, onChanged } = draw({ setEffort: () => call.promise });
await open();
await userEvent.click(at("思考强度", "high"));
call.fail(new HttpError(409, "a turn is running", { code: "busy.switch_model" }));
await waitFor(() => expect(field("思考强度").parentElement?.querySelector(".segbad")).toBeTruthy());
const said = field("思考强度").parentElement?.querySelector(".segbad");
expect(said?.textContent).toContain("任务正在运行");
// The refusal belongs to the field it was about, and to no other.
expect(field("执行方式").parentElement?.querySelector(".segbad")).toBeNull();
expect(at("思考强度", "medium").getAttribute("aria-pressed")).toBe("true");
// Usable again, and nothing told the pane to go and re-read a change that
// did not happen.
expect((at("思考强度", "high") as HTMLButtonElement).disabled).toBe(false);
expect(onChanged).not.toHaveBeenCalled();
});
// The kernel omits the field exactly when it would refuse every rung but one.
it("draws no ladder for an endpoint that names no levels", async () => {
const onChanged = vi.fn();
render(
<Policy
port={{ setPreset: async () => {} } as unknown as AgentPort}
status={STATUS}
efforts={[]}
onChanged={onChanged}
/>,
);
await userEvent.click(screen.getByRole("button", { expanded: false }));
expect(screen.queryByRole("group", { name: "思考强度" })).toBeNull();
expect(screen.getByRole("group", { name: "工具权限" })).toBeTruthy();
});
});
@@ -11,7 +11,7 @@ const CTL = new Set(["Switch", "Menu"]);
// As two literals these matched only when the census ran on `src` from the
// package root: scanning a copy elsewhere silently counted the primitives' own
// handlers as three extra interaction roots.
const PRIMITIVES = new Set(["Switch.tsx", "Menu.tsx"].map((f) => join(SRC, "ui", f)));
const PRIMITIVES = new Set(["Switch.tsx", "Menu.tsx", "Seg.tsx"].map((f) => join(SRC, "ui", f)));
// Callees whose implementation is outside the module graph: the platform. Not
// a list of helpers believed pure — those are decided by the fixpoint.
const PLATFORM = /^(String|Number|Boolean|Object|Array|JSON|Math|Date|Promise|Set|Map|console|window|document|navigator|localStorage|sessionStorage|requestAnimationFrame|cancelAnimationFrame|setTimeout|clearTimeout|setInterval|clearInterval|structuredClone|encodeURIComponent|decodeURIComponent|alert|confirm|prompt|matchMedia|getComputedStyle|URL|Blob|FileReader|IntersectionObserver|ResizeObserver|MutationObserver|AbortController|crypto|performance|queueMicrotask|isNaN|parseInt|parseFloat)$/;