diff --git a/Agent.md b/Agent.md
index 620e873..fbe7cf0 100644
--- a/Agent.md
+++ b/Agent.md
@@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand)
- Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端"
- Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout)
- - Unit tests `npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
+ - Unit tests `npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions
- **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles
- **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope)
@@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```
Python: `uv run pytest tests/ -v` (730) — import check: `uv run python -c "from emrg.client.app import run_client"`
-GUI: `cd emrg/gui && npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
+GUI: `cd emrg/gui && npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css
index 3a8adcf..566fb44 100644
--- a/emrg/gui/renderer/css/layout.css
+++ b/emrg/gui/renderer/css/layout.css
@@ -397,17 +397,16 @@ body.sidebar-collapsed #app {
background: var(--bg-soft);
font-weight: 600;
}
-/* 打开文件 Tab 条(P3 查看器接入;每文件一个可关闭 Tab) */
+/* 打开文件 Tab 条(P3 查看器接入;rant 17:28 并入 .result-tabs 同排,每文件一个可关闭 Tab) */
.result-tabbar {
display: none;
flex-direction: row;
+ align-items: center;
gap: var(--sp-1);
- padding: var(--sp-1) var(--sp-3);
- border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
-.result-tabbar.has-tabs { display: flex; }
+.result-tabbar.has-tabs { display: inline-flex; }
.result-tabbar::-webkit-scrollbar { display: none; }
.result-filetab {
display: inline-flex;
@@ -423,6 +422,7 @@ body.sidebar-collapsed #app {
white-space: nowrap;
max-width: 160px;
overflow: hidden;
+ height: 22px; /* 与静态 tab 同高(同排并列) */
}
.result-filetab.active {
color: var(--text-1);
@@ -463,43 +463,78 @@ body.sidebar-collapsed #app {
opacity: 0.5;
}
-/* ── P3.1:文件浏览器(懒加载目录树) ── */
+/* ── P3.1:文件浏览器(懒加载目录树,VS Code 风格 rant 2026-08-12T17:28:19) ── */
.result-files {
overflow-y: auto;
+ /* 滚动条 hover 显示(VS Code 行为:平时隐藏,移入文件树区域显示) */
+ scrollbar-width: thin;
+ scrollbar-gutter: stable;
}
+.result-files::-webkit-scrollbar { width: 8px; }
+.result-files::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
+.result-files:hover::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); }
+.result-files::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
+
.ft-row {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 2px var(--sp-2);
+ display: block; /* 块级排布:.ft-head 行 + .ft-kids 子树纵向排列,行高自适应
+ (修复布局 bug:定高 flex-wrap 使下一兄弟行与展开目录首个子项
+ 重叠,headless Chrome 像素实证——兄弟行遮挡子项) */
cursor: pointer;
- border-radius: 4px;
font-size: var(--fs-small);
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
}
-.ft-row:hover {
+.ft-head {
+ display: flex;
+ align-items: center;
+ height: 24px; /* VS Code 紧凑行高(默认 22px,取 24px 适配 14px 图标) */
+ border-radius: 4px;
+}
+.ft-row:hover > .ft-head {
+ background: var(--bg-soft);
+ color: var(--text-1);
+}
+/* 选中态:背景 + 左侧 2px accent 竖条(VS Code active 行) */
+.ft-row.active > .ft-head {
background: var(--bg-soft);
color: var(--text-1);
+ box-shadow: inset 2px 0 0 var(--accent);
}
.ft-dir {
color: var(--text-1);
font-weight: 500;
}
-.ft-arrow {
- width: 14px;
+.ft-icon {
flex: none;
- text-align: center;
- color: var(--text-3);
- font-size: 10px;
+ width: 14px;
+ height: 14px;
+ margin-right: 4px;
+ color: currentColor; /* mono:图标颜色跟随文字色 */
+ fill: currentColor;
+}
+.ft-icon path { fill: currentColor; }
+.ft-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis; /* 超长名省略号 */
+ white-space: nowrap;
}
.ft-kids {
- margin-left: 12px;
+ position: relative; /* 正常流布局:位于 .ft-head 之下(不再作为 flex 行溢出) */
}
.ft-kids.hidden {
display: none;
}
+/* 缩进引导线:嵌套结构天然对齐父行图标列(left:2px ≈ 父行图标起点) */
+.ft-kids::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 0;
+ bottom: 0;
+ border-left: 1px solid var(--border);
+}
.ft-hint {
padding: var(--sp-2) var(--sp-3);
color: var(--text-3);
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index 11b0604..2548b7f 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -91,11 +91,11 @@
-
-
文件浏览器即将推出
diff --git a/emrg/gui/renderer/js/file-tree.js b/emrg/gui/renderer/js/file-tree.js
index ddb4f1e..e07aee2 100644
--- a/emrg/gui/renderer/js/file-tree.js
+++ b/emrg/gui/renderer/js/file-tree.js
@@ -1,11 +1,21 @@
"use strict";
/**
- * file-tree.js — 工作区文件浏览器(workspace panel P3.1,rant 2026-08-11T12:20:35)
+ * file-tree.js — 工作区文件浏览器(workspace panel P3.1,rant 2026-08-11T12:20:35;
+ * VS Code 风格 UI 对齐,rant 2026-08-12T17:28:19)
*
- * Tab「文件」内容:懒加载目录树。
+ * Tab「文件」内容:懒加载目录树,视觉对齐 VS Code Explorer:
* - 根 = 当前激活会话 projectPath(app.js 经 FileTree.setSession(sid, path) 注入)
* - 目录行点击 → emrg.listFiles(path) 懒加载子项(已加载目录缓存,折叠不重新拉取)
* - 文件行点击 → ResultPanel.openFileTab(sid, path)(P2.2 框架 Tab + P3.3 查看器)
+ * - 文件类型图标(mono 内联 SVG,fill=currentColor 跟随文字色):目录折叠/展开
+ * 文件夹图标 + 按扩展名映射文件图标(js/ts/py/md/json/css/html/yml/图片/文本),
+ * 默认通用文件图标
+ * - 递归渲染 + 深度缩进(每层 padding-left = 8 + depth*16,行内 --indent CSS 变量)
+ * - 选中态:文件行点击 → .active(背景 --bg-soft + 左侧 2px accent 竖条,单选)
+ * - 紧凑 24px 行高 + 超长名省略号(.ft-name ellipsis)
+ * - 缩进引导线(.ft-kids::before 1px 竖线,嵌套结构天然对齐父箭头列)
+ * - 展开态持久:Map
(重新 render 不丢,切会话清空)
+ * - 滚动条 hover 显示(.result-files overlay thumb,默认透明)
* - 排序(目录在前)/5000 截断/符号链接不展开/绝对路径校验由 daemon 保证(P1 #661)
* 切会话 → setSession 重设根 + 清缓存(per-session 隔离)
*/
@@ -14,17 +24,55 @@ const FileTree = (() => {
let sid = null;
let root = "";
const cache = new Map(); // path -> { loaded, loading, error, entries }
+ const expanded = new Map(); // path -> bool(展开态持久,VS Code 行为)
+ let selectedPath = null; // 当前选中文件行(单选)
function container() { return $("result-files"); }
function t(key) { return window.EMRG_I18N ? window.EMRG_I18N.t(key) : key; }
+ // ── mono 内联 SVG 图标(16x16 viewBox,fill: currentColor 跟随文字色) ──
+ const ICON = {
+ dirClosed: '',
+ dirOpen: '',
+ fileDefault: '',
+ fileImg: '',
+ fileMd: '',
+ fileJson: '',
+ fileCode: '',
+ fileYml: '',
+ fileTxt: '',
+ };
+ const EXT_ICON = {
+ png: "fileImg", jpg: "fileImg", jpeg: "fileImg", gif: "fileImg", svg: "fileImg",
+ webp: "fileImg", bmp: "fileImg", ico: "fileImg",
+ md: "fileMd", markdown: "fileMd",
+ json: "fileJson",
+ js: "fileCode", jsx: "fileCode", mjs: "fileCode", cjs: "fileCode",
+ ts: "fileCode", tsx: "fileCode",
+ html: "fileCode", htm: "fileCode", css: "fileCode", scss: "fileCode",
+ py: "fileCode", rb: "fileCode", go: "fileCode", rs: "fileCode", java: "fileCode", c: "fileCode", cpp: "fileCode",
+ yml: "fileYml", yaml: "fileYml", toml: "fileYml", cfg: "fileYml", ini: "fileYml",
+ txt: "fileTxt", log: "fileTxt", csv: "fileTxt", tsv: "fileTxt",
+ };
+
+ function iconSvg(d) {
+ const svg = el("svg", { class: "ft-icon", viewBox: "0 0 16 16", width: 14, height: 14, "aria-hidden": "true" });
+ svg.innerHTML = d; // 静态白名单路径,无注入风险
+ return svg;
+ }
+ function iconFor(entry) {
+ if (entry.type === "dir") return expanded.get(entry.path) ? ICON.dirOpen : ICON.dirClosed;
+ const ext = String(entry.name).split(".").pop().toLowerCase();
+ return ICON[EXT_ICON[ext] || "fileDefault"];
+ }
+
function ensure(path) {
let st = cache.get(path);
if (!st) { st = { loaded: false, loading: false, error: false, entries: [] }; cache.set(path, st); }
return st;
}
- /** 设置会话根目录(切会话调用:重设根 + 清缓存) */
+ /** 设置会话根目录(切会话调用:重设根 + 清缓存 + 清展开态/选中态) */
function setSession(s, rootPath) {
sid = s || null;
setRoot(rootPath);
@@ -33,11 +81,13 @@ const FileTree = (() => {
function setRoot(path) {
root = path || "";
cache.clear();
+ expanded.clear();
+ selectedPath = null;
render();
}
- /** 拉取 + 渲染目录子项(st 为 ensure(path) 的缓存态) */
- async function expandDir(path, kidsEl, st) {
+ /** 拉取 + 渲染目录子项(st 为 ensure(path) 的缓存态;childDepth = 子项缩进层级) */
+ async function expandDir(path, kidsEl, st, childDepth) {
if (!st.loaded && !st.loading) {
st.loading = true;
kidsEl.innerHTML = "";
@@ -60,42 +110,72 @@ const FileTree = (() => {
kidsEl.appendChild(el("div", { class: "ft-hint" }, t("result.treeLoadFailed")));
return;
}
- for (const e of st.entries) kidsEl.appendChild(renderEntry(e));
+ for (const e of st.entries) kidsEl.appendChild(renderEntry(e, childDepth));
}
- function renderEntry(entry) {
+ /** 渲染一条目行(depth 相对其父容器;padding-left = 8 + depth*16 对齐箭头列) */
+ function renderEntry(entry, depth) {
+ const row = el("div", {
+ class: "ft-row" + (entry.type === "dir" ? " ft-dir" : " ft-file"),
+ dataset: { path: entry.path },
+ });
+ row.style.paddingLeft = (8 + depth * 16) + "px";
+ // 行头(图标+名称)单独包装:.ft-row 为块级容器(head + kids 纵向排布),
+ // 修复定高 flex-wrap 下兄弟行与展开子项重叠的布局 bug(headless Chrome 像素实证)
+ const head = el("div", { class: "ft-head" });
+ head.appendChild(iconSvg(iconFor(entry)));
+ head.appendChild(el("span", { class: "ft-name" }, entry.name));
+ row.appendChild(head);
if (entry.type === "dir") {
- const row = el("div", { class: "ft-row ft-dir", dataset: { path: entry.path } });
- const arrow = el("span", { class: "ft-arrow" }, "▸");
- row.appendChild(arrow);
- row.appendChild(el("span", { class: "ft-name" }, entry.name));
- const kids = el("div", { class: "ft-kids hidden" });
+ const kids = el("div", { class: "ft-kids" + (expanded.get(entry.path) ? "" : " hidden") });
row.appendChild(kids);
const st = ensure(entry.path);
row.addEventListener("click", (e) => {
if (e && e.stopPropagation) e.stopPropagation();
- toggleDir(entry.path, arrow, kids, st);
+ toggleDir(entry.path, kids, st, depth + 1);
+ });
+ if (expanded.get(entry.path) && !st.loaded) expandDir(entry.path, kids, st, depth + 1);
+ } else {
+ if (selectedPath === entry.path) row.classList.add("active");
+ row.addEventListener("click", (e) => {
+ if (e && e.stopPropagation) e.stopPropagation();
+ selectFile(entry.path);
+ openFile(entry.path);
});
- return row;
}
- const row = el("div", { class: "ft-row ft-file", dataset: { path: entry.path } });
- row.appendChild(el("span", { class: "ft-name" }, entry.name));
- row.addEventListener("click", (e) => {
- if (e && e.stopPropagation) e.stopPropagation();
- openFile(entry.path);
- });
return row;
}
- async function toggleDir(path, arrow, kidsEl, st) {
+ /** 目录展开/折叠:状态持久到 expanded Map(VS Code 行为,重新 render 不丢) */
+ async function toggleDir(path, kidsEl, st, childDepth) {
const wasHidden = kidsEl.classList.contains("hidden");
if (wasHidden) {
- await expandDir(path, kidsEl, st);
+ expanded.set(path, true);
+ await expandDir(path, kidsEl, st, childDepth);
kidsEl.classList.remove("hidden");
- arrow.textContent = "▾";
} else {
+ expanded.set(path, false);
kidsEl.classList.add("hidden");
- arrow.textContent = "▸";
+ }
+ // 目录行图标随展开态切换(重新渲染子行不影响兄弟行)
+ const row = kidsEl.parentNode;
+ if (row) {
+ const ic = row.querySelector(".ft-icon");
+ if (ic) ic.innerHTML = expanded.get(path) ? ICON.dirOpen : ICON.dirClosed;
+ }
+ }
+
+ /** 文件行选中(单选):去旧 active 加新 active */
+ function selectFile(path) {
+ const c = container();
+ if (!c) return;
+ const rows = c.querySelectorAll(".ft-file");
+ for (const r of rows) {
+ if (r.dataset && r.dataset.path === selectedPath) r.classList.remove("active");
+ }
+ selectedPath = path;
+ for (const r of rows) {
+ if (r.dataset && r.dataset.path === path) r.classList.add("active");
}
}
@@ -114,14 +194,18 @@ const FileTree = (() => {
return;
}
const rootName = String(root).split(/[\\/]/).filter(Boolean).pop() || root;
+ expanded.set(root, true); // 根默认展开
const rootRow = el("div", { class: "ft-row ft-dir ft-root", dataset: { path: root } });
- rootRow.appendChild(el("span", { class: "ft-arrow" }, "▾"));
- rootRow.appendChild(el("span", { class: "ft-name" }, rootName));
+ rootRow.style.paddingLeft = "8px";
+ const rootHead = el("div", { class: "ft-head" });
+ rootHead.appendChild(iconSvg(ICON.dirOpen));
+ rootHead.appendChild(el("span", { class: "ft-name" }, rootName));
+ rootRow.appendChild(rootHead);
const kids = el("div", { class: "ft-kids" });
rootRow.appendChild(kids);
c.appendChild(rootRow);
- // 根自动展开(fire-and-forget)
- expandDir(root, kids, ensure(root));
+ // 根自动展开(fire-and-forget;根子项 depth=1)
+ expandDir(root, kids, ensure(root), 1);
}
return { setRoot, setSession };
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index 8d871d5..d5e00f9 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -1258,6 +1258,110 @@ test("P3:切会话 → 文件树根跟随(per-session)", async () => {
assert.strictEqual(rootName2, "other", "s2 根 = other");
});
+test("P3.5(rant 17:28):VS Code 风格图标 — 目录折叠/展开 + 文件类型映射", async () => {
+ const { ctx, els } = makeSandbox();
+ await tick();
+ await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx);
+ await tick();
+ const pathCount = (ic) => (ic ? (ic.innerHTML.match(/ r.dataset.path === "/proj/README.md");
+ const mdIcon = mdRow.querySelector(".ft-icon");
+ assert.ok(mdIcon, "文件行应有 .ft-icon");
+ assert.ok(mdIcon.innerHTML.includes("M4.5 6h7M4.5 8.5h7M4.5 11h4.5"), "md → fileMd 横线组");
+ const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py");
+ assert.ok(pyRow.querySelector(".ft-icon").innerHTML.includes("M6.5 6.5L4.5 8.5l2 2"), "py → fileCode 尖括号");
+});
+
+test("P3.5(rant 17:28):文件行选中态 — 点击选中 + 单选切换", async () => {
+ const { ctx, els } = makeSandbox();
+ await tick();
+ await vm.runInContext('ResultPanel.switchSession("s1")', ctx);
+ await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx);
+ await tick();
+ const mdRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/README.md");
+ // 点击 → active
+ mdRow.dispatch("click", { stopPropagation() {} });
+ assert.ok(mdRow.classList.contains("active"), "点击文件行应加 .active");
+ // 点击 src 目录 → 选中不移除(目录不高亮),展开目录
+ const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1];
+ srcRow.dispatch("click", { stopPropagation() {} });
+ await tick();
+ assert.ok(mdRow.classList.contains("active"), "目录行点击不应清除文件选中");
+ assert.ok(!srcRow.classList.contains("active"), "目录行不应有 active");
+ // 点击另一个文件 → 单选切换
+ const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py");
+ pyRow.dispatch("click", { stopPropagation() {} });
+ assert.ok(pyRow.classList.contains("active"), "新点击文件应 active");
+ assert.ok(!mdRow.classList.contains("active"), "旧选中应移除(单选)");
+});
+
+test("P3.5(rant 17:28):深度缩进 — 子行 padding-left 递增 16px", async () => {
+ const { ctx, els } = makeSandbox();
+ await tick();
+ await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx);
+ await tick();
+ const rootRow = els["result-files"].querySelectorAll(".ft-root")[0];
+ assert.strictEqual(rootRow.style.paddingLeft, "8px", "根 depth=0 → 8px");
+ // 根子项 README.md(depth 1)
+ const mdRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/README.md");
+ assert.strictEqual(mdRow.style.paddingLeft, "24px", "根子项 depth=1 → 24px");
+ // 展开 src → main.py(depth 2)
+ const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1];
+ srcRow.dispatch("click", { stopPropagation() {} });
+ await tick();
+ const pyRow = [...els["result-files"].querySelectorAll(".ft-file")].find((r) => r.dataset.path === "/proj/src/main.py");
+ assert.strictEqual(pyRow.style.paddingLeft, "40px", "src 子项 depth=2 → 40px");
+ // 结构契约(修复布局 bug):.ft-head 包装图标+名称,.ft-kids 紧随其后——块级排布,
+ // 兄弟行不再与展开子项重叠(headless Chrome 像素实证:定高 flex-wrap 下行遮挡子项)
+ const srcHead = srcRow.children[0];
+ assert.ok(srcHead && srcHead.classList.contains("ft-head"), "目录行第一个子元素应为 .ft-head 包装");
+ assert.ok(srcHead.querySelector(".ft-icon"), "图标在 .ft-head 内");
+ assert.strictEqual(srcRow.children[1], srcRow.querySelector(".ft-kids"), ".ft-kids 紧随 .ft-head");
+});
+
+test("P3.5(rant 17:28):展开态持久 + 滚动条 hover 样式(CSS 源级断言)", async () => {
+ const { ctx, els } = makeSandbox();
+ await tick();
+ await vm.runInContext('FileTree.setSession("s1", "/proj")', ctx);
+ await tick();
+ const srcRow = els["result-files"].querySelectorAll(".ft-dir")[1];
+ // 展开 src → 折叠 → 再展开:缓存命中 + 展开态持久(expandDir 不重复拉取已由既有测试覆盖,这里验证 DOM 状态往返)
+ srcRow.dispatch("click", { stopPropagation() {} });
+ await tick();
+ assert.ok(!srcRow.querySelector(".ft-kids").classList.contains("hidden"), "展开后 kids 可见");
+ srcRow.dispatch("click", { stopPropagation() {} });
+ assert.ok(srcRow.querySelector(".ft-kids").classList.contains("hidden"), "折叠后 kids 隐藏");
+ srcRow.dispatch("click", { stopPropagation() {} });
+ await tick();
+ assert.ok(!srcRow.querySelector(".ft-kids").classList.contains("hidden"), "再次展开恢复可见");
+ const iconHtml = srcRow.querySelector(".ft-icon").innerHTML;
+ assert.strictEqual((iconHtml.match(/= 0 && tabbarIdx >= 0 && toggleIdx >= 0, "三个锚点都应存在");
+ assert.ok(tabsIdx < tabbarIdx && tabbarIdx < toggleIdx, "tabbar 应嵌套在 result-tabs 内(同排),旧布局 toggle 在 tabbar 之前");
+});
+
test("工具调用上限中断 → 系统提示可继续(对齐 TUI,跨项目教训)", async () => {
const { ctx, els } = makeSandbox({});
await tick();