fix: the knowledge-context slider lives in the stats bar's left slot — the session-data spot — with a "tok" readout

The original vision, restored (owner, 2026-09-15): pre-chat the session-
data side of the under-composer stats bar is free real estate ("—" only),
so the budget slider mounts exactly where session data will sit once a
session exists — via a new StatsBar leftSlot — vertically centered with
the model + thinking pills by the row's own geometry. Short 96px slider
instead of a full-width one; readout abbreviates "1k tok" so >1k budgets
stay on one line ("4.1k tokens" wrapped). Home and /chat/new both.
This commit is contained in:
amosblomqvist
2026-09-15 08:00:04 +03:00
parent 1d45d77303
commit 4c7ad48c73
5 changed files with 69 additions and 43 deletions
+17 -16
View File
@@ -11,15 +11,19 @@ import { formatTokens } from "@/components/usage-format";
/** /**
* The knowledge-context budget control (vision-context-injection.md) — the * The knowledge-context budget control (vision-context-injection.md) — the
* "session data" section's one input, shown where sessions START: the home * "session data" section's one input, shown where sessions START: the home
* (graph) view's new-session dock and the /chat/new draft. Input before * (graph) view's new-session dock and the /chat/new draft. It renders in
* output: the panel that will later show what the session contributed to * the StatsBar's LEFT slot — the very place session data (context/cost)
* the graph asks first what the graph will contribute to the session. * lives once a session exists, which pre-chat is free real estate (just
* "—"). Input before output: the panel that will later show what the
* session contributed to the graph asks first what the graph will
* contribute to the session.
* *
* One quiet slider row: the token budget for the prior-knowledge block the * One quiet compact row sized to sit in the stats band beside the model +
* injection steers in at session start. 0 = off; absent settings show the * thinking pills: short slider, "tok" readout (abbreviated — "4.1k tok"
* DEFAULT (1024 — on). The value rides ChatSettings (the same lifted * stays on one line where "4.1k tokens" wrapped). 0 = off; absent settings
* settings the model picker uses), so it persists pre-chat via the same * show the DEFAULT (1024 — on). The value rides ChatSettings (the same
* deferred PUT. Sessions never show this control — the injection is * lifted settings the model picker uses), so it persists pre-chat via the
* same deferred PUT. Sessions never show this control — the injection is
* one-shot at session start, so mid-session there is nothing to decide. * one-shot at session start, so mid-session there is nothing to decide.
*/ */
export function GraphContextControl({ export function GraphContextControl({
@@ -32,12 +36,9 @@ export function GraphContextControl({
testId?: string; testId?: string;
}) { }) {
return ( return (
<div <div data-testid={testId} className="flex items-center gap-2 text-xs text-muted-foreground">
data-testid={testId}
className="flex items-center gap-2 px-1 text-xxs text-muted-foreground"
>
<BookOpenIcon className="size-3 shrink-0" /> <BookOpenIcon className="size-3 shrink-0" />
<span className="shrink-0 uppercase tracking-widest">Knowledge context</span> <span className="shrink-0">Knowledge context</span>
<input <input
type="range" type="range"
min={0} min={0}
@@ -46,14 +47,14 @@ export function GraphContextControl({
value={value} value={value}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
aria-label="Prior-knowledge token budget" aria-label="Prior-knowledge token budget"
className="h-1 flex-1 accent-primary" className="h-1 w-24 shrink-0 accent-primary"
data-testid="graph-context-slider" data-testid="graph-context-slider"
/> />
<span <span
className="w-16 shrink-0 text-right tabular-nums" className="w-14 shrink-0 text-right tabular-nums"
data-testid="graph-context-value" data-testid="graph-context-value"
> >
{value === 0 ? "off" : `${formatTokens(value)} tokens`} {value === 0 ? "off" : `${formatTokens(value)} tok`}
</span> </span>
</div> </div>
); );
+11 -13
View File
@@ -72,17 +72,6 @@ export function Home() {
px-4) so nothing shifts when a session starts (owner). */} px-4) so nothing shifts when a session starts (owner). */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4"> <div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
{/* The session-data strip (vision-context-injection.md): what the
graph will contribute to the session, decided before the session
starts. Rides the same lifted settings as the model picker. */}
<div className="mb-1.5">
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
setSettings((s) => ({ ...s, graphContextTokens: tokens }))
}
/>
</div>
<form <form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg" className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => { onSubmit={(e) => {
@@ -114,13 +103,22 @@ export function Home() {
</button> </button>
</form> </form>
{/* Same as the chat view's under-composer stats bar (owner) — no {/* Same as the chat view's under-composer stats bar (owner) — no
session yet, so money/context fold as "—"; the model + session yet, so the free LEFT side carries the knowledge-context
thinking control still works (deferred save). */} budget (session-data slot), while the RIGHT keeps the model +
thinking control (deferred save). */}
<StatsBar <StatsBar
turns={[] as TurnUsage[]} turns={[] as TurnUsage[]}
chatId="" chatId=""
settings={settings} settings={settings}
onSettingsSaved={setSettings} onSettingsSaved={setSettings}
leftSlot={
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
setSettings((s) => ({ ...s, graphContextTokens: tokens }))
}
/>
}
/> />
</div> </div>
</div> </div>
+17 -11
View File
@@ -99,16 +99,6 @@ export function DraftShell({
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" /> <div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4"> <div className="bg-background pb-4">
<div className={DOCK}> <div className={DOCK}>
{/* The session-data strip (vision-context-injection.md) — same
control as the home view, riding the same lifted settings. */}
<div className="mb-1.5">
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
onSettingsSaved({ ...settings, graphContextTokens: tokens })
}
/>
</div>
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg"> <div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer <Composer
busy={busy} busy={busy}
@@ -120,7 +110,23 @@ export function DraftShell({
autoFocus autoFocus
/> />
</div> </div>
<StatsBar turns={[]} chatId="" settings={settings} onSettingsSaved={onSettingsSaved} /> <StatsBar
turns={[]}
chatId=""
settings={settings}
onSettingsSaved={onSettingsSaved}
// The session-data slot (owner, 2026-09-15): pre-chat the left
// side is free — the knowledge-context budget lives exactly
// where session data will sit once a session exists.
leftSlot={
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
onSettingsSaved({ ...settings, graphContextTokens: tokens })
}
/>
}
/>
</div> </div>
</div> </div>
</div> </div>
+15 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { ModelControl } from "@/components/model-picker"; import { ModelControl } from "@/components/model-picker";
@@ -11,6 +11,12 @@ import { ModelControl } from "@/components/model-picker";
* LEFT-aligned: context window + session cost; RIGHT-aligned: model + * LEFT-aligned: context window + session cost; RIGHT-aligned: model +
* thinking level. Clicking the left side opens the per-turn detail popover. * thinking level. Clicking the left side opens the per-turn detail popover.
* *
* Pre-chat views (home, /chat/new) pass `leftSlot`: with no session there
* IS no session data (the summary would just read "—"), so that left side
* is free real estate — the knowledge-context budget slider lives there
* (vision-context-injection.md), vertically centered beside the model +
* thinking pills by the row's own geometry.
*
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost) * All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
* — no cost math lives here. Absent data renders "—", never zero (the * — no cost math lives here. Absent data renders "—", never zero (the
* doctrine): no turns yet → every figure is "—"; turns the price table * doctrine): no turns yet → every figure is "—"; turns the price table
@@ -26,11 +32,15 @@ export function StatsBar({
chatId, chatId,
settings, settings,
onSettingsSaved, onSettingsSaved,
leftSlot,
}: { }: {
turns: TurnUsage[]; turns: TurnUsage[];
chatId: string; chatId: string;
settings?: ChatSettings; settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void; onSettingsSaved: (settings: ChatSettings) => void;
/** Pre-chat only: replaces the (empty) session-data summary — home and
* the /chat/new draft mount the knowledge-context slider here. */
leftSlot?: ReactNode;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Same dropdown discipline as the model control (owner, df4433f treatment, // Same dropdown discipline as the model control (owner, df4433f treatment,
@@ -74,6 +84,9 @@ export function StatsBar({
outside-close ref anchors HERE too (owner, round 3): on the whole outside-close ref anchors HERE too (owner, round 3): on the whole
bar it wrapped the model control, so clicking model/thinking never bar it wrapped the model control, so clicking model/thinking never
counted as outside — the asymmetry the owner observed. */} counted as outside — the asymmetry the owner observed. */}
{/* The left side: the session-data summary, or — pre-chat — the
caller's slot (the knowledge-context slider) in its place. */}
{leftSlot ?? (
<div ref={barRef} className="relative"> <div ref={barRef} className="relative">
<button <button
type="button" type="button"
@@ -141,6 +154,7 @@ export function StatsBar({
</div> </div>
)} )}
</div> </div>
)}
{/* The model + thinking control — one controller for the conversation {/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */} the ACTUAL models billed, regardless of what the picker says. */}
+9 -2
View File
@@ -84,7 +84,7 @@ describe("DraftShell — the sessionless surface", () => {
assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html)); assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html));
}); });
it("renders the knowledge-context slider at the decided default (absent settings = on, 1024)", () => { it("renders the knowledge-context slider in the stats bar's left slot, at the decided default (absent settings = on, 1024)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<DraftShell <DraftShell
busy={false} busy={false}
@@ -96,9 +96,16 @@ describe("DraftShell — the sessionless surface", () => {
/>, />,
); );
assert.ok(html.includes('data-testid="graph-context-control"')); assert.ok(html.includes('data-testid="graph-context-control"'));
// INSIDE the stats row (the session-data slot), not a strip above the
// composer: the control renders after the stats bar opens.
assert.ok(
html.indexOf('data-testid="stats-bar"') < html.indexOf('data-testid="graph-context-control"'),
"the slider is the stats bar's left slot",
);
// The slider's default position: 1024 of 4096, step 128 (decided). // The slider's default position: 1024 of 4096, step 128 (decided).
assert.ok(html.includes('min="0"') && html.includes('max="4096"') && html.includes('step="128"')); assert.ok(html.includes('min="0"') && html.includes('max="4096"') && html.includes('step="128"'));
assert.ok(html.includes("1k tokens"), "the readout shows the default budget, not 0 — default is ON"); // The abbreviated readout (owner: "tokens" wrapped at >1k — "tok" stays one line).
assert.ok(html.includes("1k tok"), "the readout shows the default budget, not 0 — default is ON");
assert.ok(html.includes("Knowledge context"), "the control is labeled"); assert.ok(html.includes("Knowledge context"), "the control is labeled");
// Absent settings render the default — never 'off'. // Absent settings render the default — never 'off'.
assert.ok(!html.includes(">off<")); assert.ok(!html.includes(">off<"));