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 })}} +
+ ); +}