compaction: the surface — settings row, two ruled dividers, summary dialog, send gate (round 14 slice 3)

- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
  in a Context group — CommitInput mirroring the graph budget row, hint
  resolved through the SAME core resolver the trigger reads; PUT rejects
  below the 32k floor with the anti-churn rationale; own section
  (compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
  the marker (retrying-message register, no rule lines) and the longer
  CUT divider emitted immediately before the first kept message
  (phase-divider geometry, chip carries '· Nk tok'); both carry the full
  summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
  (pure exported body for SSR pins, the delete-dialog pattern; Esc +
  Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
  canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
  ('compacting session…') gate on it (the server's registry mutex is the
  structural gate; this is the affordance); dialog mounts beside the
  abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
This commit is contained in:
amosblomqvist
2026-10-05 22:51:08 +03:00
parent dd159cc8fb
commit ffc4df1938
14 changed files with 465 additions and 19 deletions
+50 -4
View File
@@ -43,6 +43,12 @@ import { TopBar } from "@/components/top-bar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import {
CompactionCutDivider,
CompactionMomentDivider,
CompactionSummaryDialog,
type CompactionDividerData,
} from "@/components/compaction-divider";
import { CoverageRing } from "@/components/extraction-coverage";
import {
activeProblemOf,
@@ -613,7 +619,14 @@ export function Chat({
// a pending question still owns the interaction (the dock replaces the
// composer, and there is no live turn to steer anyway — questions end
// turns).
const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem;
// Compaction (owner round 14): the RUNNING state — compaction-changed
// flips it; the send paths, canSend, and the docks gate on it (the
// server's registry mutex is the structural gate; this is the
// affordance). The summary dialog state rides with it.
const [compacting, setCompacting] = useState(false);
const [compactionSummary, setCompactionSummary] = useState<CompactionDividerData | null>(null);
const canSend =
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
@@ -722,6 +735,7 @@ export function Chat({
// popup) mount INSIDE the session-area div — both are session-scoped, so
// the drag bar stays sharp and draggable while a focus owns the area.
const [mermaidFocus, setMermaidFocus] = useState<string | null>(null);
// Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the
// lh:kbd-abort-turn handler reads these at EVENT time — declared here
// beside their states, consumed below wherever the states live.
@@ -995,6 +1009,14 @@ export function Chat({
// Fully idle — a turn we never saw (every window missed): adopt now.
void syncFromServer();
});
useServerEvent("compaction-changed", (p) => {
// The settle-kick's background summary: while it runs, sends gate
// (the next turn's server-side ensureCompacted awaits it anyway —
// this only keeps the affordance honest). On finish, record-changed
// (emitted by the compaction write itself) carries the marker in.
if (p.chatId !== chatId) return;
setCompacting(p.running);
});
useServerEvent("record-changed", (p) => {
// The silent dismissal write (A2, 2026-10-03): no turn ever ran, so
// only the record moved. Other tabs and post-error windows adopt;
@@ -1008,7 +1030,7 @@ export function Chat({
});
const onSend = () => {
if (pending) return;
if (pending || compacting) return;
/* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps
* talking right through Enter must not lose the tail — stop the run,
* let the last finals land in the composer, then send the REF value
@@ -1020,7 +1042,7 @@ export function Chat({
void dictation.finish().then(doSteerOrSend);
};
const doSend = () => {
if (busy || pending) return;
if (busy || pending || compacting) return;
const t = textRef.current.trim();
if (!t) return;
draftRef.current = t; // the refusal flow's preserve-and-replay seam
@@ -1038,7 +1060,7 @@ export function Chat({
* time the send's POST resolved, so the steer lands. Idle takes the
* plain send; a raced settle 409s and falls back to exactly that. */
const doSteerOrSend = () => {
if (pending) return;
if (pending || compacting) return;
const t = textRef.current.trim();
if (!t) return;
if (busy) {
@@ -1227,6 +1249,22 @@ export function Chat({
switch (part.type) {
case "data-graph-context":
return <GraphContextCard key={pi} data={part.data as GraphContextData} />;
case "data-compaction":
return (
<CompactionMomentDivider
key={pi}
data={part.data as CompactionDividerData}
onOpen={() => setCompactionSummary(part.data as CompactionDividerData)}
/>
);
case "data-compaction-cut":
return (
<CompactionCutDivider
key={pi}
data={part.data as CompactionDividerData}
onOpen={() => setCompactionSummary(part.data as CompactionDividerData)}
/>
);
case "data-practice-thinking":
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
default:
@@ -1418,6 +1456,7 @@ export function Chat({
<DockOverlay
pending={pending}
practice={activeProblem}
compactionRunning={compacting}
onSubmitThinking={submitThinking}
chatId={chatId}
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
@@ -1491,6 +1530,7 @@ export function Chat({
)}
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
<CompactionSummaryDialog summary={compactionSummary} onClose={() => setCompactionSummary(null)} />
</div>
</div>
</div>
@@ -1632,6 +1672,7 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
function DockOverlay({
pending,
practice,
compactionRunning,
onSubmitThinking,
requiresReveal,
readingThinking,
@@ -1650,6 +1691,9 @@ function DockOverlay({
queuedSteers,
}: {
pending: ReturnType<typeof findPendingToolCall>;
/** Compaction running for this chat (round 14) — the docks' submit
* affordance gate (the server's registry is the real one). */
compactionRunning: boolean;
/** The ACTIVE practice problem (practice sessions, 2026-10-04):
* {toolCallId, input} — dock ownership exactly like a pending
* question (the composer is replaced); null in teaching sessions. */
@@ -2067,6 +2111,7 @@ function DockOverlay({
onSubmit={onSubmitAnswer}
onEscape={dictationCancel}
dictationClaim={!revealPending}
compactionRunning={compactionRunning}
/>
) : practice ? (
<PracticeDock
@@ -2079,6 +2124,7 @@ function DockOverlay({
}}
onSubmit={onSubmitThinking}
onEscape={dictationCancel}
compactionRunning={compactionRunning}
/>
) : (
composer
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import type { CompactionDetails } from "@/src/core";
/** What one divider carries: the marker's full model-facing content (the
* [SESSION HISTORY COMPACTED] header + the summary) and its details —
* both dividers open the same dialog from the same payload. */
export interface CompactionDividerData {
content: string;
details: CompactionDetails;
}
/** The moment divider (owner round 14): minimal centered small text where
* the user currently is — the retrying-message register (SystemLine's
* exact classes), NO horizontal rule. The transcript's quiet note that
* history was just summarized; the cut divider below carries the seam. */
export function CompactionMomentDivider({ data, onOpen }: { data: CompactionDividerData; onOpen: () => void }) {
return (
<div className="flex w-full items-center justify-center py-1">
<button
type="button"
data-testid="compaction-divider"
onClick={onOpen}
className="rounded px-1.5 text-xs text-muted-foreground/70 transition-colors hover:text-muted-foreground"
>
Context compacted
</button>
</div>
);
}
/** The cut divider (owner round 14): the LONGER divider at the actual
* compaction cut — the seam where verbatim history ends and the kept
* recent tail begins. Phase-divider's geometry (dotted rules + centered
* chip) so it reads as a transcript boundary, not a message. */
export function CompactionCutDivider({ data, onOpen }: { data: CompactionDividerData; onOpen: () => void }) {
const kTok = data.details.tokensBefore > 0 ? ` · ${Math.round(data.details.tokensBefore / 1000)}k tok` : "";
return (
<button
type="button"
data-testid="compaction-cut-divider"
onClick={onOpen}
className="my-4 flex w-full items-center gap-3 text-left"
>
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
History compacted{kTok}
</span>
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
</button>
);
}
/** The dialog's pure body — exported for SSR pins (the delete-dialog /
* node-dialog body pattern; the wrapper owns the portal + Esc effect). */
export function CompactionSummaryBody({ summary, onClose }: { summary: CompactionDividerData; onClose: () => void }) {
return (
<div
data-testid="compaction-summary-dialog"
className="flex max-h-[75vh] w-full max-w-2xl flex-col rounded-2xl border border-border bg-card shadow-xl"
>
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3">
<span className="font-title text-xl tracking-tight">Compacted context</span>
<button
type="button"
data-testid="compaction-summary-close"
onClick={onClose}
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Close
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-foreground/90">
{summary.content}
</pre>
</div>
</div>
);
}
/** The full-summary view both dividers open (owner round 14): the
* marker's content verbatim — the compaction header + the summary the
* model itself reads — in a scrollable document block. Esc and the
* Close row both dismiss; the backdrop click does not (the summary is
* reference material, not a decision — an accidental dismissal costs a
* re-click, and a stray click-through mid-read costs more). */
export function CompactionSummaryDialog({
summary,
onClose,
}: {
summary: CompactionDividerData | null;
onClose: () => void;
}) {
useEffect(() => {
if (!summary) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [summary, onClose]);
if (!summary) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6">
<CompactionSummaryBody summary={summary} onClose={onClose} />
</div>,
document.body,
);
}
+9 -3
View File
@@ -41,9 +41,14 @@ export interface PracticeDockProps {
onSubmit: (text: string) => void;
/** Escape in the thinking field stops dictation (threaded from the page). */
onEscape?: () => void;
/** Compaction is running for this chat (owner round 14): the thinking
* POST would block on the server's compaction gate — disable submit
* and say so in the hint row. The server registry is the real gate;
* this is the affordance. */
compactionRunning?: boolean;
}
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape }: PracticeDockProps) {
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
const [thinking, setThinking] = useState("");
// Ref MIRRORS the field ("nothing gets lost", the question-dock
// discipline): a submit resuming after `await finishDictation()` reads
@@ -63,6 +68,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
);
const submit = () => {
if (compactionRunning) return;
const text = thinkingRef.current.trim();
if (text.length === 0) return;
thinkingRef.current = "";
@@ -149,12 +155,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
field 10 − p-1 (4) = button 6. */}
<div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
↵ submits · ⇧↵ newline
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
</span>
<Button
type="button"
data-testid="practice-send"
disabled={thinking.trim().length === 0}
disabled={thinking.trim().length === 0 || compactionRunning}
onClick={() => submit()}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
>
+11 -3
View File
@@ -86,6 +86,11 @@ export interface QuestionDockProps {
* during the reading; the dock reclaims on reveal. Default true (probe
* questions, predictions, old callers). */
dictationClaim?: boolean;
/** Compaction is running for this chat (owner round 14): the answer
* POST would block on the server's compaction gate — disable submit
* and say so in the hint row. The server registry is the real gate;
* this is the affordance. */
compactionRunning?: boolean;
}
export function QuestionDock({
@@ -99,6 +104,7 @@ export function QuestionDock({
onEscape,
phase = "probe",
dictationClaim = true,
compactionRunning = false,
}: QuestionDockProps) {
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
// input, and only the schema the tool declared vouches for its shape.
@@ -140,6 +146,7 @@ export function QuestionDock({
onSubmit={onSubmit}
onEscape={onEscape}
dictationClaim={dictationClaim}
compactionRunning={compactionRunning}
/>
</div>
</>
@@ -228,6 +235,7 @@ function QuestionDockForm({
onSubmit,
onEscape,
dictationClaim = true,
compactionRunning = false,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const kind = toolQuestionKind(toolName);
@@ -340,7 +348,7 @@ function QuestionDockForm({
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
const submit = () => {
if (!canSubmit) return;
if (!canSubmit || compactionRunning) return;
/* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while
* a run is live with reasoning still in Deepgram's hands. Stop the
* run, let the last finals land in the target field, and only then
@@ -846,11 +854,11 @@ function QuestionDockForm({
)}
<div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
↵ submits · ⇧↵ newline
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
</span>
<Button
type="submit"
disabled={!canSubmit}
disabled={!canSubmit || compactionRunning}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
>
Submit answer
+1 -1
View File
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
// dispatches it after save AND delete — deletions used to be invisible to
// the zero-keys pill and the setup dialog's auto-advance.
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph";
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction";
/** Writers call this after the PUT resolves ok — never before (a failed
* write notifies nothing; the record is unchanged). */
+51 -1
View File
@@ -57,8 +57,11 @@ import { useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
import type React from "react";
import {
COMPACTION_THRESHOLD_FLOOR,
MODEL_SLOTS,
resolveSettings,
type AgentSettings,
type CompactionSettings,
type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName,
@@ -67,7 +70,7 @@ import {
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
/** One slot's display identity — the tier's roster as the row hint rides
@@ -289,8 +292,10 @@ export function AgentSection({
agent,
models,
graph,
compaction,
onSectionChange,
onModelsChange,
onCompactionChange,
}: {
agent: AgentSettings | undefined;
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
@@ -299,10 +304,15 @@ export function AgentSection({
* master switch (graph.enabled off dims + disables the row; the
* effective gate is server-side, per-turn fresh). */
graph: GraphSettings | undefined;
/** The `compaction` section — its own PUT (the threshold is engine
* behavior, not an agent-section key; owner round 14, Agent tab). */
compaction: CompactionSettings | undefined;
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
onSectionChange: (next: AgentSettings) => void;
/** Writes the composed models section (slots — replace-PUT discipline). */
onModelsChange: (next: ModelsSettings) => void;
/** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void;
}) {
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
@@ -599,6 +609,13 @@ export function AgentSection({
// Resolved through the SAME core resolver the server's effective
// settings use (the GraphSection precedent): the hint and placeholder
// report exactly the number the trigger waits for.
const resolvedCompaction = resolveSettings(
compaction === undefined ? null : { version: 1, compaction },
).compaction;
return (
<>
<SettingsGroup label="Models" testid="group-models">
@@ -844,6 +861,39 @@ export function AgentSection({
})()}
</SettingsGroup>
<SettingsGroup label="Context" testid="group-context">
<SettingsRow
label="Compaction threshold"
hint={`${resolvedCompaction.thresholdTokens} tok`}
testid="compaction-row-threshold"
>
<CommitInput
type="number"
value={String(compaction?.thresholdTokens ?? "")}
placeholder={String(resolvedCompaction.thresholdTokens)}
ariaLabel="compaction threshold"
testid="input-compaction-threshold"
onCommit={(v) => {
// Empty = factory default (the budget row's discipline):
// absence is the unset for numbers.
if (v === "") {
onCompactionChange({ ...compaction, thresholdTokens: undefined });
return;
}
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return; // bad input: no write
// Mirror the server floor (anti-churn: each compaction
// keeps a ~20k-token tail); no ceiling — the effective
// trigger is min(threshold, contextWindow − 16k) anyway.
onCompactionChange({
...compaction,
thresholdTokens: Math.max(COMPACTION_THRESHOLD_FLOOR, Math.round(n)),
});
}}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label="System prompts" testid="group-prompts">
{prompts === null ? (
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
+6 -1
View File
@@ -34,6 +34,7 @@ import {
CURRENT_SETTINGS_VERSION,
type AgentSettings,
type AppearanceSettings,
type CompactionSettings,
type GraphSettings,
type UserSettings,
} from "@/src/core";
@@ -216,7 +217,7 @@ export function SettingsDialogBody({
* schema mistake caught by the validation surface). */
const write = useCallback(
async (
section: "general" | "agent" | "models" | "appearance" | "graph",
section: "general" | "agent" | "models" | "appearance" | "graph" | "compaction",
body: object,
) => {
setError(null);
@@ -379,12 +380,16 @@ export function SettingsDialogBody({
agent={record.agent}
models={record.models}
graph={record.graph}
compaction={record.compaction}
onModelsChange={(next: ModelsSettings) =>
void write("models", next)
}
onSectionChange={(next: AgentSettings) =>
void write("agent", next)
}
onCompactionChange={(next: CompactionSettings) =>
void write("compaction", next)
}
/>
</SettingsSection>
)}