mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): compact context from the ring and inspector
This commit is contained in:
1 parent
2872663c2d
commit
de06daf75a
10 files changed
+261
-12
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -1098,6 +1098,10 @@ export const EN: Record<string, string> = {
|
||||
"仅显示当前模型实际支持的档位。": "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",
|
||||
|
||||
@@ -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; }
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setAsking(false), []);
|
||||
useDismiss(asking, box, close);
|
||||
const cancel = () => { close(); trigger.current?.focus(); };
|
||||
return (
|
||||
<div className="compact-now" ref={box}>
|
||||
<button ref={trigger} className="btn sm" data-action="compaction.confirm" disabled={action.busy} aria-expanded={asking} onClick={() => setAsking(true)}>
|
||||
{action.pending ? t("正在压缩…") : t("立即压缩")}
|
||||
</button>
|
||||
{asking && (
|
||||
<div className="wsconfirm" role="alertdialog" aria-label={t("压缩当前上下文?")}>
|
||||
<div className="wsconfirm-t"><span className="q">{t("压缩当前上下文?")}</span><span className="h">{t("模型将依据摘要继续工作;完整对话记录会保留。")}</span></div>
|
||||
<div className="wsconfirm-a">
|
||||
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("取消")}</button>
|
||||
<button data-action="compaction.now" disabled={action.busy} onClick={() => { close(); action.run(); trigger.current?.focus(); }}>{t("确认压缩")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<div className="chrome-context-parts">{parts.map(([label, value, key]) => <div key={key}><i data-part={key} /><span>{label}</span><b>{compact(value)}</b></div>)}</div>
|
||||
</>
|
||||
) : <p>{t("暂时没有上下文数据")}</p>}
|
||||
{action && <CompactNow action={action} />}
|
||||
<section>
|
||||
<div><b>MCP</b><small>{ready.length} / {active.length} {t("个已连接")} · {tools} {t("个工具")}</small></div>
|
||||
{active.slice(0, 4).map((server) => <div className="chrome-mcp-row" key={server.name}><i data-ready={server.state === "ready" ? "" : undefined} /><span>{server.name}</span><small>{server.state === "ready" ? `${server.tools} ${t("个工具")}` : t("等待连接")}</small></div>)}
|
||||
|
||||
@@ -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<Metrics, "hit" | "miss">;
|
||||
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"); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<Section, ReactNode> = {
|
||||
cache: <Cache key="cache" metrics={metrics} />,
|
||||
cost: <Cost key="cost" metrics={metrics} wallet={wallet} account={account} onRefreshWallet={onRefreshWallet} />,
|
||||
context: <Context key="context" ctx={ctx} legend port={port} onCtx={onCtx} />,
|
||||
context: <Context key="context" ctx={ctx} legend port={port} onCtx={onCtx} compact={compact} />,
|
||||
agents: <Agents key="agents" tasks={tasks} />,
|
||||
runtime: <Runtime key="runtime" rate={rate} done={done} stats={stats} />,
|
||||
plan: <Plan key="plan" steps={plan} shownElsewhere={planShownElsewhere} />,
|
||||
|
||||
@@ -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<HTMLDivElement>(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 && <div className="cmpalert">{alert}</div>}
|
||||
<Composer port={port} status={status} running={running} quote={quote} restore={restored} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} changeCount={tree?.repo ? tree.changes.length : 0} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<MeterRail
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} cost={cost}
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost}
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
onCancelJob={(id) => 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}
|
||||
|
||||
@@ -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(<>
|
||||
<Pane port={port} rt={{ id: "compact", root: "/workspace", name: "Workspace", base: "/rt/compact" }}
|
||||
title="Workspace" active visible sideHost={side ? host : null} side={side}
|
||||
onFocus={() => {}} 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<HTMLTextAreaElement>('textarea[aria-label="任务输入"]')!;
|
||||
|
||||
async function confirm(card: ReturnType<typeof within>) {
|
||||
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<HTMLButtonElement>("button", { name: "正在压缩…" }).disabled).toBe(true);
|
||||
expect(rail.getByRole<HTMLButtonElement>("button", { name: "正在压缩…" }).disabled).toBe(true);
|
||||
pane.send({ kind: "notice", code: "runtime_reloaded", text: "compacted" });
|
||||
expect(rail.getByRole<HTMLButtonElement>("button", { name: "正在压缩…" }).disabled).toBe(true);
|
||||
pane.send({ kind: "notice", code, text: "kernel feedback", detail: "input_unchanged" });
|
||||
await waitFor(() => expect(rail.getByRole<HTMLButtonElement>("button", { name: "立即压缩" }).disabled).toBe(false));
|
||||
expect(pane.card().getByRole<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("button", { name: "立即压缩" }).disabled).toBe(true);
|
||||
pane.setRunning(false);
|
||||
pane.send({ kind: "turn_done" });
|
||||
await waitFor(() => expect(pane.card().getByRole<HTMLButtonElement>("button", { name: "立即压缩" }).disabled).toBe(false));
|
||||
pane.send({ kind: "compaction_started", compaction: { trigger: "manual" } });
|
||||
await waitFor(() => expect(pane.card().getByRole<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<void>((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<HTMLButtonElement>("button", { name: "正在压缩…" }).disabled).toBe(true);
|
||||
pane.send({ kind: "notice", code: "compacted", text: "compacted" });
|
||||
await waitFor(() => expect(within(pane.host).getByRole<HTMLButtonElement>("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<void>((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<HTMLButtonElement>("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<HTMLButtonElement>("button", { name: "立即压缩" }).disabled).toBe(false));
|
||||
});
|
||||
@@ -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<HTMLElement | null>) {
|
||||
* 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("该来源未声明窗口大小,因此无法显示已用比例,也不会自动压缩。中转服务转发的是第三方模型,其容量只有你知道。")}
|
||||
</p>
|
||||
{field}
|
||||
{compact && <CompactNow action={compact} />}
|
||||
</>
|
||||
);
|
||||
return legend ? <div className="block" data-b="ctx">{missing}</div> : 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. */}
|
||||
<Row k={t("上下文")} v={<span className="ctxq">{tokens(Math.round(used))}</span>} />
|
||||
{compact && <CompactNow action={compact} />}
|
||||
{!folds && <Row k={t("模型容量")} v={windowFigure} />}
|
||||
{!folds && editing && field}
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user