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:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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<"));
|
||||||
|
|||||||
Reference in New Issue
Block a user