Merge commit 'refs/pr12347' into takeover/12347

# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/ui/Workspaces.tsx
This commit is contained in:
yhh committed 2026-10-12 04:59:51 -07:00
commit 6766cc0042
6 files changed
+411 -5

No files matched your search

+3
View File
@@ -63,6 +63,9 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -3,6 +3,20 @@
// en_settings and en_kernel were — one screen's worth of wording, read together.
export const EN_WINDOW: Record<string, string> = {
"选择本机会话": "Select local sessions",
"选择会话:{name}": "Select session: {name}",
"已选 {n} 个会话": "{n} sessions selected",
"完成选择": "Done selecting",
"全选当前显示的会话": "Select all displayed sessions",
"取消当前显示会话的选择": "Deselect displayed sessions",
"取消全选": "Deselect all",
"全选可见": "Select visible",
"归档所选会话": "Archive selected sessions",
"取消所选会话归档": "Unarchive selected sessions",
"删除所选会话": "Delete selected sessions",
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
"正在处理所选会话…": "Processing selected sessions…",
"删除 {n} 个会话?": "Delete {n} sessions?",
"运行指标": "Run metrics",
"时间线": "Timeline",
"定位": "Locate",
@@ -0,0 +1,18 @@
.session-batch-entry { display: flex; justify-content: flex-end; padding: 3px 18px; }
.session-batch-entry button, .session-batch button { font: var(--w-med) var(--fs-micro) var(--ui); }
.session-batch-entry button { padding: 4px 6px; border: 0; border-radius: 4px; color: var(--muted); background: transparent; cursor: pointer; }
.session-batch-entry button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
.session-batch { margin: 4px 12px 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--page); }
.session-batch-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-size: var(--fs-micro); }
.session-batch-head button { flex: none; color: var(--muted); background: transparent; padding: 4px; border: 0; border-radius: 4px; cursor: pointer; }
.session-batch-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.session-batch-actions button { padding: 5px 7px; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
.session-batch-actions button:hover:not(:disabled) { background: var(--overlay); }
.session-batch [data-action="session.batch-delete"] { color: var(--err-ink); }
.session-batch p { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-micro); }
.session-batch button:disabled, .session-batch-entry button:disabled { opacity: .45; cursor: default; }
.session-batch button:focus-visible, .session-batch-entry button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.session-batch .wsconfirm { margin: 8px 0 0; flex-wrap: wrap; gap: 8px; }
.session-batch .wsconfirm-t { flex-basis: 100%; }
.session-batch-names { font-size: var(--fs-micro); color: var(--text); overflow-wrap: anywhere; max-height: 100px; overflow-y: auto; }
.rail .session-select { flex: none; width: 14px; height: 14px; margin: 0 6px 0 0; accent-color: var(--accent); }
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import { current, t } from "../i18n";
import type { HubPort, TreeSession } from "../port/hub";
import { clearDraftForSession } from "./drafts";
import "../styles/session-batch.css";
export function SessionBatch({ hub, selection, selected, displayed, onSelect, onExit, onBusy, onClose, onArchive, liveIds, reload, onError }: {
hub: HubPort;
selection: Set<string> | null;
selected: TreeSession[];
displayed: TreeSession[];
onSelect: (next: Set<string> | null) => void;
onExit: () => void;
onBusy: (busy: boolean) => void;
onClose: (ids: string[]) => Promise<void>;
onArchive: (path: string, archived: boolean, runtimeId?: string) => Promise<void>;
liveIds: (ids: string[]) => string[];
reload: () => Promise<void>;
onError: (e: unknown) => void;
}) {
const [phase, setPhase] = useState<"idle" | "confirm" | "archive" | "restore" | "delete">("idle");
const entryButton = useRef<HTMLButtonElement>(null);
const wasSelecting = useRef(selection !== null);
const deleteButton = useRef<HTMLButtonElement>(null);
const doneButton = useRef<HTMLButtonElement>(null);
const pending = phase !== "idle" && phase !== "confirm";
const running = liveIds(selected.flatMap((row) => row.runtimeId ? [row.runtimeId] : [])).length > 0;
const blocked = pending || running || selected.length === 0;
const all = displayed.length > 0 && displayed.every((row) => selection?.has(row.path));
const restore = selected.length > 0 && selected.every((row) => row.archived);
const cancel = () => { setPhase("idle"); onBusy(false); deleteButton.current?.focus(); };
useEffect(() => {
if (wasSelecting.current && selection === null) {
if (entryButton.current?.disabled) onExit(); else entryButton.current?.focus();
}
wasSelecting.current = selection !== null;
}, [selection !== null]);
useEffect(() => {
if (phase === "idle" && selection !== null) (deleteButton.current?.disabled ? doneButton.current : deleteButton.current)?.focus();
}, [phase, selection !== null]);
const run = async (action: "archive" | "restore" | "delete") => {
if (blocked) return;
setPhase(action);
onBusy(true);
const failed = new Set<string>();
let firstError: unknown;
let removed = false;
for (const row of selected) {
try {
if (row.runtimeId && liveIds([row.runtimeId]).length > 0) throw new Error(t("所选会话正在运行,停止后才能操作"));
if (action === "delete") {
if (row.runtimeId) await onClose([row.runtimeId]);
await hub.removeSession(row.path);
clearDraftForSession("", row.path);
removed = true;
} else {
await onArchive(row.path, action === "archive", row.runtimeId);
}
} catch (e) {
failed.add(row.path);
firstError ??= e;
}
}
if (removed) {
try { await reload(); } catch (e) { firstError ??= e; }
}
onSelect(failed);
setPhase("idle");
onBusy(false);
if (firstError) onError(firstError);
deleteButton.current?.focus();
};
if (selection === null) return (
<div className="session-batch-entry">
<button ref={entryButton} data-action="session.selection" data-value="start" disabled={displayed.length === 0} onClick={() => onSelect(new Set())}>{t("选择本机会话")}</button>
</div>
);
return (
<div className="session-batch" aria-busy={pending}>
<div className="session-batch-head">
<span role="status">{t("已选 {n} 个会话", { n: selected.length })}</span>
<button ref={doneButton} data-action="session.selection" data-value="end" disabled={pending} onClick={() => { cancel(); onSelect(null); }}>{t("完成选择")}</button>
</div>
<div className="session-batch-actions">
<button data-action="session.selection" data-value={all ? "clear" : "all"} disabled={phase !== "idle" || displayed.length === 0}
aria-label={t(all ? "取消当前显示会话的选择" : "全选当前显示的会话")}
onClick={() => {
const next = new Set(selection);
for (const row of displayed) { if (all) next.delete(row.path); else next.add(row.path); }
onSelect(next);
}}>{t(all ? "取消全选" : "全选可见")}</button>
<button data-action="session.batch-archive" data-value={restore ? "restore" : "archive"} disabled={blocked || phase === "confirm"}
aria-label={t(restore ? "取消所选会话归档" : "归档所选会话")} onClick={() => void run(restore ? "restore" : "archive")}>{t(restore ? "取消归档" : "归档")}</button>
<button ref={deleteButton} data-action="session.batch-delete" disabled={blocked || phase === "confirm"} aria-label={t("删除所选会话")}
onClick={() => { setPhase("confirm"); onBusy(true); }}>{t("删除")}</button>
</div>
{running && <p>{t("所选会话正在运行,停止后才能操作")}</p>}
{pending && <p role="status">{t("正在处理所选会话…")}</p>}
{phase === "confirm" && (
<div className="wsconfirm" role="alertdialog" aria-label={t("删除 {n} 个会话?", { n: selected.length })}
data-action-keydown="layer.dismiss" onKeyDown={(ev) => { if (ev.key === "Escape") { ev.stopPropagation(); cancel(); } }}>
<div className="wsconfirm-t">
<span className="q">{t("删除 {n} 个会话?", { n: selected.length })}</span>
<span className="session-batch-names">{selected.map((row) => row.title || row.name).join(current() === "zh" ? "、" : ", ")}</span>
<span className="h">{t("连同其记录一并删除")}</span>
</div>
<div className="wsconfirm-a">
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("取消")}</button>
<button data-action="session.batch-delete" data-danger="" disabled={blocked} onClick={() => void run("delete")}>{t("删除")}</button>
</div>
</div>
)}
</div>
);
}
+37 -5
View File
@@ -23,6 +23,7 @@ import { SessionMenu } from "./SessionMenu";
import { Confirm, removeHint } from "./WorkspaceConfirm";
import { UnreadCount, UnreadDot } from "./UnreadMark";
import { NO_PAGING, atCap, sessionCount, unloaded, type TreePaging } from "./treewindow";
import { SessionBatch } from "./SessionBatch";
const parentOf = (root: string) => root.replace(/[/\\]+$/, "").split(/[/\\]/).slice(-2, -1)[0] ?? "";
@@ -78,10 +79,18 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
// holds it.
const [hereShut, setHereShut] = useState(false);
const [confirm, setConfirm] = useState("");
const [selection, setSelection] = useState<Set<string> | null>(null);
// The row a Delete key asked about. The question replaces that row, so focus
// goes back to it once the question is gone, or a keyboard is left nowhere.
const askedByKey = useRef("");
const needle = useRailQuery();
useEffect(() => { setSelection((current) => current === null ? null : new Set()); }, [needle, scope]);
const toggleSelection = (path: string) => setSelection((current) => {
if (!current) return current;
const next = new Set(current);
if (next.has(path)) next.delete(path); else next.add(path);
return next;
});
const treeKeys = useTreeKeys();
// Renaming is a pencil, not a double-click: a single click already opens the
// session, so a double one would open it twice on the way to the edit.
@@ -317,11 +326,26 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
paging.view({ scope: kernelScope, q: asked });
}, [paging, kernelScope, asked]);
const pagedScope = scope === "all" || scope === "archived";
const displayed = shutHere ? [] : shownTree.filter((ws) => needle || !folded.has(ws.root))
.flatMap((ws) => ws.sessions);
const selected = [...new Map(displayed.filter((row) => selection?.has(row.path)).map((row) => [row.path, row])).values()];
useEffect(() => {
const paths = new Set(displayed.map((row) => row.path));
setSelection((current) => {
if (!current) return current;
const next = new Set([...current].filter((path) => paths.has(path)));
return next.size === current.size ? current : next;
});
}, [tree, folded, hereShut, needle, scope, pinned, runs]);
return (
<>
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
onExit={() => treeKeys.ref.current?.querySelector<HTMLElement>('[role="treeitem"]')?.focus()}
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
<div className="scroll">
<div role="tree" aria-label={t("机器、工作区与会话")} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
<div role="tree" aria-label={t("机器、工作区与会话")} aria-multiselectable={selection !== null} inert={busy === "batch"} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
{/* This machine is the first row of the list rather than another kind of
thing, and its add button sits where a host's does: open a folder
on this machine. */}
@@ -499,23 +523,24 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
<Fragment key={session.path}>
<div
ref={sessionMenu === session.path ? sessionMenuBox : undefined}
data-action-click="session.open"
data-action-click={selection ? "session.selection" : "session.open"}
data-action-contextmenu="session.menu"
aria-haspopup="menu"
data-action-keydown={["session.open", "session.delete"]}
data-action-keydown={selection ? ["session.selection"] : ["session.open", "session.delete"]}
data-target={session.path}
className="sessrow sessrow-context"
role="treeitem"
aria-selected={on}
aria-selected={selection ? selection.has(session.path) : on}
data-on={on ? "" : undefined}
data-live={session.runtimeId ? "" : undefined}
data-run={run === "idle" ? undefined : run}
data-unread={session.unread ? "" : undefined}
data-just-done={session.runtimeId && justDone.has(session.runtimeId) ? "" : undefined}
data-busy={opening === session.path ? "" : undefined}
onClick={() => void pick(ws, session)}
onClick={() => selection ? toggleSelection(session.path) : void pick(ws, session)}
onContextMenu={(ev) => {
if ((ev.target as HTMLElement).closest("input, textarea, [role='menu']")) return;
if (selection) { ev.preventDefault(); return; }
ev.preventDefault();
ev.stopPropagation();
menuTrigger.current = ev.currentTarget;
@@ -526,6 +551,10 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
onKeyDown={(ev) => {
if (ev.target !== ev.currentTarget) return;
if (editing === session.path) return;
if (selection) {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); toggleSelection(session.path); }
return;
}
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
void pick(ws, session);
@@ -535,6 +564,9 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
}
}}
>
{selection && <input type="checkbox" className="session-select" tabIndex={-1} aria-label={t("选择会话:{name}", { name: rowLabel(session) })}
data-action-click="session.selection" data-action-change="session.selection" data-target={session.path} checked={selection.has(session.path)}
onClick={(ev) => ev.stopPropagation()} onChange={() => toggleSelection(session.path)} />}
<i className="pip" />
{editing === session.path ? (
<input
@@ -0,0 +1,222 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { Workspaces } from "./Workspaces";
import { RailSearch } from "./railsearch";
import { draftKey, readDraft, writeDraft } from "./drafts";
import type { TreeSession, TreeWorkspace } from "../port/hub";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
const sessions: TreeSession[] = [
{ path: "/sessions/a.jsonl", name: "a", title: "Alpha", runtimeId: "r1" },
{ path: "/sessions/b.jsonl", name: "b", title: "Beta" },
{ path: "/sessions/c.jsonl", name: "c", title: "Gamma", archived: true },
];
function draw(rows = sessions, scope: "all" | "archived" = "all", folded = new Set<string>()) {
const hub = { removeSession: vi.fn().mockResolvedValue(undefined) };
const onArchive = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn().mockResolvedValue(undefined);
const onOpen = vi.fn().mockResolvedValue(undefined);
const onFocus = vi.fn();
const onError = vi.fn();
const reload = vi.fn().mockResolvedValue(undefined);
let live: string[] = [];
const props = { hub: hub as never, tree: [{ root: "/w", name: "Project", sessions: rows }] as TreeWorkspace[],
treeRead: true, runtimes: [{ id: "r1" }] as never, active: "r1", folded, onFold: vi.fn(), reload,
onOpen, onFocus, onClose, liveIds: (ids: string[]) => ids.filter((id) => live.includes(id)), runs: {},
scope, onArchive, onRename: vi.fn(), onError, adder: { add: vi.fn(), close: vi.fn(), at: null } as never };
const view = render(<RailSearch><Workspaces {...props} /></RailSearch>);
return { hub, onArchive, onClose, onOpen, onFocus, onError, reload,
live: (ids: string[]) => { live = ids; view.rerender(<RailSearch><Workspaces {...props}
runs={Object.fromEntries(ids.map((id) => [id, { run: "running", live: true }]))} /></RailSearch>); },
update: (tree: TreeWorkspace[]) => { props.tree = tree; view.rerender(<RailSearch><Workspaces {...props} /></RailSearch>); },
fold: (roots: Set<string>) => { props.folded = roots; view.rerender(<RailSearch><Workspaces {...props} /></RailSearch>); } };
}
async function selectAll() {
await userEvent.click(screen.getByRole("button", { name: "选择本机会话" }));
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
}
it("selects rows with clicks and the keyboard without opening or focusing a pane", async () => {
const pane = draw();
await userEvent.click(screen.getByRole("button", { name: "选择本机会话" }));
const alpha = screen.getByRole("treeitem", { name: /Alpha/ });
await userEvent.click(alpha);
const beta = screen.getByRole("treeitem", { name: /Beta/ });
beta.focus();
await userEvent.keyboard(" ");
expect(alpha.getAttribute("aria-selected")).toBe("true");
expect(beta.getAttribute("aria-selected")).toBe("true");
expect(screen.getByText("已选 2 个会话")).toBeTruthy();
expect(pane.onOpen).not.toHaveBeenCalled();
expect(pane.onFocus).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "完成选择" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "选择本机会话" }));
await userEvent.click(beta);
expect(pane.onOpen).toHaveBeenCalledWith({ root: "/w", sessionPath: "/sessions/b.jsonl" });
});
it("archives selected sessions through the same owner as an individual row", async () => {
const pane = draw();
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledTimes(2));
expect(pane.onArchive).toHaveBeenNthCalledWith(1, "/sessions/a.jsonl", true, "r1");
expect(pane.onArchive).toHaveBeenNthCalledWith(2, "/sessions/b.jsonl", true, undefined);
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
});
it("restores only the selected archived rows", async () => {
const pane = draw(sessions, "archived");
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "取消所选会话归档" }));
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledWith("/sessions/c.jsonl", false, undefined));
expect(pane.onArchive).toHaveBeenCalledTimes(1);
});
it("confirms the selected names before deletion and lets Escape cancel without a mutation", async () => {
const pane = draw();
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
const question = screen.getByRole("alertdialog", { name: "删除 2 个会话?" });
expect(within(question).getByText("Alpha、Beta")).toBeTruthy();
expect(pane.hub.removeSession).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "删除所选会话" }));
expect(pane.onClose).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
await userEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除" }));
await waitFor(() => expect(pane.hub.removeSession).toHaveBeenCalledTimes(2));
expect(pane.onClose).toHaveBeenCalledWith(["r1"]);
expect(pane.onClose.mock.invocationCallOrder[0]).toBeLessThan(pane.hub.removeSession.mock.invocationCallOrder[0]!);
expect(pane.hub.removeSession).toHaveBeenNthCalledWith(1, "/sessions/a.jsonl");
expect(pane.hub.removeSession).toHaveBeenNthCalledWith(2, "/sessions/b.jsonl");
expect(pane.reload).toHaveBeenCalledTimes(1);
});
it("keeps failed rows selected while removing successful rows from the batch", async () => {
const pane = draw();
const failure = new Error("storage unavailable");
pane.onArchive.mockRejectedValueOnce(failure);
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
await waitFor(() => expect(pane.onError).toHaveBeenCalledWith(failure));
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
expect(screen.getByRole("treeitem", { name: /Alpha/ }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("treeitem", { name: /Beta/ }).getAttribute("aria-selected")).toBe("false");
});
it("does not stop running sessions, including a turn that starts after selection", async () => {
const pane = draw();
await selectAll();
pane.live(["r1"]);
expect((screen.getByRole("button", { name: "归档所选会话" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "删除所选会话" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText("所选会话正在运行,停止后才能操作")).toBeTruthy();
expect(pane.onClose).not.toHaveBeenCalled();
expect(pane.hub.removeSession).not.toHaveBeenCalled();
});
it("rechecks each running state during a batch instead of stopping a newly started turn", async () => {
const pane = draw([sessions[0]!, { ...sessions[1]!, runtimeId: "r2" }]);
pane.onArchive.mockImplementationOnce(async () => pane.live(["r2"]));
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
await waitFor(() => expect(pane.onError).toHaveBeenCalled());
expect(pane.onArchive).toHaveBeenCalledTimes(1);
expect(pane.onClose).not.toHaveBeenCalled();
expect(screen.getByRole("treeitem", { name: /Beta/ }).getAttribute("aria-selected")).toBe("true");
});
it("keeps a session when closing its pane is refused and still deletes the other selected row", async () => {
const pane = draw();
const failure = new Error("pane cannot close");
const kept = draftKey("", "/w", "/sessions/a.jsonl");
const removed = draftKey("", "/w", "/sessions/b.jsonl");
const remote = draftKey("other-host", "/w", "/sessions/b.jsonl");
writeDraft(kept, "keep the refused session");
writeDraft(removed, "delete with the removed session");
writeDraft(remote, "keep the remote session");
pane.onClose.mockRejectedValueOnce(failure);
await selectAll();
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
await userEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除" }));
await waitFor(() => expect(pane.onError).toHaveBeenCalledWith(failure));
expect(pane.hub.removeSession).toHaveBeenCalledTimes(1);
expect(pane.hub.removeSession).toHaveBeenCalledWith("/sessions/b.jsonl");
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
expect(readDraft(kept)).toBe("keep the refused session");
expect(readDraft(removed)).toBe("");
expect(readDraft(remote)).toBe("keep the remote session");
});
it("keeps a pending batch from being submitted twice", async () => {
const pane = draw();
let release: () => void = () => {};
pane.onArchive.mockImplementationOnce(() => new Promise<void>((resolve) => { release = resolve; }));
await selectAll();
await userEvent.dblClick(screen.getByRole("button", { name: "归档所选会话" }));
expect(pane.onArchive).toHaveBeenCalledTimes(1);
expect((screen.getByRole("button", { name: "完成选择" }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => release());
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledTimes(2));
});
it("selects only displayed rows and clears the selection when the query changes", async () => {
const many = Array.from({ length: 35 }, (_, i) => ({ path: `/sessions/${i}.jsonl`, name: `${i}`, title: `Task ${i}` }));
const pane = draw(many);
await selectAll();
expect(screen.getByText("已选 30 个会话")).toBeTruthy();
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "Task 34");
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
expect(pane.onArchive).not.toHaveBeenCalled();
});
it("drops deleted selections when a tree refresh removes their canonical rows", async () => {
const pane = draw();
await selectAll();
pane.update([{ root: "/w", name: "Project", sessions: [sessions[1]!] }]);
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledWith("/sessions/b.jsonl", true, undefined));
expect(pane.onArchive).toHaveBeenCalledTimes(1);
});
it("removes hidden selections when a project or the local machine is folded", async () => {
const pane = draw();
await selectAll();
pane.update([
{ root: "/w", name: "Project", sessions },
{ root: "/other", name: "Other", sessions: [{ path: "/sessions/d.jsonl", name: "Delta" }] },
]);
pane.fold(new Set(["/w"]));
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
pane.fold(new Set());
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
pane.fold(new Set(["/w"]));
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "取消当前显示会话的选择" }));
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
await userEvent.click(screen.getByRole("treeitem", { name: /这台机器/ }));
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
expect((screen.getByRole("button", { name: "删除所选会话" }) as HTMLButtonElement).disabled).toBe(true);
expect(pane.onArchive).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "完成选择" }));
expect(document.activeElement).toBe(screen.getByRole("treeitem", { name: /这台机器/ }));
});
it("limits a search matching the project name to the rows actually rendered", async () => {
draw(Array.from({ length: 35 }, (_, i) => ({ path: `/sessions/${i}.jsonl`, name: `${i}`, title: `Task ${i}` })));
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "Project");
await selectAll();
expect(screen.getByText("已选 30 个会话")).toBeTruthy();
expect(screen.queryByRole("treeitem", { name: /Task 34/ })).toBeNull();
});