mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12328' into takeover/12328
This commit is contained in:
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);
|
||||
|
||||
@@ -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(["对话", "运行分析", "工作台"]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user