From ffc4df1938e072126372d4f455079104728f8efa Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 22:51:08 +0300 Subject: [PATCH] =?UTF-8?q?compaction:=20the=20surface=20=E2=80=94=20setti?= =?UTF-8?q?ngs=20row,=20two=20ruled=20dividers,=20summary=20dialog,=20send?= =?UTF-8?q?=20gate=20(round=2014=20slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/api/settings/[section]/route.ts | 2 +- components/chat.tsx | 54 ++++++++++- components/compaction-divider.tsx | 113 +++++++++++++++++++++++ components/practice-dock.tsx | 12 ++- components/question-dock.tsx | 14 ++- components/settings-signal.ts | 2 +- components/settings/agent-section.tsx | 52 ++++++++++- components/settings/settings-dialog.tsx | 7 +- src/core/settings.ts | 5 +- src/core/view.ts | 41 ++++++++ src/server/settings-body.ts | 32 ++++++- tests/compaction.test.ts | 41 ++++++++ tests/render-compaction-divider.test.tsx | 72 +++++++++++++++ tests/settings-body.test.ts | 37 +++++++- 14 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 components/compaction-divider.tsx create mode 100644 tests/render-compaction-divider.test.tsx diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index aced475..616db9f 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -5,7 +5,7 @@ * discipline, same as the per-chat and defaults routes); sections are * independent, so partial writes can never clobber each other. * - * section ∈ general | chat | agent | appearance | graph (else 404). + * section ∈ general | chat | agent | appearance | graph | compaction * Body: the complete desired section object. Parse + validate strictly * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections diff --git a/components/chat.tsx b/components/chat.tsx index 16bc3d1..b392856 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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(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(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 ; + case "data-compaction": + return ( + setCompactionSummary(part.data as CompactionDividerData)} + /> + ); + case "data-compaction-cut": + return ( + setCompactionSummary(part.data as CompactionDividerData)} + /> + ); case "data-practice-thinking": return ; default: @@ -1418,6 +1456,7 @@ export function Chat({ : undefined} @@ -1491,6 +1530,7 @@ export function Chat({ )} {planOpen && setPlanOpen(false)} />} setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} /> + setCompactionSummary(null)} /> @@ -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; + /** 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 ? ( ) : ( composer diff --git a/components/compaction-divider.tsx b/components/compaction-divider.tsx new file mode 100644 index 0000000..e2900f9 --- /dev/null +++ b/components/compaction-divider.tsx @@ -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 ( +
+ +
+ ); +} + +/** 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 ( + + ); +} + +/** 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 ( +
+
+ Compacted context + +
+
+
+					{summary.content}
+				
+
+
+ ); +} + +/** 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( +
+ +
, + document.body, + ); +} diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index e38c428..ca2f53b 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -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. */}
- ↵ submits · ⇧↵ newline + {compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
@@ -228,6 +235,7 @@ function QuestionDockForm({ onSubmit, onEscape, dictationClaim = true, + compactionRunning = false, }: Omit) { 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({ )}
- ↵ submits · ⇧↵ newline + {compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}