From c842f014f4ca83427c3ded8d7b17adb968b15a76 Mon Sep 17 00:00:00 2001 From: yhh Date: Sun, 11 Oct 2026 06:53:27 -0700 Subject: [PATCH] fix(studio): a running call with no output yet opens and says what it is doing In the activity group a running call that had printed nothing was a row that did nothing when clicked, and a long command was clipped to one line, so a call waiting out a timeout looked frozen. Such a row now opens to its input, says it has no output yet and for how long it has run, names the command's own timeout when it carries one, and its hover text holds the whole command. --- desktop/frontend-next/perf/README.md | 1 + desktop/frontend-next/perf/harness.tsx | 1 + desktop/frontend-next/perf/runningtool.mjs | 29 +++++++++ desktop/frontend-next/src/i18n/en.ts | 3 + desktop/frontend-next/src/main.tsx | 1 + desktop/frontend-next/src/styles/waitnote.css | 1 + desktop/frontend-next/src/ui/Cost.tsx | 2 +- .../frontend-next/src/ui/cards/ToolCard.tsx | 10 ++- .../src/ui/cards/WaitNote.test.tsx | 65 +++++++++++++++++++ .../frontend-next/src/ui/cards/WaitNote.tsx | 28 ++++++++ 10 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 desktop/frontend-next/perf/runningtool.mjs create mode 100644 desktop/frontend-next/src/styles/waitnote.css create mode 100644 desktop/frontend-next/src/ui/cards/WaitNote.test.tsx create mode 100644 desktop/frontend-next/src/ui/cards/WaitNote.tsx diff --git a/desktop/frontend-next/perf/README.md b/desktop/frontend-next/perf/README.md index ba08bcbcee..2917e48492 100644 --- a/desktop/frontend-next/perf/README.md +++ b/desktop/frontend-next/perf/README.md @@ -68,6 +68,7 @@ node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到 node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样 node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄) node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed) +node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时 node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏 node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载) ``` diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx index bcb975f1c7..4500e7ea4b 100644 --- a/desktop/frontend-next/perf/harness.tsx +++ b/desktop/frontend-next/perf/harness.tsx @@ -6,6 +6,7 @@ import "../src/styles/app.css"; import "../src/styles/dock.css"; import "../src/styles/studio.css"; import "../src/styles/tips.css"; +import "../src/styles/waitnote.css"; import "../src/styles/feedback.css"; import { App } from "../src/ui/App"; import { MockHub } from "../src/port/mock_hub"; diff --git a/desktop/frontend-next/perf/runningtool.mjs b/desktop/frontend-next/perf/runningtool.mjs new file mode 100644 index 0000000000..8caa8b5452 --- /dev/null +++ b/desktop/frontend-next/perf/runningtool.mjs @@ -0,0 +1,29 @@ +// A running call with no output yet: its row opens, says it is still running and for how long, and names its own timeout. +import { chromium } from "playwright"; +const fails = []; +const check = (name, ok, detail = "") => { console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!ok) fails.push(name); }; +const [,, out, w] = process.argv; +const b = await chromium.launch(); +const p = await b.newPage({ viewport: { width: +w || 1100, height: 700 } }); +await p.goto((process.env.PERF_URL ?? "http://localhost:4521/perf.html") + "?pref=zh", { waitUntil: "networkidle" }); await p.waitForSelector(".app"); await p.waitForTimeout(400); +await p.evaluate(async () => { + window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "list them" }); + window.__feed({ kind: "text", text: "我来看一下目录。" }); + const cmd = 'cd /Users/me/Documents/AI项目/音频搜索 && for f in *.md; do echo "=== $f ($(wc -l < "$f") 行)"; head -20 "$f"; done; ls -la /Users/me/Documents/ ; echo ---; find /Users/me/Documents -maxdepth 3 -name "*.md" | head -50'; + window.__feed({ kind: "tool_dispatch", tool: { id: "b1", name: "bash", args: JSON.stringify({ command: cmd, timeout_seconds: 900 }) } }); + await new Promise((r) => setTimeout(r, 300)); +}); +await p.waitForTimeout(600); +if (out) await p.screenshot({ path: out + "-1.png" }); +const row = p.locator(".activity-group .call").first(); +check("the running row is a disclosure", (await row.locator("details.tool-disclosure").count()) === 1); +await row.locator("summary").click().catch(() => {}); +await p.waitForTimeout(1500); +const g = await p.evaluate(() => ({ note: document.querySelector(".activity-call-wait")?.textContent ?? "", input: !!document.querySelector(".activity-call-input"), title: document.querySelector(".activity-group .arg")?.getAttribute("title") ?? "" })); +check("opened, it shows the whole command", g.input && g.title.includes("maxdepth 3")); +check("and says it is still running with no output, and for how long", /命令仍在运行/.test(g.note) && /已运行/.test(g.note), g.note); +check("and names the command's own timeout", /超时上限 900 秒/.test(g.note), g.note); +if (out) await p.screenshot({ path: out + "-2.png" }); +await b.close(); +console.log(fails.length ? `\n${fails.length} failed` : "\nall ok"); +process.exit(fails.length ? 1 : 0); diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 3f8c3cc9c1..b1ed27eda7 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1044,6 +1044,9 @@ export const EN: Record = { "确定": "OK", "重试": "Retry", "设置": "Settings", + "还没有输出,命令仍在运行": "No output yet; the command is still running", + "已运行 {time}": "running for {time}", + "这条命令自带的超时上限 {n} 秒": "this command's own timeout is {n}s", "专注": "Focus", "退出专注": "Leave focus", "专注:收起两侧栏": "Focus: put the side columns away", diff --git a/desktop/frontend-next/src/main.tsx b/desktop/frontend-next/src/main.tsx index 8f41d0cc4a..e4b2deaa66 100644 --- a/desktop/frontend-next/src/main.tsx +++ b/desktop/frontend-next/src/main.tsx @@ -8,6 +8,7 @@ import "./styles/app.css"; import "./styles/dock.css"; import "./styles/studio.css"; import "./styles/tips.css"; +import "./styles/waitnote.css"; import "./styles/feedback.css"; import "./styles/community.css"; import "./styles/sponsor.css"; diff --git a/desktop/frontend-next/src/styles/waitnote.css b/desktop/frontend-next/src/styles/waitnote.css new file mode 100644 index 0000000000..6ee3bb2c09 --- /dev/null +++ b/desktop/frontend-next/src/styles/waitnote.css @@ -0,0 +1 @@ +.activity-call-wait { margin: 6px 0; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); } diff --git a/desktop/frontend-next/src/ui/Cost.tsx b/desktop/frontend-next/src/ui/Cost.tsx index 095f70eb47..d4da825ae4 100644 --- a/desktop/frontend-next/src/ui/Cost.tsx +++ b/desktop/frontend-next/src/ui/Cost.tsx @@ -22,7 +22,7 @@ function tookWidth(ms: number) { // counts on the window's own clock from the moment the window saw the call // running, and says nothing about a call that was already running when it // arrived, because that is a duration this window does not know. -function useWatched(running: boolean): number { +export function useWatched(running: boolean): number { const from = useRef(0); const [now, setNow] = useState(0); useEffect(() => { diff --git a/desktop/frontend-next/src/ui/cards/ToolCard.tsx b/desktop/frontend-next/src/ui/cards/ToolCard.tsx index 2d5b7cf7a6..fdfb9a801c 100644 --- a/desktop/frontend-next/src/ui/cards/ToolCard.tsx +++ b/desktop/frontend-next/src/ui/cards/ToolCard.tsx @@ -11,6 +11,7 @@ import { Cost } from "../Cost"; import { seconds, tokens } from "../../i18n/format"; import { currentStep, parsePlan, stepDone } from "../../state/session"; import { DiffView } from "./DiffView"; +import { WaitNote } from "./WaitNote"; import { Term, ToolOutput, BoundNote } from "./ToolOutput"; import { ExtensionView } from "./ExtensionView"; import { toolChangedFile, toolFailed, toolFailureLabel, toolRefusalReason } from "./outcome"; @@ -117,11 +118,13 @@ export function ToolCard({ }, [bad]); const open = touched ?? start; const changes = changeCounts(tool); + const activityArgs = activity ? prettyArgs(tool.args) : ""; + // A call being watched in the activity group opens to its input even with no + // output yet: a row that cannot open is the one that looks stuck. const hasBody = Boolean( takeover || tool.diff || goal || tool.name === "todo_write" || - (tool.output && !echoed && children.length === 0) || tool.err || children.length, + (tool.output && !echoed && children.length === 0) || tool.err || children.length || (activity && running && activityArgs), ); - const activityArgs = activity ? prettyArgs(tool.args) : ""; const activityResult = Boolean( takeover || tool.diff || goal || tool.name === "todo_write" || tool.output || tool.err || children.length, ); @@ -133,7 +136,7 @@ export function ToolCard({ {who && {who}} {from && {from.server}} {tag && {tag}} - {arg && {arg}} + {arg && {arg}} {changes && ( +{changes.added}−{changes.removed} @@ -166,6 +169,7 @@ export function ToolCard({
{activityArgs}
)} + {running && !tool.output && !tool.err && children.length === 0 && } {activityResult && (
{t("结果")} diff --git a/desktop/frontend-next/src/ui/cards/WaitNote.test.tsx b/desktop/frontend-next/src/ui/cards/WaitNote.test.tsx new file mode 100644 index 0000000000..95e8850bf4 --- /dev/null +++ b/desktop/frontend-next/src/ui/cards/WaitNote.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import "../testkit"; +import { ToolCard } from "./ToolCard"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const bash = (over: Record = {}) => ({ id: "b1", name: "bash", args: JSON.stringify({ command: "sleep 600" }), readOnly: false, ...over }) as Parameters[0]["tool"]; +const note = (c: HTMLElement) => c.querySelector(".activity-call-wait"); +const open = (c: HTMLElement) => { + const d = c.querySelector("details")!; + d.open = true; + act(() => { d.dispatchEvent(new Event("toggle")); }); +}; + +describe("a running call that has printed nothing", () => { + it("says it is still running instead of sitting blank, and counts how long", () => { + const { container } = render(); + open(container); + expect(note(container)?.textContent).toContain("命令仍在运行"); + act(() => vi.advanceTimersByTime(5000)); + expect(note(container)?.textContent).toMatch(/已运行 5/); + }); + + it("names the timeout only when the command carries one", () => { + const withLimit = render(); + open(withLimit.container); + expect(note(withLimit.container)?.textContent).toContain("超时上限 300 秒"); + cleanup(); + const without = render(); + open(without.container); + expect(note(without.container)?.textContent).not.toContain("超时"); + }); + + it("goes away once output arrives, and is not drawn for a finished call or outside the detail view", () => { + const { container, rerender } = render(); + open(container); + rerender(); + expect(note(container)).toBeNull(); + rerender(); + expect(note(container)).toBeNull(); + rerender(); + expect(note(container)).toBeNull(); + }); + + it("opens at all: a running call with no output yet used to be a row that did nothing", () => { + const { container } = render(); + expect(container.querySelector("details.tool-disclosure")).not.toBeNull(); + expect(container.querySelector(".activity-call-input")).not.toBeNull(); + cleanup(); + expect(render().container.querySelector("details")).toBeNull(); + }); + + it("puts the whole command in the row's hover text, since the row clips it to one line", () => { + const long = "cd /a/very/long/path && for f in *.md; do wc -l \"$f\"; done; find . -maxdepth 3"; + const { container } = render(); + expect(container.querySelector(".arg")?.getAttribute("title")).toContain("maxdepth 3"); + }); +}); diff --git a/desktop/frontend-next/src/ui/cards/WaitNote.tsx b/desktop/frontend-next/src/ui/cards/WaitNote.tsx new file mode 100644 index 0000000000..edfbdfca9f --- /dev/null +++ b/desktop/frontend-next/src/ui/cards/WaitNote.tsx @@ -0,0 +1,28 @@ +import { t } from "../../i18n"; +import { seconds } from "../../i18n/format"; +import { useWatched } from "../Cost"; + +// The only timeout the page can state is the one the call carries itself; the +// host's own default is configuration this window does not hold. +function ownTimeout(args: string | undefined): number { + try { + const n = Number((JSON.parse(args ?? "{}") as { timeout_seconds?: unknown }).timeout_seconds); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } +} + +/** What a running call with no output yet says about itself, so a card that sits + * still reads as "still running, nothing printed" rather than as stuck. */ +export function WaitNote({ args }: { args?: string }) { + const watched = useWatched(true); + const limit = ownTimeout(args); + return ( +
+ {t("还没有输出,命令仍在运行")} + {watched >= 1000 && <> · {t("已运行 {time}", { time: seconds(watched, 0) })}} + {limit > 0 && <> · {t("这条命令自带的超时上限 {n} 秒", { n: limit })}} +
+ ); +}