confidence slider UI round: global ←/→, fraction positions, color ramp; plan-flank phantom fix

Owner UI round 2026-10-01, three rulings on the slider plus one en-route
bug:

- GLOBAL ←/→, the ↑/↓ pattern: confidence adjusts wherever focus sits
  (window keydown, typing-guarded), no click-in, no focus ring. The
  slider itself takes NO focus — tabIndex -1, mousedown prevented,
  removed from the Tab chain (rows → thinking directly again).
- Position = fraction: the thumb's scale is 0-100 with the rendered
  value the stop's own percent, so 50 sits halfway; onChange snaps to
  the nearest stop (magnet feel while dragged).
- Six-step ramp (owner pick over three bands): 0 red, 30 orange,
  50 yellow (the coin flip), 70 lime, 90 green, 100 emerald — fill AND
  handle wear the stop's color; taller 10px pill bar, custom track/thumb
  (webkit + moz), muted remainder via --muted.
- Plan-flank phantom: gating the marginalia button on a JSX element
  (always truthy) mounted an invisible clickable button in the
  bottom-right whenever a question was pending with no plan.
  planExists() now gates it (pinned in plan-window tests).
This commit is contained in:
amosblomqvist
2026-10-02 00:44:02 +03:00
parent 9b9f6372fa
commit 62cfb8112b
4 changed files with 103 additions and 33 deletions
+7 -2
View File
@@ -26,7 +26,7 @@ import { StatsBar } from "@/components/stats-bar";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core"; import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window"; import { PlanMarginalia, planExists } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
import { WaypointsIcon } from "lucide-react"; import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core"; import type { PlanBlockData } from "@/src/core";
@@ -563,6 +563,11 @@ export function Chat({
// The live plan popup (wave 4): opened by any transcript plan block, the // The live plan popup (wave 4): opened by any transcript plan block, the
// top-bar plan chip, and the dock band's marginalia. // top-bar plan chip, and the dock band's marginalia.
const [planOpen, setPlanOpen] = useState(false); const [planOpen, setPlanOpen] = useState(false);
// Real plan presence — gates the marginalia's clickable flank. A JSX
// element is always truthy; gating on the node alone mounted a phantom
// (invisible, clickable) button in the bottom-right whenever a question
// was pending with no plan yet (owner report, UI round 2026-10-01).
const hasPlan = useMemo(() => planExists(messages), [messages]);
// The coverage ring's refresh chain (extraction-UX redesign): bumped at // The coverage ring's refresh chain (extraction-UX redesign): bumped at
// turn settle — the moment this session's uncovered mass changes. // turn settle — the moment this session's uncovered mass changes.
const [coverageTick, setCoverageTick] = useState(0); const [coverageTick, setCoverageTick] = useState(0);
@@ -1163,7 +1168,7 @@ export function Chat({
<DockOverlay <DockOverlay
pending={pending} pending={pending}
chatId={chatId} chatId={chatId}
plan={<PlanMarginalia messages={messages} />} plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)} onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal} requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"} phase={phases.at(-1) ?? "probe"}
+11
View File
@@ -30,6 +30,17 @@ import {
const MARGIN_PAD = GRAPH_PAD + 16; const MARGIN_PAD = GRAPH_PAD + 16;
/** True when the transcript holds a plan tree. Gates the marginalia's
* clickable flank (chat.tsx): PlanMarginalia renders null without a
* tree, but the WRAPPER button must unmount too — a JSX element is
* always truthy, so gating on it alone left a phantom click target in
* the bottom-right whenever a question was pending with no plan
* (owner report, UI round 2026-10-01). */
export function planExists(messages: readonly PopupMessage[]): boolean {
const tree = latestTree(messages);
return !!tree && tree.nodes.length > 0;
}
export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) { export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) {
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
const tree = useMemo(() => latestTree(messages), [messages]); const tree = useMemo(() => latestTree(messages), [messages]);
+71 -30
View File
@@ -79,6 +79,19 @@ export interface QuestionDockProps {
phase?: Phase3; phase?: Phase3;
} }
/** The six-step confidence ramp (owner, UI round 2026-10-01): color
* follows the number — each stop reads as its own belief level, red
* (believes the pick wrong) through emerald (certain), yellow the coin
* flip. Pairs with CONFIDENCE_STOPS index-for-index. */
const CONFIDENCE_COLORS: readonly string[] = [
"#ef4444", // 0 — red
"#f97316", // 30 — orange
"#eab308", // 50 — yellow: a coin flip
"#84cc16", // 70 — lime
"#22c55e", // 90 — green
"#10b981", // 100 — emerald
];
export function QuestionDock({ export function QuestionDock({
toolName, toolName,
toolCallId, toolCallId,
@@ -271,7 +284,6 @@ function QuestionDockForm({
// deliberate (the same number, the same meaning). The dock unmounts // deliberate (the same number, the same meaning). The dock unmounts
// between questions, so each question starts fresh at 50. // between questions, so each question starts fresh at 50.
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX); const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
const confidenceRef = useRef<HTMLInputElement>(null);
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select"; const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
// The PRIMARY dictation input (owner, STT round 1): while a question // The PRIMARY dictation input (owner, STT round 1): while a question
@@ -379,19 +391,16 @@ function QuestionDockForm({
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions // Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
// globally — they must not ALSO walk the question rows). // globally — they must not ALSO walk the question rows).
const modded = e.altKey || e.metaKey || e.ctrlKey; const modded = e.altKey || e.metaKey || e.ctrlKey;
// Plain Tab walks the dock's focus chain forward: rows → // Plain Tab toggles focus: rows → thinking. The confidence
// confidence slider (quiz, when enabled) → thinking. Shift+Tab // slider takes NO focus at all (owner, UI round 2026-10-01) —
// falls through (the global thinking-level cycle owns it), so the // global ←/→ own its keyboard, so it never joins the chain.
// shift check matters. // Shift+Tab falls through (the global thinking-level cycle owns
// it), so the shift check matters.
if (e.key === "Tab" && !e.shiftKey && !modded) { if (e.key === "Tab" && !e.shiftKey && !modded) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (t?.closest?.("[data-confidence]")) thinkingRef.current?.focus(); if (t?.closest?.("[data-kbdrow]")) thinkingRef.current?.focus();
else if (t?.closest?.("[data-kbdrow]")) { else {
const slider = confidenceRef.current;
if (slider && !slider.disabled) slider.focus();
else thinkingRef.current?.focus();
} else {
const idx = kbdIdx ?? 0; const idx = kbdIdx ?? 0;
setKbdIdx(idx); setKbdIdx(idx);
focusRow(idx); focusRow(idx);
@@ -413,6 +422,19 @@ function QuestionDockForm({
const next = Math.max((kbdIdx ?? rowCount) - 1, 0); const next = Math.max((kbdIdx ?? rowCount) - 1, 0);
setKbdIdx(next); setKbdIdx(next);
if (t?.closest?.("[data-kbdrow]")) focusRow(next); if (t?.closest?.("[data-kbdrow]")) focusRow(next);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
// Global confidence cycling (owner, UI round 2026-10-01): the
// SAME pattern as ↑/↓ over the rows — works wherever focus
// sits, no click-in, no focus ring. Dead on "I don't know"
// (the slider grays out and nothing is sent).
if (kind !== "quiz" || sel.dontKnow) return;
e.preventDefault();
setConfIdx((i) =>
Math.min(
CONFIDENCE_STOPS.length - 1,
Math.max(0, i + (e.key === "ArrowRight" ? 1 : -1)),
),
);
} else if (e.key === " ") { } else if (e.key === " ") {
if (kbdIdx === null) return; // plain Space keeps scrolling the page if (kbdIdx === null) return; // plain Space keeps scrolling the page
e.preventDefault(); e.preventDefault();
@@ -589,12 +611,16 @@ function QuestionDockForm({
)} )}
</div> </div>
{/* Confidence (owner, 2026-10-01): a snapped slider below the options {/* Confidence (owner, 2026-10-01; UI round same day): a snapped
and above "Your thinking" — the learner's stated probability slider below the options and above "Your thinking" — the
that his selected answer is correct. ←/→ move it (native range learner's stated probability that his selected answer is
semantics over the stop INDEX, so the uneven grid steps correct. The thumb's POSITION is the raw fraction (50 sits
cleanly). "I don't know" GRAYS it out — the layout holds, no halfway), the fill + handle wear the stop's ramp color, and
value is sent, the IDK signal stands alone. */} the bar is a taller pill. Keyboard: GLOBAL ←/→ (the ↑/↓
pattern — works wherever focus sits); the element itself
takes no focus and never rings (tabIndex -1, mousedown
prevented). "I don't know" GRAYS it out — the layout holds,
no value is sent, the IDK signal stands alone. */}
{kind === "quiz" && ( {kind === "quiz" && (
<div className="border-t border-border/60 px-4 py-2.5"> <div className="border-t border-border/60 px-4 py-2.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -607,27 +633,42 @@ function QuestionDockForm({
Confidence Confidence
</span> </span>
<input <input
ref={confidenceRef}
type="range" type="range"
min={0} min={0}
max={CONFIDENCE_STOPS.length - 1} max={100}
step={1} step={1}
value={confIdx} value={CONFIDENCE_STOPS[confIdx]}
disabled={sel.dontKnow} disabled={sel.dontKnow}
tabIndex={-1}
aria-label="Confidence — the probability your selected answer is correct" aria-label="Confidence — the probability your selected answer is correct"
title="The probability your selected answer is correct — 50% is a coin flip" aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`}
title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→."
data-testid="confidence-slider" data-testid="confidence-slider"
data-confidence="" // No focus, ever: the owner's global-←/→ pattern owns
onChange={(e) => setConfIdx(Number(e.target.value))} // the keyboard; clicking adjusts without ringing.
onKeyDown={(e) => { onMouseDown={(e) => e.preventDefault()}
// Enter submits from here too (dock-wide intuition); onChange={(e) => {
// arrows stay native — ←/→ step the stop index. // Snap to the nearest stop; the rendered VALUE is
if (e.key === "Enter" && canSubmit) { // the stop's own percent, so the thumb always rests
e.preventDefault(); // at its true fraction (50 = halfway).
submit(); const v = Number(e.target.value);
let best = 0;
for (let i = 1; i < CONFIDENCE_STOPS.length; i++) {
if (Math.abs(CONFIDENCE_STOPS[i]! - v) < Math.abs(CONFIDENCE_STOPS[best]! - v)) best = i;
} }
setConfIdx(best);
}} }}
className="h-1.5 min-w-0 flex-1 accent-primary disabled:opacity-40" style={{
color: CONFIDENCE_COLORS[confIdx],
background: `linear-gradient(90deg, ${CONFIDENCE_COLORS[confIdx]} 0%, ${CONFIDENCE_COLORS[confIdx]} ${CONFIDENCE_STOPS[confIdx]}%, var(--muted) ${CONFIDENCE_STOPS[confIdx]}%, var(--muted) 100%)`,
}}
className={cn(
"h-2.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full outline-none disabled:opacity-40",
"[&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent",
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:-mt-[3px]",
"[&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent",
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-current",
)}
/> />
<span <span
data-testid="confidence-value" data-testid="confidence-value"
+14 -1
View File
@@ -1,7 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { PlanMarginalia } from "../components/plan-window.tsx"; import { PlanMarginalia, planExists } from "../components/plan-window.tsx";
import { blockLayout, type PopupMessage } from "../components/plan-block.tsx"; import { blockLayout, type PopupMessage } from "../components/plan-block.tsx";
import type { PlanTree } from "../src/core/contracts.ts"; import type { PlanTree } from "../src/core/contracts.ts";
@@ -44,6 +44,19 @@ function transcript(tree: PlanTree, taggedNodeId?: string): PopupMessage[] {
]; ];
} }
describe("planExists", () => {
it("false without a tree — the phantom-click gate (owner report, 2026-10-01)", () => {
// A JSX element is always truthy; the WRAPPER must gate on real
// presence or an invisible clickable button sits in the flank.
assert.equal(planExists([]), false);
assert.equal(planExists(transcript({ nodes: [], edges: [], version: 0 })), false);
});
it("true once the transcript holds a plan tree", () => {
assert.equal(planExists(transcript(CHAIN)), true);
});
});
describe("PlanMarginalia render", () => { describe("PlanMarginalia render", () => {
it("renders nothing until a tree exists", () => { it("renders nothing until a tree exists", () => {
assert.equal(renderToStaticMarkup(<PlanMarginalia messages={[]} />), ""); assert.equal(renderToStaticMarkup(<PlanMarginalia messages={[]} />), "");