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:
+7
-2
@@ -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"}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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={[]} />), "");
|
||||||
|
|||||||
Reference in New Issue
Block a user