diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index d85dcc3f14..a16eb007f7 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -364,6 +364,8 @@ export const ACTIONS: UIAction[] = [ { id: "compaction.threshold", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, // Which bound is in force is the setting; how much of it is on screen is not. { id: "compaction.advanced", kind: "view", target: "none", proof: "interaction" }, + { id: "compaction.confirm", kind: "view", target: "none", proof: "interaction" }, + { id: "compaction.now", kind: "kernel-mutation", target: "none", proof: "interaction" }, { id: "panel.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "panel.delegate-placement", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 8fa34846ef..a1ee844e1c 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1098,6 +1098,10 @@ export const EN: Record = { "仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.", "按模型生效": "Per model", "查看上下文与压缩": "View context and compaction", + "立即压缩": "Compact now", + "确认压缩": "Confirm compaction", + "压缩当前上下文?": "Compact the current context?", + "模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.", "查看本轮费用": "View turn cost", "查看钱包余额": "View wallet balance", "余额不足": "Balance is low", diff --git a/desktop/frontend-next/src/styles/compact-now.css b/desktop/frontend-next/src/styles/compact-now.css new file mode 100644 index 0000000000..ff69a72b5f --- /dev/null +++ b/desktop/frontend-next/src/styles/compact-now.css @@ -0,0 +1,4 @@ +.compact-now .wsconfirm { display: grid; gap: 8px; padding: 8px 0; border: 0; background: transparent; } +.compact-now .wsconfirm-t .q { overflow: visible; white-space: normal; } +.compact-now .wsconfirm-a { justify-content: flex-end; flex-wrap: wrap; } +.compact-now .wsconfirm-a button:disabled { opacity: .4; cursor: default; } diff --git a/desktop/frontend-next/src/ui/CompactNow.tsx b/desktop/frontend-next/src/ui/CompactNow.tsx new file mode 100644 index 0000000000..b7a50bb28f --- /dev/null +++ b/desktop/frontend-next/src/ui/CompactNow.tsx @@ -0,0 +1,54 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { t } from "../i18n"; +import type { AgentPort } from "../port/port"; +import type { WireEvent } from "../port/wire"; +import { useDismiss } from "./dismiss"; +import "../styles/compact-now.css"; + +export interface CompactAction { + busy: boolean; + pending: boolean; + run: () => void; +} + +export function useCompactNow(port: AgentPort, blocked: boolean, onError: (error: unknown) => void) { + const [pending, setPending] = useState(false); + const held = useRef(false); + const release = useCallback(() => { held.current = false; setPending(false); }, []); + const onEvent = useCallback((event: WireEvent) => { + if (event.kind === "notice" && (event.code === "compacted" || event.code === "compact_declined" || event.code === "compact_failed")) release(); + }, [release]); + const run = useCallback(() => { + if (blocked || held.current) return; + held.current = true; + setPending(true); + // /submit acknowledges admission; the typed notice settles the operation. + void port.submit("/compact").catch((error) => { release(); onError(error); }); + }, [port, blocked, onError, release]); + return useMemo(() => ({ busy: blocked || pending, pending, run, onEvent, release }), [blocked, pending, run, onEvent, release]); +} + +export function CompactNow({ action }: { action: CompactAction }) { + const [asking, setAsking] = useState(false); + const box = useRef(null); + const trigger = useRef(null); + const close = useCallback(() => setAsking(false), []); + useDismiss(asking, box, close); + const cancel = () => { close(); trigger.current?.focus(); }; + return ( +
+ + {asking && ( +
+
{t("压缩当前上下文?")}{t("模型将依据摘要继续工作;完整对话记录会保留。")}
+
+ + +
+
+ )} +
+ ); +} diff --git a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx index d366737424..c79a9ea76a 100644 --- a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx +++ b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx @@ -1,13 +1,15 @@ import { t } from "../i18n"; import type { ContextBreakdown, McpEntry } from "../port/port"; +import { CompactNow, type CompactAction } from "./CompactNow"; const compact = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 100) / 10}k` : String(n); -export function ContextSummaryCard({ context, mcp, percent, onManage, className = "" }: { +export function ContextSummaryCard({ context, mcp, percent, onManage, compact: action, className = "" }: { context: ContextBreakdown | null; mcp: McpEntry[]; percent: number | null; onManage: () => void; + compact?: CompactAction; className?: string; }) { const active = mcp.filter((entry) => entry.enabled); @@ -31,6 +33,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, className
{parts.map(([label, value, key]) =>
{label}{compact(value)}
)}
) :

{t("暂时没有上下文数据")}

} + {action && }
MCP{ready.length} / {active.length} {t("个已连接")} · {tools} {t("个工具")}
{active.slice(0, 4).map((server) =>
{server.name}{server.state === "ready" ? `${server.tools} ${t("个工具")}` : t("等待连接")}
)} 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}