From 62cfb8112b1c140c802f89ead854df095c964b08 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 00:44:02 +0300 Subject: [PATCH] =?UTF-8?q?confidence=20slider=20UI=20round:=20global=20?= =?UTF-8?q?=E2=86=90/=E2=86=92,=20fraction=20positions,=20color=20ramp;=20?= =?UTF-8?q?plan-flank=20phantom=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- components/chat.tsx | 9 +++- components/plan-window.tsx | 11 ++++ components/question-dock.tsx | 101 ++++++++++++++++++++++++----------- tests/plan-window.test.tsx | 15 +++++- 4 files changed, 103 insertions(+), 33 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 25d3497..72a8cfc 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,7 +26,7 @@ import { StatsBar } from "@/components/stats-bar"; import { useActivity } from "@/components/activity-dock"; import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core"; 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 { WaypointsIcon } from "lucide-react"; 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 // top-bar plan chip, and the dock band's marginalia. 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 // turn settle — the moment this session's uncovered mass changes. const [coverageTick, setCoverageTick] = useState(0); @@ -1163,7 +1168,7 @@ export function Chat({ } + plan={hasPlan ? : undefined} onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} diff --git a/components/plan-window.tsx b/components/plan-window.tsx index c6cf628..6471ddc 100644 --- a/components/plan-window.tsx +++ b/components/plan-window.tsx @@ -30,6 +30,17 @@ import { 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[] }) { const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); const tree = useMemo(() => latestTree(messages), [messages]); diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 413f249..4d29a5d 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -79,6 +79,19 @@ export interface QuestionDockProps { 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({ toolName, toolCallId, @@ -271,7 +284,6 @@ function QuestionDockForm({ // deliberate (the same number, the same meaning). The dock unmounts // between questions, so each question starts fresh at 50. const [confIdx, setConfIdx] = useState(DEFAULT_CONFIDENCE_INDEX); - const confidenceRef = useRef(null); const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select"; // 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 // globally — they must not ALSO walk the question rows). const modded = e.altKey || e.metaKey || e.ctrlKey; - // Plain Tab walks the dock's focus chain forward: rows → - // confidence slider (quiz, when enabled) → thinking. Shift+Tab - // falls through (the global thinking-level cycle owns it), so the - // shift check matters. + // Plain Tab toggles focus: rows → thinking. The confidence + // slider takes NO focus at all (owner, UI round 2026-10-01) — + // global ←/→ own its keyboard, so it never joins the chain. + // Shift+Tab falls through (the global thinking-level cycle owns + // it), so the shift check matters. if (e.key === "Tab" && !e.shiftKey && !modded) { e.preventDefault(); e.stopPropagation(); - if (t?.closest?.("[data-confidence]")) thinkingRef.current?.focus(); - else if (t?.closest?.("[data-kbdrow]")) { - const slider = confidenceRef.current; - if (slider && !slider.disabled) slider.focus(); - else thinkingRef.current?.focus(); - } else { + if (t?.closest?.("[data-kbdrow]")) thinkingRef.current?.focus(); + else { const idx = kbdIdx ?? 0; setKbdIdx(idx); focusRow(idx); @@ -413,6 +422,19 @@ function QuestionDockForm({ const next = Math.max((kbdIdx ?? rowCount) - 1, 0); setKbdIdx(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 === " ") { if (kbdIdx === null) return; // plain Space keeps scrolling the page e.preventDefault(); @@ -589,12 +611,16 @@ function QuestionDockForm({ )} - {/* Confidence (owner, 2026-10-01): a snapped slider below the options - and above "Your thinking" — the learner's stated probability - that his selected answer is correct. ←/→ move it (native range - semantics over the stop INDEX, so the uneven grid steps - cleanly). "I don't know" GRAYS it out — the layout holds, no - value is sent, the IDK signal stands alone. */} + {/* Confidence (owner, 2026-10-01; UI round same day): a snapped + slider below the options and above "Your thinking" — the + learner's stated probability that his selected answer is + correct. The thumb's POSITION is the raw fraction (50 sits + halfway), the fill + handle wear the stop's ramp color, and + 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" && (
@@ -607,27 +633,42 @@ function QuestionDockForm({ Confidence setConfIdx(Number(e.target.value))} - onKeyDown={(e) => { - // Enter submits from here too (dock-wide intuition); - // arrows stay native — ←/→ step the stop index. - if (e.key === "Enter" && canSubmit) { - e.preventDefault(); - submit(); + // No focus, ever: the owner's global-←/→ pattern owns + // the keyboard; clicking adjusts without ringing. + onMouseDown={(e) => e.preventDefault()} + onChange={(e) => { + // Snap to the nearest stop; the rendered VALUE is + // the stop's own percent, so the thumb always rests + // at its true fraction (50 = halfway). + 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", + )} /> { + 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", () => { it("renders nothing until a tree exists", () => { assert.equal(renderToStaticMarkup(), "");