feat(studio): keep pane close confirmation anchored to its tab

Closing a pane that is still working asked first, and asked by replacing
the tab strip with the question. Three tabs became one sentence, so the one
thing needed to answer it — which of them this is about — left the screen
along with everything else. The answer also acted on what was running when
the question went up, which is not the same set as what is running when it
is answered.

The question is now a bubble under the tab it is about, through the same
viewport seam the context menu already uses, and the strip stays exactly
where it was. Escape and a press outside cancel it, changing nothing, and
put the keyboard back on the close button that asked — found on the tab
rather than held since it was pressed, because the pane it sits on may have
gone in the meantime.

What is still live stopped being something the question owns. Only the ids
are kept; which of them still exist and which are still running are read
off the current tabs on every render and again when the answer comes. A
pane that finishes while the question is up is closed without the sentence
about stopping it, one closed from somewhere else is never handed back to
the host, and a question whose panes have all gone takes itself away rather
than offering to stop what is not there. The wording follows the same
derivation, so it cannot say "3 panes" about two.

Closing panes keeps one identity. The confirmation is a view; pane.close
is answered where it was asked, with one|others|all still on data-value,
and cancelling names no action because it performs none.

Three sabotages, because a confirmation that merely looks calmer is the
easy thing to ship here. Trusting the open-time live set, sending the
unfiltered ids, and putting the strip-replacing shape back each turn their
own fixture red — the last one takes five of the ten with it, which is a
fair measure of how much that shape was hiding.

Two things the census caught that a screenshot would not. The effect that
withdraws a dead question first declared its dependency on present.length,
a value derived in that render and traceable from nowhere else; the census
lost the whole cell's provenance with it and five unrelated handlers in
this file went from read-only to "might write". Depending on the sources
instead — the confirmation and the tabs — is both the honest edge and the
resolvable one. Seal A stays held and Seal B stays at 98.

The English catalogue reached the tree's 800-line ceiling on the way, and
the usage and cost section moves out whole rather than the budget moving.
The dialog is named by the question it renders, which is one key fewer and
a name that cannot drift from what is on screen.

testkit gains scrollIntoView. jsdom implements no scrolling, so the strip
keeping the selected tab in view was throwing inside a requestAnimationFrame
on every one of these guards — outside React, reported as an unhandled error
the file still passes with, which is how a real one would go unseen.
This commit is contained in:
esengine committed 2026-09-08 05:05:14 -07:00
1 parent 637c0ff6e1
commit 47e518f943
6 files changed
+315 -111

No files matched your search

+3 -57
View File
@@ -13,11 +13,13 @@ import { EN_TRAJECTORY } from "./en_trajectory";
import { EN_SETTINGS } from "./en_settings";
import { EN_METRICS } from "./en_metrics";
import { EN_STORAGE } from "./en_storage";
import { EN_USAGE } from "./en_usage";
export const EN: Record<string, string> = {
...EN_SETTINGS,
...EN_METRICS,
...EN_STORAGE,
...EN_USAGE,
...EN_KERNEL,
...EN_REMOTE,
...EN_WINDOW,
@@ -619,6 +621,7 @@ export const EN: Record<string, string> = {
// ── 面板标签与关闭确认 ───────────────────────────────────────────
"会话面板": "Session panes",
"关闭这个面板?": "Close this pane?",
"都已经停下了": "They have all stopped",
"关闭 {n} 个面板?": "Close {n} panes?",
"{names} 还在跑": "{names} is still running",
"其中 {n} 个还在跑:{names}": "{n} of them are still running: {names}",
@@ -714,63 +717,6 @@ export const EN: Record<string, string> = {
"开始": "Start",
"连上看看": "Connect",
// ── 用量与成本 ───────────────────────────────────────────────
"一句话说明": "One-line summary",
"什么时候用上": "When it applies",
"相关时想起": "Recalled when relevant",
"每轮都在": "In every turn",
"保存会记成新的一版,旧的还在": "Saving records a new revision; the old one stays",
"第 {n} 版,看旧的": "Revision {n} — see older",
"收起旧版本": "Hide older revisions",
"正在读旧版本…": "Reading older revisions…",
"只有当前这一版。": "Only the current revision exists.",
"第 {n} 版": "Revision {n}",
"恢复这版": "Restore this one",
"恢复也会记成新的一版,这些都还在": "Restoring records a new revision too; these all stay",
"还原这个文件": "Revert this file",
"已还原": "Reverted",
"把这个文件还原到这次改动之前。": "Put this file back the way it was before this change.",
"还原": "Revert",
"这个文件在检查点之后又被改过了。": "This file has changed again since the checkpoint.",
"用检查点的版本覆盖": "Overwrite with the checkpoint's",
"保留现在的": "Keep the current one",
"用量": "Usage",
"用量与成本": "Usage & cost",
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
"正在读记录…": "Reading the records…",
"时间范围": "Time range",
"7 天": "7 days",
"30 天": "30 days",
"全部": "All",
"这段时间送进去的 {tok} 输入 tokens 里,{rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "Of the {tok} input tokens sent in this span, {rate}% hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"其中输入 {n}:命中 {a} · 未命中 {b}": "of which {n} input: {a} hit · {b} missed",
"这个文件不能从检查点还原": "This file cannot be restored from a checkpoint",
"保留了当前版本": "Kept current",
"把这个文件还原到本次会话开始时的样子 —— 会话里对它的其他改动也会一并撤销。": "Put this file back the way it was when the session started — other changes made to it during the session go too.",
"实付": "Paid",
"花费": "Spend",
"含估算": "includes estimates",
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
"缓存命中率": "Cache hit rate",
"越高越省 —— 前缀保持稳定": "Higher is cheaper — keep the prefix stable",
"{n} 次 API 请求": "{n} API requests",
"活跃天数": "Active days",
"共 {n} 天中": "of {n} days",
"每日用量": "Daily usage",
"悬停查看某天": "Hover for a day",
"每日 tokens": "Daily tokens",
"每日支出": "Daily spend",
"与用量分开画:两者量纲不同,共用一根轴会读错": "Drawn apart from usage: different units, one axis would misread",
"另有 {n} 天有用量但没有成本记录 —— 不是那几天没花费,是成本字段后来才开始持久化。": "{n} more days have usage but no cost recorded — not free days; the cost field started being persisted later.",
"按模型": "By model",
"按来源": "By source",
"还没有?去拿一个": "Don't have one? Get one",
"打开 {name} 控制台": "Open the {name} console",
"打不开浏览器,手动访问 {at}": "Could not open a browser — visit {at} yourself",
// ── 回合中的通知(notices.ts 按 code 查表,扫描器看不到)──────
"这一轮模型没有给出回答,正在让它重说一次": "The model gave no answer this round; asking it again",
"模型直接给了答案却没动手,正在要求它先用工具": "The model answered without acting; asking it to use its tools first",
@@ -0,0 +1,60 @@
// Usage and cost, lifted out of en.ts whole: that file had reached the tree's
// 800-line ceiling, and a catalogue grows by the screen. Nothing here was
// retyped — the keys and their English are the ones that were there.
export const EN_USAGE: Record<string, string> = {
"一句话说明": "One-line summary",
"什么时候用上": "When it applies",
"相关时想起": "Recalled when relevant",
"每轮都在": "In every turn",
"保存会记成新的一版,旧的还在": "Saving records a new revision; the old one stays",
"第 {n} 版,看旧的": "Revision {n} — see older",
"收起旧版本": "Hide older revisions",
"正在读旧版本…": "Reading older revisions…",
"只有当前这一版。": "Only the current revision exists.",
"第 {n} 版": "Revision {n}",
"恢复这版": "Restore this one",
"恢复也会记成新的一版,这些都还在": "Restoring records a new revision too; these all stay",
"还原这个文件": "Revert this file",
"已还原": "Reverted",
"把这个文件还原到这次改动之前。": "Put this file back the way it was before this change.",
"还原": "Revert",
"这个文件在检查点之后又被改过了。": "This file has changed again since the checkpoint.",
"用检查点的版本覆盖": "Overwrite with the checkpoint's",
"保留现在的": "Keep the current one",
"用量": "Usage",
"用量与成本": "Usage & cost",
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
"正在读记录…": "Reading the records…",
"时间范围": "Time range",
"7 天": "7 days",
"30 天": "30 days",
"全部": "All",
"这段时间送进去的 {tok} 输入 tokens 里,{rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "Of the {tok} input tokens sent in this span, {rate}% hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"其中输入 {n}:命中 {a} · 未命中 {b}": "of which {n} input: {a} hit · {b} missed",
"这个文件不能从检查点还原": "This file cannot be restored from a checkpoint",
"保留了当前版本": "Kept current",
"把这个文件还原到本次会话开始时的样子 —— 会话里对它的其他改动也会一并撤销。": "Put this file back the way it was when the session started — other changes made to it during the session go too.",
"实付": "Paid",
"花费": "Spend",
"含估算": "includes estimates",
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
"缓存命中率": "Cache hit rate",
"越高越省 —— 前缀保持稳定": "Higher is cheaper — keep the prefix stable",
"{n} 次 API 请求": "{n} API requests",
"活跃天数": "Active days",
"共 {n} 天中": "of {n} days",
"每日用量": "Daily usage",
"悬停查看某天": "Hover for a day",
"每日 tokens": "Daily tokens",
"每日支出": "Daily spend",
"与用量分开画:两者量纲不同,共用一根轴会读错": "Drawn apart from usage: different units, one axis would misread",
"另有 {n} 天有用量但没有成本记录 —— 不是那几天没花费,是成本字段后来才开始持久化。": "{n} more days have usage but no cost recorded — not free days; the cost field started being persisted later.",
"按模型": "By model",
"按来源": "By source",
"还没有?去拿一个": "Don't have one? Get one",
"打开 {name} 控制台": "Open the {name} console",
"打不开浏览器,手动访问 {at}": "Could not open a browser — visit {at} yourself",
};
+15 -9
View File
@@ -4038,7 +4038,7 @@
animation: none !important; animation-delay: 0s !important;
}
/* 队列一次能进来几十行,逐行入场就是几十次移动。行在不在是状态,动不动不是。 */
.queue, .qi, .ovf { animation: none !important }
.queue, .qi, .ovf, .tabconfirm { animation: none !important }
}
.ws[data-busy] { opacity: .5; pointer-events: none }
@@ -5341,22 +5341,28 @@
.tabmenu button:disabled { color: var(--ghost); cursor: default }
/* 关掉还在跑的东西要先问一声,并且说清会停下哪几个 */
/* 就地浮在它所关于的那个页签下面。整条页签条被确认界面换掉时,读起来是界面
换了一张脸,而不是「这一个要确认」—— 而且看不出正在确认的是哪一个。
进场只做位移和淡入;相邻的右键菜单也没有退场动画,一个确认框不该比它重。 */
.tabconfirm {
flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0;
padding: 6px 10px 8px; animation: card .16s var(--ease-out) both;
position: fixed; z-index: 61; width: max-content; max-width: min(340px, 92vw);
display: flex; flex-direction: column; gap: 3px;
padding: 9px 11px 10px; border-radius: var(--r-sm);
background: var(--float); border: 1px solid var(--border);
box-shadow: var(--shadow-float);
animation: tabconfirmin .18s var(--ease-out) both;
}
.tabconfirm .q { flex: none; font: var(--w-med) 12px var(--ui); color: var(--text) }
.tabconfirm .h {
flex: 1; min-width: 0; font: var(--w-reg) 11px var(--ui); color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tabconfirm .a { flex: none; display: flex; gap: 4px }
@keyframes tabconfirmin { from { opacity: 0; transform: translateY(-3px) } to { opacity: 1; transform: none } }
.tabconfirm .q { font: var(--w-med) 12px var(--ui); color: var(--text) }
.tabconfirm .h { font: var(--w-reg) 11px/1.55 var(--ui); color: var(--muted) }
.tabconfirm .a { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end }
.tabconfirm .a button {
padding: 4px 10px; border-radius: var(--r-xs); cursor: pointer;
border: 1px solid var(--border); background: transparent; color: var(--muted);
font: var(--w-med) 11px var(--ui);
}
.tabconfirm .a button:hover { color: var(--text); border-color: var(--faint) }
.tabconfirm .a button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.tabconfirm .a button[data-danger] { color: var(--err); border-color: color-mix(in srgb, var(--err) 45%, var(--border)) }
.tabconfirm .a button[data-danger]:hover { background: var(--err); border-color: var(--err); color: var(--accent-fg) }
+97 -45
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { RuntimeView } from "../port/hub";
import { arrowTabs } from "./tablist";
import { pinToViewport } from "./place";
import { useMarker } from "./marker";
import { useDismiss } from "./dismiss";
export interface TabView {
rt: RuntimeView;
@@ -30,8 +31,13 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
// 裁掉(点了像没反应),所以菜单挂在 fixed 上,位置由触发点决定。
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
const [editing, setEditing] = useState("");
// Which close is waiting on an answer, and what it would take with it.
const [confirm, setConfirm] = useState<{ ids: string[]; live: string[] } | null>(null);
// Which close is waiting on an answer. The ids only: which of them still
// exist and which are still running are read off the current tabs on every
// render and again when the answer comes, because a set captured when the
// question was asked stops being true while it is on screen — a pane can
// finish, or be closed from somewhere else, in the seconds it takes to read.
const [confirm, setConfirm] = useState<{ ids: string[]; kind: string; from: string } | null>(null);
const box = useRef<HTMLDivElement>(null);
// A tab scrolled out of the strip is a tab you cannot see you are on. On the
// next frame: this fires exactly when a second pane opens, which is also when
@@ -69,59 +75,65 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
};
}, [menu]);
// What the question is about, now: ids whose tab is still on the strip, and
// of those the ones still working. Derived rather than stored — see above.
const present = confirm ? confirm.ids.filter((id) => tabs.some((t) => t.rt.id === id)) : [];
const liveNow = present.filter((id) => tabs.find((t) => t.rt.id === id)?.live);
const nameOf = (id: string) => tabs.find((t) => t.rt.id === id)?.title ?? id;
// Back to the control that asked, which is the close button on the tab this
// is about — looked up now rather than held since it was pressed, because
// the pane it sits on may have gone in the meantime. A menu item cannot be
// returned to at all: choosing it is what closed the menu.
const cancel = useCallback(() => {
const at = confirm?.from;
setConfirm(null);
const back = at ? bar.current?.querySelector<HTMLElement>(`[data-pane="${CSS.escape(at)}"] .ptab-x`) : null;
(back ?? bar.current?.querySelector<HTMLElement>('[aria-selected="true"]'))?.focus();
}, [confirm?.from]);
useDismiss(!!confirm, box, cancel);
// Nothing left to close: the question answered itself elsewhere, and leaving
// it up would offer to stop panes that are already gone.
useEffect(() => {
if (confirm && present.length === 0) cancel();
// The sources, not the projection of them: depending on present.length
// declares an edge to a value derived here, which nothing outside this
// render can trace back — and the census loses the whole cell's provenance
// with it, taking five unrelated handlers in this file from read-only to
// "might write".
}, [confirm, tabs, cancel]);
// Closing what is still working needs an answer first; closing idle panes is
// just tidying and happens straight away.
const close = (ids: string[]) => {
const live = ids.filter((id) => tabs.find((t) => t.rt.id === id)?.live);
if (live.length === 0) {
const close = (ids: string[], kind: string, from: string) => {
if (!ids.some((id) => tabs.find((t) => t.rt.id === id)?.live)) {
onClose(ids);
return;
}
setConfirm({ ids, live });
setConfirm({ ids, kind, from });
};
// The answer acts on what is live at this moment, not on what was live when
// the question went up. An id whose pane has since gone is not sent back to
// the host, and a pane that has since finished is closed without the claim
// that closing stops it.
const commit = () => {
const ids = confirm ? confirm.ids.filter((id) => tabs.some((t) => t.rt.id === id)) : [];
setConfirm(null);
if (ids.length > 0) onClose(ids);
};
const others = (id: string) => tabs.filter((t) => t.rt.id !== id).map((t) => t.rt.id);
if (confirm) {
const names = confirm.live
.map((id) => tabs.find((t) => t.rt.id === id)?.title ?? id)
.slice(0, 3)
.join("、");
return (
<div className="panetabs">
<div className="tabconfirm" role="alertdialog">
<span className="q">
{confirm.ids.length > 1 ? t("关闭 {n} 个面板?", { n: confirm.ids.length }) : t("关闭这个面板?")}
</span>
<span className="h">
{confirm.live.length === 1 ? t("{names} 还在跑", { names }) : t("其中 {n} 个还在跑:{names}", { n: confirm.live.length, names })}
{t(",关掉会停下它")}
</span>
<div className="a">
<button onClick={() => setConfirm(null)}>{t("取消")}</button>
<button
data-danger=""
data-action="pane.close"
autoFocus
onClick={() => {
onClose(confirm.ids);
setConfirm(null);
}}
>
{t("关闭")}
</button>
</div>
</div>
</div>
);
}
return (
<div className="panetabs" ref={bar} role="tablist" aria-label={t("会话面板")} onKeyDown={arrowTabs}>
{tabs.map(({ rt, title, run }) => (
<div
key={rt.id}
className="ptab"
data-pane={rt.id}
data-action-click="pane.activate"
role="tab"
tabIndex={rt.id === active ? 0 : -1}
@@ -176,7 +188,7 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
aria-label={t("关闭这个面板")}
onClick={(ev) => {
ev.stopPropagation();
close([rt.id]);
close([rt.id], "one", rt.id);
}}
>
×
@@ -187,6 +199,46 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
{mark && <i className="tabmark" style={{ width: mark.len, transform: `translateX(${mark.at}px)` }} />}
{confirm && present.length > 0 && (
<div
className="tabconfirm"
role="alertdialog"
// Named by the question it is asking, so the accessible name and
// what is on screen cannot drift apart.
aria-labelledby="tabconfirm-q"
ref={(el) => {
// The same node the dismiss watches: without it every press inside
// the question reads as a press away, and answering cancels first.
box.current = el;
if (!el) return;
// Anchored under the tab this is about, through the same viewport
// seam the context menu uses — a bubble near the right edge is
// moved rather than clipped, and the zoom is corrected in one place.
const tab = bar.current?.querySelector<HTMLElement>(`[data-pane="${CSS.escape(confirm.from)}"]`);
const at = (tab ?? bar.current)?.getBoundingClientRect();
if (at) pinToViewport(el, at.left, at.bottom + 6);
}}
>
<span className="q" id="tabconfirm-q">
{present.length > 1 ? t("关闭 {n} 个面板?", { n: present.length }) : t("关闭这个面板?")}
</span>
<span className="h">
{liveNow.length === 0
? t("都已经停下了")
: (liveNow.length === 1
? t("{names} 还在跑", { names: nameOf(liveNow[0]) })
: t("其中 {n} 个还在跑:{names}", { n: liveNow.length, names: liveNow.slice(0, 3).map(nameOf).join("、") })) +
t(",关掉会停下它")}
</span>
<div className="a">
<button onClick={cancel}>{t("取消")}</button>
<button data-danger="" data-action="pane.close" data-value={confirm.kind} autoFocus onClick={commit}>
{t("关闭")}
</button>
</div>
</div>
)}
{menu && (
<div
className="tabmenu"
@@ -204,7 +256,7 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
<button role="menuitem" onClick={() => { const id = menu.id; setMenu(null); setEditing(id); }}>
{t("重命名")}
</button>
<button role="menuitem" data-action="pane.close" data-value="one" onClick={() => { const id = menu.id; setMenu(null); close([id]); }}>
<button role="menuitem" data-action="pane.close" data-value="one" onClick={() => { const id = menu.id; setMenu(null); close([id], "one", id); }}>
{t("关闭")}
</button>
<button
@@ -212,11 +264,11 @@ export function PaneTabs({ tabs, active, showRoot, onFocus, onClose, onRename }:
data-value="others"
role="menuitem"
disabled={tabs.length < 2}
onClick={() => { const id = menu.id; setMenu(null); close(others(id)); }}
onClick={() => { const id = menu.id; setMenu(null); close(others(id), "others", id); }}
>
关闭其他({Math.max(tabs.length - 1, 0)})
</button>
<button role="menuitem" data-action="pane.close" data-value="all" onClick={() => { setMenu(null); close(tabs.map((t) => t.rt.id)); }}>
<button role="menuitem" data-action="pane.close" data-value="all" onClick={() => { const id = menu.id; setMenu(null); close(tabs.map((t) => t.rt.id), "all", id); }}>
全部关闭({tabs.length})
</button>
</div>
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { PaneTabs, type TabView } from "./PaneTabs";
import type { RuntimeView } from "../port/hub";
afterEach(cleanup);
const tab = (id: string, live: boolean): TabView => ({
rt: { id, root: "/w/" + id, name: id } as RuntimeView,
title: id,
run: live ? "running" : "idle",
live,
});
function draw(tabs: TabView[]) {
const onClose = vi.fn();
const props = { active: tabs[0]?.rt.id ?? "", showRoot: false, onFocus: () => {}, onClose, onRename: () => {} };
const view = render(<PaneTabs tabs={tabs} {...props} />);
return {
onClose,
view,
again: (next: TabView[]) => view.rerender(<PaneTabs tabs={next} {...props} />),
strip: () => screen.queryByRole("tablist"),
ask: () => screen.queryByRole("alertdialog"),
closerOf: (id: string) =>
within(document.querySelector(`[data-pane="${id}"]`) as HTMLElement).getByRole("button", { name: "关闭这个面板" }),
};
}
const confirmBtn = () => within(screen.getByRole("alertdialog")).getByRole("button", { name: "关闭" });
describe("closing a pane that is still working", () => {
it("asks nothing at all when nothing is running", async () => {
const { onClose, closerOf, ask } = draw([tab("A", false), tab("B", false)]);
await userEvent.click(closerOf("B"));
expect(onClose).toHaveBeenCalledWith(["B"]);
expect(ask()).toBeNull();
});
// The claim this cut is about. Replacing the strip loses the one thing the
// reader needs to answer: which of them this is about.
it("keeps the whole tab strip in place while it asks", async () => {
const { closerOf, strip, ask } = draw([tab("A", false), tab("B", true), tab("C", false)]);
await userEvent.click(closerOf("B"));
expect(ask()).toBeTruthy();
expect(strip()).toBeTruthy();
expect(screen.getAllByRole("tab").map((el) => el.getAttribute("data-pane"))).toEqual(["A", "B", "C"]);
});
it("puts the keyboard inside the question it just asked", async () => {
const { closerOf } = draw([tab("A", false), tab("B", true)]);
await userEvent.click(closerOf("B"));
expect(screen.getByRole("alertdialog").contains(document.activeElement)).toBe(true);
});
// Rule and mechanism in one: what was live when the question went up does not
// decide anything. A pane that finished while the question was on screen is
// closed without the sentence about stopping it.
it("re-reads what is still running when the answer comes", async () => {
const { onClose, again, closerOf } = draw([tab("A", false), tab("B", true)]);
await userEvent.click(closerOf("B"));
expect(screen.getByRole("alertdialog").textContent).toContain("还在跑");
again([tab("A", false), tab("B", false)]);
await waitFor(() => expect(screen.getByRole("alertdialog").textContent).toContain("都已经停下了"));
expect(screen.getByRole("alertdialog").textContent).not.toContain("还在跑");
await userEvent.click(confirmBtn());
expect(onClose).toHaveBeenCalledWith(["B"]);
});
it("never hands back an id whose pane has gone", async () => {
const { onClose, again } = draw([tab("A", false), tab("B", true), tab("C", true)]);
fireEvent.contextMenu(document.querySelector('[data-pane="B"]') as HTMLElement);
await userEvent.click(screen.getByRole("menuitem", { name: /全部关闭/ }));
expect(screen.getByRole("alertdialog")).toBeTruthy();
again([tab("A", false), tab("B", true)]);
await userEvent.click(confirmBtn());
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith(["A", "B"]);
});
it("takes the question away when everything it was about is gone", async () => {
const { again, closerOf, ask, strip } = draw([tab("A", false), tab("B", true)]);
await userEvent.click(closerOf("B"));
again([tab("A", false)]);
await waitFor(() => expect(ask()).toBeNull());
expect(strip()).toBeTruthy();
});
it("shrinks to what is left rather than asking about panes that went", async () => {
const { again } = draw([tab("A", true), tab("B", true), tab("C", true)]);
fireEvent.contextMenu(document.querySelector('[data-pane="A"]') as HTMLElement);
await userEvent.click(screen.getByRole("menuitem", { name: /全部关闭/ }));
expect(screen.getByRole("alertdialog").textContent).toContain("关闭 3 个面板");
again([tab("A", true), tab("B", true)]);
await waitFor(() => expect(screen.getByRole("alertdialog").textContent).toContain("关闭 2 个面板"));
});
it("cancels on Escape without closing anything, and gives the focus back", async () => {
const { onClose, closerOf, ask } = draw([tab("A", false), tab("B", true)]);
const x = closerOf("B");
await userEvent.click(x);
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(ask()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
expect(document.activeElement).toBe(x);
});
it("cancels on a press outside it, and closes nothing", async () => {
const { onClose, closerOf, ask } = draw([tab("A", false), tab("B", true)]);
await userEvent.click(closerOf("B"));
fireEvent.mouseDown(document.body);
await waitFor(() => expect(ask()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
// One identity for closing panes, wherever it is answered: the confirmation
// is a view, not a second kind of mutation.
it("answers under the same action the close button asked with", async () => {
const { closerOf } = draw([tab("A", true), tab("B", true)]);
expect(closerOf("B").getAttribute("data-action")).toBe("pane.close");
await userEvent.click(closerOf("B"));
const go = confirmBtn();
expect(go.getAttribute("data-action")).toBe("pane.close");
expect(go.getAttribute("data-value")).toBe("one");
expect(within(screen.getByRole("alertdialog")).getByRole("button", { name: "取消" }).hasAttribute("data-action")).toBe(false);
});
});
+7
View File
@@ -30,3 +30,10 @@ globalThis.matchMedia ??= ((query: string) =>
removeEventListener() {},
dispatchEvent: () => false,
}) as MediaQueryList) as typeof globalThis.matchMedia;
// jsdom has no scrolling, so it implements none of the methods that ask for
// it. Left out, a tab strip keeping the selected tab in view throws inside a
// requestAnimationFrame — outside React, where it is reported as an unhandled
// error the file still passes with, which is exactly how a real one would go
// unnoticed later.
Element.prototype.scrollIntoView ??= function scrollIntoView() {};