refactor(studio): give transcript facts their proper visual weight

Six kinds of fact share one card: what you asked, what it did, what it
finally said, what it needs decided. They shared the weight too, so a turn
scrolled back through read as one long list of the same row in different
colours, and the answer — the thing the turn was for — was drawn in the
grey reserved for process text.

Four weights now, and only the weight moves. Membership is untouched,
semantic projection is untouched, and no card shows less than it did.

The answer stops being a timeline entry. Its body takes --text where the
transcript's process text keeps --muted, it gets the panel's vertical air
instead of the card's, and its symbol loses the ring and ground that say
"there is a control here". The turn anchor keeps its rule and gets a
paragraph's distance above it rather than a line's. Work rows are one line
each: a long argument gives up characters now instead of folding the row
in two, because rows of unequal height cannot be scanned for structure,
only read one at a time.

The raw tool id leaves the ordinary card, on a criterion rather than a
list. It is shown when it says something the name above it does not — the
interpreter a shell call really went to, the remote tool a capability
resolved to — and dropped when tagFor would only hand back the string
labelFor was computed from. "Read" beside read_file is one fact printed
twice and it was on every card in the record. A list of tools to hide it
for would have to be maintained against a registry that grows; the
derivability test does not. The exact id stays one hover away on the name,
so nothing became unauditable.

The reading measure is deliberately not narrowed. That was settled
already: fencing the prose at 68ch while terminal blocks and diffs stay
full width puts two column widths on one screen, and the tier is carried
by colour and air instead.

Measured against the same markup through the real compiled stylesheet, not
asserted: the answer's body moves from --muted to --text, its padding from
11px to 17px; Read and Search drop a header element each and stop
wrapping; Bash keeps bash and an MCP call keeps its server and remote
name.

What this does not do is fix the entrance animation. Every .call animates
on mount while blocks of 48 mount and unmount as they scroll, so a fact
from an hour ago re-enters as though it just happened. Telling those apart
needs provenance carried to each card — the DOM's shape cannot say it,
since only an unfinished say is rendered outside a block — and that is its
own change.
This commit is contained in:
esengine committed 2026-09-08 01:50:14 -07:00
1 parent 491016f8c9
commit 38c1477679
2 files changed
+33 -7

No files matched your search

+18 -1
View File
@@ -1534,7 +1534,7 @@
.call[data-k="me"] > .g .sym { background: var(--text); color: var(--surface) }
/* 一轮从这里开始。滚回去找东西时这张卡是唯一的锚点,而它原来跟一次工具调用
的头部一样轻。轮号刻意不写 —— 回退入口就在卡上,没有编号要用眼睛去对。 */
.call[data-k="me"] { margin-top: 20px }
.call[data-k="me"] { margin-top: 28px }
.call[data-k="me"]::before {
content: ""; grid-column: 1 / -1; height: 1px; margin-bottom: 12px; background: var(--hair);
}
@@ -1543,6 +1543,23 @@
.call[data-k="write"] > .g .line { background: color-mix(in srgb, var(--accent) 34%, transparent) }
.call[data-k="ask"] > .g .line { background: color-mix(in srgb, var(--accent) 55%, transparent) }
/* ── 四档份量 ────────────────────────────────────────────────────────────
一轮的锚点、最终的回答、中间做过的事、要你定的事。四者原来共用一张卡的
重量,一屏滚下来只能靠符号的颜色分辨。这里改的只是各自值多少地方 ——
成员不动,语义投影不动。 */
/* 回答是这一轮的产出,不是又一条时间线。--muted 是留给过程的,正文用
--text;符号退成一个记号,不再是一枚有环有底的按钮。正文仍然全铺(理由
见 .out .txt 上面那段),所以份量只能靠字色和留白来分。 */
.call[data-k="say"] > .g .sym { background: none; box-shadow: none }
.call[data-k="say"] > .c { padding: var(--y-panel) 0 }
.call[data-k="say"] > .c > .out > .txt { color: var(--text); line-height: 1.78 }
/* 中间做过的事是扫的,不是读的:一步一行,参数长了省略,而不是把这一行折成
两行 —— 折行让同一类卡在一屏里高矮不齐,层次就得逐张对齐才看得出来。
锚点、回答和要你定的事都还可以折:它们本来就是读的。 */
.call:not([data-k="me"], [data-k="say"], [data-k="ask"]) > .c > .hl { flex-wrap: nowrap }
/* 份量不同,入场就不该相同:一行只淡入,一张卡带位移,一整块面板从上沿展开 */
.term-l, .dl, .plan .s { animation: line var(--m-line) both }
.call, .hits > *, .steps > * { animation: card var(--m-card) both }
@@ -78,6 +78,9 @@ export function ToolCard({
// Which server answered belongs on the card, not in a panel: this is the
// moment the user can judge whether an external service should have run.
const from = mcpOrigin(shown);
// The interpreter that actually ran it, or the remote tool a capability call
// resolved to. Anything else the name already said.
const tag = tagFor(tool);
// A delegated step is only auditable if it names the profile that ran: "a
// subagent" is not a thing you can go read, "skill:security-review" is.
const who = tool.profile?.name?.trim();
@@ -106,10 +109,12 @@ export function ToolCard({
</div>
<div className="c">
<div className="hl" data-swap={settling.swap ? "" : undefined}>
<span className={running ? "nm shim" : "nm"}>{head}</span>
{/* 名字是给人读的,id 是给人查的。标签只在它说了名字没说的事时才占地方
(见 tagFor),所以精确的那个字符串挂在这里,一直够得着。 */}
<span className={running ? "nm shim" : "nm"} title={shown}>{head}</span>
{who && <span className="who" title={t("按 {name} 这份技能的设定跑的子代理", { name: who })}>{who}</span>}
{from && <span className="src" title={t("外部服务 {name} 提供的工具", { name: from.server })}>{from.server}</span>}
<span className="tag" title={tagHint(tool)}>{tagFor(tool)}</span>
{tag && <span className="tag" title={tagHint(tool)}>{tag}</span>}
{arg && <span className={streaming ? "arg shim" : "arg"}>{arg}</span>}
{bad && <span className="fail">{badLabel}</span>}
<Cost tools={[tool]} />
@@ -183,6 +188,7 @@ export function ToolCard({
function NestedCall({ tool }: { tool: Tool }) {
const shown = tool.resolvedName || tool.name;
const tag = tagFor(tool);
return (
<div className="call" data-call={tool.id || undefined} data-k={KINDED.has(categoryOf(shown)) ? categoryOf(shown) : undefined}>
<div className="g">
@@ -191,8 +197,8 @@ function NestedCall({ tool }: { tool: Tool }) {
</div>
<div className="c">
<div className="hl">
<span className="nm">{labelFor(shown)}</span>
<span className="tag" title={tagHint(tool)}>{tagFor(tool)}</span>
<span className="nm" title={shown}>{labelFor(shown)}</span>
{tag && <span className="tag" title={tagHint(tool)}>{tag}</span>}
{tool.args && <span className="arg">{shortArgs(tool.args)}</span>}
<Cost tools={[tool]} />
</div>
@@ -234,10 +240,13 @@ const childTokens = (kids: Tool[]) => kids.reduce((n, k) => n + (k.contextTokens
// interpreter instead: the name above already says "Bash", and on a host without
// one the command was actually handed to PowerShell — which is the difference
// between a command that works and the same text failing on '&&'.
const tagFor = (tool: Tool) => {
// Nothing at all when the id is the very string the name above was derived
// from: "Read" beside read_file is one fact printed twice, and it was on every
// card in the record.
const tagFor = (tool: Tool): string | null => {
const ex = tool.execution;
if (ex?.kind === "shell" && ex.shell) return ex.shell;
return mcpOrigin(tool.resolvedName || tool.name)?.tool ?? (tool.resolvedName || tool.name);
return mcpOrigin(tool.resolvedName || tool.name)?.tool ?? null;
};
const SHELL_HINT: Record<string, string> = {