Merge commit 'refs/pr12328' into takeover/12328

This commit is contained in:
yhh committed 2026-10-12 04:34:00 -07:00
commit a4f1729c22
5 files changed
+198 -3

No files matched your search

@@ -2053,6 +2053,26 @@ body { font-size: 14px; line-height: 1.75; }
.studio-todo-body .block[data-b="plan"] > .lbl { display: none; }
@media (prefers-reduced-motion: reduce) { .studio-todo, .studio-todo-body { animation: none } }
.tabs:has(.plan-peek-pop) { z-index: 25; }
.plan-peek { margin-inline-start: auto; flex: none; }
.plan-peek-trigger { display: flex; align-items: center; gap: 6px; }
.plan-peek-trigger .studio-icon { width: 13px; height: 13px; }
.plan-peek-trigger small { font: 500 10px/1 var(--mono); font-variant-numeric: tabular-nums; }
.plan-peek-trigger[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
.plan-peek-pop {
position: absolute; inset-inline-end: var(--pane-gutter); top: calc(100% + 6px);
width: min(360px, calc(100% - 2 * var(--pane-gutter))); max-height: min(420px, calc(55vh / var(--zoom, 1)));
overflow-y: auto; overscroll-behavior: contain; padding: 14px; border-radius: var(--r-lg);
background: var(--surface); color: var(--text); box-shadow: var(--shadow-float);
}
.plan-peek-pop:focus-visible { outline: 2px solid var(--focus); }
.plan-peek-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.plan-peek-head b { font: 600 var(--fs-ui)/1.4 var(--ui); }
.plan-peek-goal { margin: 12px 0; color: var(--muted); font: 450 var(--fs-ui)/1.6 var(--ui); overflow-wrap: anywhere; white-space: pre-wrap; }
.plan-peek-pop .block[data-b="plan"] { margin: 12px 0 0; padding: 0; border: 0; background: none; }
.plan-peek-pop .block[data-b="plan"] > .lbl { display: none; }
.plan-peek-pop .s { animation: none; }
.palette-veil {
position: fixed; inset: 0; z-index: 80; display: grid; place-items: start center;
padding-top: 14vh; background: color-mix(in srgb, var(--page) 62%, transparent);
+1 -1
View File
@@ -403,7 +403,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<PaneNav
view={docked ? "browser" : tab}
onPick={showView}
rows={traj.rows.length}
rows={traj.rows.length} plan={s.plan} goal={status?.goal}
surfaces={surfaces}
/>
@@ -3,9 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "./testkit";
import { PaneNav } from "./PaneNav";
import type { PlanStep } from "../state/session";
afterEach(cleanup);
const plan: PlanStep[] = [
{ text: "复现问题", status: "completed" },
{ text: "修复问题", status: "in_progress", activeForm: "正在修复问题" },
{ text: "检查结果", status: "pending" },
];
const draw = (rows = 3, surfaces = 0) => {
const onPick = vi.fn();
render(<PaneNav view="flow" onPick={onPick} rows={rows} surfaces={surfaces} />);
@@ -13,6 +20,78 @@ const draw = (rows = 3, surfaces = 0) => {
};
describe("pane navigation", () => {
it("opens the current goal and plan without leaving the conversation", () => {
const onPick = vi.fn();
const view = render(<PaneNav view="flow" onPick={onPick} rows={3} surfaces={1} plan={plan} goal="修复当前问题" />);
const trigger = screen.getByRole("button", { name: /计划/ });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("1/3");
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "计划" });
expect(dialog.textContent).toContain("修复当前问题");
expect(dialog.textContent).toContain("正在修复问题");
expect(dialog.querySelectorAll(".s")).toHaveLength(3);
expect(view.container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe("对话");
expect(onPick).not.toHaveBeenCalled();
});
it("projects live plan updates and closes when the current task disappears", () => {
const props = { view: "flow" as const, onPick: vi.fn(), rows: 0, surfaces: 0 };
const view = render(<PaneNav {...props} plan={plan} />);
fireEvent.click(screen.getByRole("button", { name: /计划/ }));
view.rerender(<PaneNav {...props} plan={plan.map((step) => ({ ...step, status: "completed" }))} />);
expect(screen.getByRole("button", { name: /计划/ }).textContent).toContain("3/3");
expect(screen.getByRole("dialog").querySelectorAll(".s[data-done]")).toHaveLength(3);
expect(screen.getByRole("dialog").querySelector("[data-now]")).toBeNull();
view.rerender(<PaneNav {...props} plan={[]} />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("button", { name: /计划/ })).toBeNull();
view.rerender(<PaneNav {...props} plan={plan} />);
expect(screen.getByRole("button", { name: /计划/ }).getAttribute("aria-expanded")).toBe("false");
});
it("dismisses on Escape with focus return and on outside press", () => {
render(<><PaneNav view="flow" onPick={vi.fn()} rows={0} surfaces={0} plan={plan} /><button>outside</button></>);
const trigger = screen.getByRole("button", { name: /计划/ });
fireEvent.click(trigger);
expect(document.activeElement).toBe(screen.getByRole("dialog"));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger);
fireEvent.mouseDown(screen.getByRole("dialog"));
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.mouseDown(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("keeps the plan control outside arrow-key tab navigation and supports toggling closed", () => {
const onPick = vi.fn();
render(<PaneNav view="flow" onPick={onPick} rows={3} surfaces={0} plan={plan} />);
const trigger = screen.getByRole("button", { name: /计划/ });
screen.getByRole("tab", { name: "工作台" }).focus();
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
expect(document.activeElement).toBe(screen.getByRole("tab", { name: "工作台" }));
expect(onPick).not.toHaveBeenCalled();
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
expect(document.activeElement).toBe(screen.getByRole("tab", { name: "运行分析" }));
expect(onPick).toHaveBeenCalledExactlyOnceWith("analysis");
fireEvent.click(trigger);
fireEvent.click(trigger);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("offers a canonical goal before a plan exists and no extra control for an empty task", () => {
const props = { view: "flow" as const, onPick: vi.fn(), rows: 0, surfaces: 0 };
const view = render(<PaneNav {...props} goal="定位测试失败" />);
fireEvent.click(screen.getByRole("button", { name: "目标" }));
expect(screen.getByRole("dialog", { name: "目标" }).textContent).toContain("定位测试失败");
view.rerender(<PaneNav {...props} goal="" />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("button")).toBeNull();
});
it("keeps only conversation, the readable run analysis and the workbench", () => {
draw();
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["对话", "运行分析", "工作台"]);
+46 -2
View File
@@ -1,6 +1,10 @@
import { useRef } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { t } from "../i18n";
import { stepDone, type PlanStep } from "../state/session";
import { arrowTabs } from "./tablist";
import { useDismiss } from "./dismiss";
import { Plan } from "./Plan";
import { StudioIcon } from "./StudioIcon";
export type PaneView = "flow" | "analysis" | "browser";
@@ -8,7 +12,7 @@ export type PaneView = "flow" | "analysis" | "browser";
* purposes; it does not own them, and it never holds a selection of its own —
* a second piece of state saying which detail is open is a second answer to a
* question the pane has already answered. */
export function PaneNav({ view, onPick, rows, surfaces }: {
export function PaneNav({ view, onPick, rows, surfaces, plan, goal }: {
view: PaneView;
onPick: (to: PaneView) => void;
rows: number;
@@ -18,6 +22,8 @@ export function PaneNav({ view, onPick, rows, surfaces }: {
// a number naming one thing and standing for another is what put a 2 over a
// strip with one tab on it.
surfaces: number;
plan?: PlanStep[];
goal?: string;
}) {
const bar = useRef<HTMLDivElement>(null);
// Read at render: t() answers out of a dictionary boot() installs, and a
@@ -53,6 +59,44 @@ export function PaneNav({ view, onPick, rows, surfaces }: {
{surfaces > 0 && <span className="n">{surfaces}</span>}
</button>
</div>
{(plan?.length || goal) ? <PlanPeek steps={plan ?? []} goal={goal} /> : null}
</div>
);
}
function PlanPeek({ steps, goal }: { steps: PlanStep[]; goal?: string }) {
const [open, setOpen] = useState(false);
const box = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const id = useId();
const label = t(steps.length ? "计划" : "目标");
const close = useCallback(() => {
if (panel.current?.contains(document.activeElement)) trigger.current?.focus();
setOpen(false);
}, []);
useDismiss(open, box, close);
useEffect(() => { if (open) panel.current?.focus(); }, [open]);
return (
<div className="plan-peek" ref={box}>
<button type="button" className="tab plan-peek-trigger" ref={trigger}
data-action="plan.fold" aria-expanded={open} aria-haspopup="dialog" aria-controls={open ? id : undefined}
onClick={() => setOpen((was) => !was)}>
<StudioIcon name="list" />
<span>{label}</span>
{steps.length > 0 && <small>{steps.filter(stepDone).length}/{steps.length}</small>}
</button>
{open && (
<div className="plan-peek-pop" id={id} ref={panel} role="dialog" aria-label={label} tabIndex={-1}>
<div className="plan-peek-head">
<b>{label}</b>
<button type="button" className="thbtn" data-action="layer.dismiss" aria-label={t("关闭")} onClick={close}><StudioIcon name="close" /></button>
</div>
{goal && <p className="plan-peek-goal">{goal}</p>}
<Plan steps={steps} />
</div>
)}
</div>
);
}
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { HostTodo } from "../port/port";
import type { RuntimeView } from "../port/hub";
import type { WireEvent } from "../port/wire";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it("reads the host plan after reload, retains the composer entry, and follows todo progress", async () => {
const port = new MockPort();
let todos: HostTodo[] = [
{ content: "复现问题", status: "completed" },
{ content: "修复问题", status: "in_progress", activeForm: "正在修复问题" },
{ content: "验证结果", status: "pending" },
];
vi.spyOn(port, "todos").mockImplementation(async () => todos);
const st = await port.status();
vi.spyOn(port, "status").mockResolvedValue({ ...st, goal: "验证当前修复" });
let emit: (event: WireEvent) => void = () => {};
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, "subscribe").mockImplementation((onEvent, onGap, bootstrap) => {
emit = onEvent;
return subscribe(onEvent, onGap, bootstrap);
});
const view = render(<Pane port={port} rt={{ id: "p1", root: "/w", name: "w" } as RuntimeView}
title="w" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
onOpenProject={() => {}} onKeepHere={() => {}} theme="dark" dockW={560} dockMax={880} onDockW={() => {}} />);
await waitFor(() => expect(view.container.querySelector('.plan-peek-trigger')?.textContent).toContain("1/3"));
const composerPlan = view.container.querySelector<HTMLButtonElement>('.studio-todo [data-action="plan.fold"]')!;
fireEvent.click(composerPlan);
expect(view.container.querySelector(".studio-todo-body")?.textContent).toContain("正在修复问题");
fireEvent.click(view.container.querySelector('.plan-peek-trigger')!);
expect(screen.getByRole("dialog", { name: "计划" }).textContent).toContain("验证当前修复");
expect(screen.getByRole("dialog", { name: "计划" }).textContent).toContain("正在修复问题");
expect(view.container.querySelector('[data-value="flow"]')?.getAttribute("aria-selected")).toBe("true");
todos = todos.map((todo, index) => ({ ...todo, status: index < 2 ? "completed" : "in_progress" }));
await act(async () => emit({ kind: "todo_progress" } as WireEvent));
await waitFor(() => expect(screen.getByRole("dialog", { name: "计划" }).querySelectorAll(".s[data-done]")).toHaveLength(2));
expect(view.container.querySelector('.plan-peek-trigger')?.textContent).toContain("2/3");
expect(view.container.querySelector(".studio-todo-body")?.textContent).toContain("修复问题");
expect(view.container.querySelector(".studio-todo-body")?.textContent).not.toContain("正在修复问题");
todos = todos.map((todo) => ({ ...todo, status: "completed" }));
await act(async () => emit({ kind: "todo_progress" } as WireEvent));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "计划" })).toBeNull());
expect(screen.getByRole("dialog", { name: "目标" }).textContent).toContain("验证当前修复");
expect(view.container.querySelector(".studio-todo")).toBeNull();
});