)}
diff --git a/desktop/frontend-next/src/ui/MeterRail.tsx b/desktop/frontend-next/src/ui/MeterRail.tsx
index 820c9d4f7c..70216afb4d 100644
--- a/desktop/frontend-next/src/ui/MeterRail.tsx
+++ b/desktop/frontend-next/src/ui/MeterRail.tsx
@@ -4,6 +4,7 @@ import type { ContextBreakdown, JobEntry, McpEntry } from "../port/port";
import type { Metrics } from "../state/session_types";
import { DeckChips, type Deck } from "./DeckChips";
import { ContextSummaryCard } from "./ContextSummaryCard";
+import type { CompactAction } from "./CompactNow";
import { useDismiss } from "./dismiss";
import type { Task } from "./panels/Agents";
import { Spark } from "./Spark";
@@ -13,7 +14,7 @@ import { MASK, type Wallet } from "./wallet";
import type { SpeedSummary } from "./speed";
/** The numbers about this turn, in one row under the composer. */
-export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob }: {
+export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob }: {
tps: number;
trail: number[];
running: boolean;
@@ -21,6 +22,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, cost,
metrics: Pick;
ctx: ContextBreakdown | null;
mcp: McpEntry[];
+ compact: CompactAction;
cost: string;
wallet: Wallet;
hideAmounts: boolean;
@@ -74,6 +76,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, cost,
context={ctx}
mcp={mcp}
percent={contextPercent}
+ compact={compact}
onManage={() => { closeMeter(); onSettings("ext"); }}
/>
diff --git a/desktop/frontend-next/src/ui/Metrics.tsx b/desktop/frontend-next/src/ui/Metrics.tsx
index de548c901a..81de678e74 100644
--- a/desktop/frontend-next/src/ui/Metrics.tsx
+++ b/desktop/frontend-next/src/ui/Metrics.tsx
@@ -17,6 +17,7 @@ import type { Wallet } from "./wallet";
import { swapping } from "./swap";
import { ChangePreview } from "./ChangePreview";
import type { Posture } from "./decisions";
+import type { CompactAction } from "./CompactNow";
// What the rail can show. Presence is still each panel's own contract — a
// panel with nothing to say draws nothing — so this decides where something
@@ -55,6 +56,7 @@ interface Props extends Rail {
// Declaring the missing window rebuilds the runtime, so the rebuilt gauge
// comes back on that same call rather than on the next poll.
onCtx: (next: ContextBreakdown) => void;
+ compact?: CompactAction;
yolo: boolean;
/** The task view has the plan at full width, so the rail's copy stands down. */
planShownElsewhere?: boolean;
@@ -93,6 +95,7 @@ export const Metrics = memo(function Metrics({
tree,
ctx,
onCtx,
+ compact,
yolo,
planShownElsewhere,
onSettings,
@@ -118,7 +121,7 @@ export const Metrics = memo(function Metrics({
const panel: Record = {
cache: ,
cost: ,
- context: ,
+ context: ,
agents: ,
runtime: ,
plan: ,
diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx
index e3f97a770f..ed8332a602 100644
--- a/desktop/frontend-next/src/ui/Pane.tsx
+++ b/desktop/frontend-next/src/ui/Pane.tsx
@@ -33,6 +33,7 @@ import { PlanFold } from "./PlanFold";
import { RunLine } from "./RunLine";
import type { PaneProps, PaneReport } from "./panetypes";
import { useRate, useTrail } from "./num";
+import { useCompactNow } from "./CompactNow";
import { DOCK, Gutter } from "./Gutter";
import { Find } from "./Find";
import { useFind } from "./usefind";
@@ -87,6 +88,10 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const workbench = docked || tab === "browser";
const flow = useRef(null);
const running = s.running || !!status?.running; // A paired device can join after turn_started.
+ const fail = useCallback((e: unknown) => {
+ dispatch({ kind: "__error", text: reason(e) } as never);
+ }, []);
+ const compact = useCompactNow(port, running || s.items.some((item) => item.t === "compaction" && !item.done), fail);
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
// follows the deltas themselves, and expires rather than being re-derived.
const tps = useRate(s.outWindow, running);
@@ -144,6 +149,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
() =>
port.subscribe(
(ev) => {
+ compact.onEvent(ev);
pacer.push(ev); if (ev.kind === "turn_done") onTurnDone?.(rt.id);
// A server finishing its handshake changes what /mcp answers, and this
// is the only precise signal for it — the turn boundary below is the
@@ -162,12 +168,13 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
if (ev.kind === "notice" && ev.code === "display_currency") revalue();
},
() => {
+ compact.release();
pacer.drop();
rebuild();
refreshStatus();
},
),
- [port, pacer, rt.id, onTurnDone, reloadMcp, rebuild, refreshStatus, revalue],
+ [port, pacer, rt.id, onTurnDone, reloadMcp, rebuild, refreshStatus, revalue, compact.onEvent, compact.release],
);
// What the rows cover is dispatched before the rows themselves, so the table
@@ -214,12 +221,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
if (pulse) refreshStatus();
}, [pulse, refreshStatus]);
- const fail = useCallback((e: unknown) => {
- // A refusal carries a code; say() turns it into this window's language.
- // Anything else is an ordinary failure and prints as itself.
- dispatch({ kind: "__error", text: reason(e) } as never);
- }, []);
-
// Everything on screen belongs to one session; when the kernel moves this
// pane to another one — a switch, a new session, a rewind — all of it has to
// be re-read rather than patched.
@@ -505,7 +506,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
{alert &&
{alert}
}
port.cancelJob(id).then(refreshStatus, fail)}
/>
@@ -545,6 +546,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
tree={tree}
ctx={ctx}
onCtx={setCtx}
+ compact={compact}
yolo={status?.toolApprovalMode === "yolo"}
onSettings={onSettings}
panels={s.panels}
diff --git a/desktop/frontend-next/src/ui/context-compact.interaction.test.tsx b/desktop/frontend-next/src/ui/context-compact.interaction.test.tsx
new file mode 100644
index 0000000000..4a397da68e
--- /dev/null
+++ b/desktop/frontend-next/src/ui/context-compact.interaction.test.tsx
@@ -0,0 +1,170 @@
+// @vitest-environment jsdom
+import "./testkit";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { MockPort } from "../port/mock";
+import type { WireEvent } from "../port/wire";
+import { Pane } from "./Pane";
+
+afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
+
+async function mount(running = false, side = false) {
+ const port = new MockPort();
+ const snapshot = await port.status();
+ let kernelRunning = running;
+ vi.spyOn(port, "status").mockImplementation(async () => ({ ...snapshot, running: kernelRunning }));
+ const subscribers = new Set<(event: WireEvent) => void>();
+ let gap: (() => void) | undefined;
+ vi.spyOn(port, "subscribe").mockImplementation((onEvent, onGap) => {
+ subscribers.add(onEvent);
+ gap = onGap;
+ return () => subscribers.delete(onEvent);
+ });
+ const submit = vi.spyOn(port, "submit").mockResolvedValue();
+ const queue = vi.spyOn(port, "queueFollowup");
+ const context = vi.spyOn(port, "context");
+ const settings = vi.fn();
+ const host = document.createElement("aside");
+ const view = render(<>
+ {}} onReport={() => {}} onSessionChanged={() => {}} pulse={0} findPulse={0}
+ onSettings={settings} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
+ theme="light" dockW={320} dockMax={640} onDockW={() => {}} />
+ >);
+ view.container.append(host);
+ await screen.findByRole("button", { name: "查看上下文与压缩" });
+ fireEvent.click(screen.getByRole("button", { name: "查看上下文与压缩" }));
+ await waitFor(() => expect(view.container.querySelector('.studio-context-anchor[data-open]')).toBeTruthy());
+ const send = (event: WireEvent) => act(() => { subscribers.forEach((fn) => fn(event)); });
+ const card = () => within(screen.getByRole("dialog", { name: "上下文详情" }));
+ return { port, submit, queue, context, settings, send, card, host, gap: () => act(() => gap?.()), setRunning: (value: boolean) => { kernelRunning = value; } };
+}
+
+const draft = () => document.querySelector('textarea[aria-label="任务输入"]')!;
+
+async function confirm(card: ReturnType) {
+ fireEvent.click(card.getByRole("button", { name: "立即压缩" }));
+ fireEvent.click(card.getByRole("button", { name: "确认压缩" }));
+}
+
+it("asks before compacting from the ring, preserving the draft and avoiding a fake user turn", async () => {
+ const pane = await mount();
+ fireEvent.change(draft(), { target: { value: "Keep my unsent prompt" } });
+ fireEvent.click(pane.card().getByRole("button", { name: "立即压缩" }));
+ expect(pane.card().getByRole("alertdialog")).toBeTruthy();
+ expect(pane.submit).not.toHaveBeenCalled();
+ fireEvent.click(pane.card().getByRole("button", { name: "确认压缩" }));
+ await waitFor(() => expect(pane.submit).toHaveBeenCalledExactlyOnceWith("/compact"));
+ expect(draft().value).toBe("Keep my unsent prompt");
+ expect(screen.queryByText("/compact")).toBeNull();
+ expect(pane.queue).not.toHaveBeenCalled();
+});
+
+it.each(["cancel", "escape", "outside"])("dismisses confirmation with %s without submitting", async (way) => {
+ const pane = await mount();
+ fireEvent.click(pane.card().getByRole("button", { name: "立即压缩" }));
+ if (way === "cancel") fireEvent.click(pane.card().getByRole("button", { name: "取消" }));
+ else if (way === "escape") fireEvent.keyDown(pane.card().getByRole("alertdialog"), { key: "Escape" });
+ else fireEvent.mouseDown(draft());
+ expect(pane.card().queryByRole("alertdialog")).toBeNull();
+ expect(pane.submit).not.toHaveBeenCalled();
+});
+
+it.each(["compacted", "compact_declined", "compact_failed"])("keeps both entry points disabled until the %s receipt", async (code) => {
+ const pane = await mount(false, true);
+ const rail = within(pane.host);
+ await confirm(pane.card());
+ await waitFor(() => expect(pane.submit).toHaveBeenCalledTimes(1));
+ expect(pane.card().getByRole("button", { name: "正在压缩…" }).disabled).toBe(true);
+ expect(rail.getByRole("button", { name: "正在压缩…" }).disabled).toBe(true);
+ pane.send({ kind: "notice", code: "runtime_reloaded", text: "compacted" });
+ expect(rail.getByRole("button", { name: "正在压缩…" }).disabled).toBe(true);
+ pane.send({ kind: "notice", code, text: "kernel feedback", detail: "input_unchanged" });
+ await waitFor(() => expect(rail.getByRole("button", { name: "立即压缩" }).disabled).toBe(false));
+ expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false);
+ await screen.findByText(code === "compacted" ? "已压缩" : code === "compact_declined" ? "无需压缩:上下文自上次整理后没有变化" : "压缩失败:上下文自上次整理后没有变化");
+});
+
+it("compacts from the context panel through the same controller path", async () => {
+ const pane = await mount(false, true);
+ await confirm(within(pane.host));
+ await waitFor(() => expect(pane.submit).toHaveBeenCalledExactlyOnceWith("/compact"));
+ expect(pane.card().getByRole("button", { name: "正在压缩…" }).disabled).toBe(true);
+});
+
+it("disables manual compaction on a paired running session and on streamed compaction", async () => {
+ const pane = await mount(true);
+ expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(true);
+ pane.setRunning(false);
+ pane.send({ kind: "turn_done" });
+ await waitFor(() => expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false));
+ pane.send({ kind: "compaction_started", compaction: { trigger: "manual" } });
+ await waitFor(() => expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(true));
+ const before = pane.context.mock.calls.length;
+ pane.send({ kind: "compaction_done", compaction: { trigger: "manual" } });
+ await waitFor(() => expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false));
+ await waitFor(() => expect(pane.context.mock.calls.length).toBeGreaterThan(before));
+ expect(pane.submit).not.toHaveBeenCalled();
+});
+
+it("refuses a confirmation whose turn started while it was open", async () => {
+ const pane = await mount();
+ fireEvent.click(pane.card().getByRole("button", { name: "立即压缩" }));
+ pane.setRunning(true);
+ pane.send({ kind: "turn_started" });
+ await waitFor(() => expect(pane.card().getByRole("button", { name: "确认压缩" }).disabled).toBe(true));
+ fireEvent.click(pane.card().getByRole("button", { name: "确认压缩" }));
+ expect(pane.submit).not.toHaveBeenCalled();
+});
+
+it("reports a transport refusal and restores the action for retry without queueing", async () => {
+ const pane = await mount();
+ pane.submit.mockRejectedValueOnce(new Error("transport refused"));
+ await confirm(pane.card());
+ await screen.findByText("transport refused");
+ expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false);
+ expect(pane.queue).not.toHaveBeenCalled();
+ await confirm(pane.card());
+ await waitFor(() => expect(pane.submit).toHaveBeenCalledTimes(2));
+});
+
+it("keeps the adjacent MCP management action working", async () => {
+ const pane = await mount();
+ fireEvent.click(pane.card().getByRole("button", { name: "管理 MCP 与工具" }));
+ expect(pane.settings).toHaveBeenCalledExactlyOnceWith("ext");
+});
+
+it("holds both controls during HTTP admission and refuses a second confirmation", async () => {
+ const pane = await mount(false, true);
+ let admitted: (() => void) | undefined;
+ pane.submit.mockImplementationOnce(() => new Promise((resolve) => { admitted = resolve; }));
+ fireEvent.click(pane.card().getByRole("button", { name: "立即压缩" }));
+ fireEvent.click(within(pane.host).getByRole("button", { name: "立即压缩" }));
+ fireEvent.click(within(pane.host).getByRole("button", { name: "确认压缩" }));
+ expect(pane.submit).toHaveBeenCalledTimes(1);
+ fireEvent.click(pane.card().getByRole("button", { name: "确认压缩" }));
+ expect(pane.submit).toHaveBeenCalledTimes(1);
+ await act(async () => admitted?.());
+ expect(within(pane.host).getByRole("button", { name: "正在压缩…" }).disabled).toBe(true);
+ pane.send({ kind: "notice", code: "compacted", text: "compacted" });
+ await waitFor(() => expect(within(pane.host).getByRole("button", { name: "立即压缩" }).disabled).toBe(false));
+});
+
+it("does not rearm when a fast result arrives before HTTP admission resolves", async () => {
+ const pane = await mount();
+ let admitted: (() => void) | undefined;
+ pane.submit.mockImplementationOnce(() => new Promise((resolve) => { admitted = resolve; }));
+ await confirm(pane.card());
+ pane.send({ kind: "notice", code: "compact_declined", text: "nothing to compact", detail: "input_unchanged" });
+ await act(async () => admitted?.());
+ expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false);
+});
+
+it("releases a request whose result was lost across a stream gap", async () => {
+ const pane = await mount();
+ await confirm(pane.card());
+ await waitFor(() => expect(pane.submit).toHaveBeenCalledTimes(1));
+ pane.gap();
+ await waitFor(() => expect(pane.card().getByRole("button", { name: "立即压缩" }).disabled).toBe(false));
+});
diff --git a/desktop/frontend-next/src/ui/panels/Context.tsx b/desktop/frontend-next/src/ui/panels/Context.tsx
index 7f4d71dc50..57b09849cf 100644
--- a/desktop/frontend-next/src/ui/panels/Context.tsx
+++ b/desktop/frontend-next/src/ui/panels/Context.tsx
@@ -6,6 +6,7 @@ import { pct as percent, tokens } from "../../i18n/format";
import { pinToViewport } from "../place";
import { useFoldBound } from "./useFoldBound";
import { Row } from "./kit";
+import { CompactNow, type CompactAction } from "../CompactNow";
// The order is the order they arrive in a prompt, so the bar reads the way the
// request is built rather than by size — a class that grows is easier to spot
@@ -62,7 +63,7 @@ function place(anchor: RefObject) {
* all. Each names its own denominator, so neither becomes a percentage of
* something unstated, and the window stays the number a relay gets wrong and
* the only place it can be corrected. */
-export function Context({ ctx, legend = false, port, onCtx }: {
+export function Context({ ctx, legend = false, port, onCtx, compact }: {
ctx: ContextBreakdown | null;
legend?: boolean;
// Both are needed to offer the missing window: one to declare it, one to
@@ -70,6 +71,7 @@ export function Context({ ctx, legend = false, port, onCtx }: {
// cannot draw, which is the half that was there before.
port?: AgentPort;
onCtx?: (next: ContextBreakdown) => void;
+ compact?: CompactAction;
}) {
// Every hook runs before the first return: ctx arrives one render after the
// rail mounts, and a guard above them made that render ask for hooks the
@@ -124,6 +126,7 @@ export function Context({ ctx, legend = false, port, onCtx }: {
{t("该来源未声明窗口大小,因此无法显示已用比例,也不会自动压缩。中转服务转发的是第三方模型,其容量只有你知道。")}
{field}
+ {compact && }
>
);
return legend ?
{missing}
: missing;
@@ -175,6 +178,7 @@ export function Context({ ctx, legend = false, port, onCtx }: {
any more — so both keep a row, and each names its own denominator
rather than collapsing into one percentage of something unstated. */}
{tokens(Math.round(used))}} />
+ {compact && }
{!folds && }
{!folds && editing && field}