From f4775ebc93b14f1a51b3ec58996d3226d51ab57a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 05:18:26 +0300 Subject: [PATCH] =?UTF-8?q?ui:=20round=206=20=E2=80=94=20Enter=20submits?= =?UTF-8?q?=20from=20anywhere;=20model=20dashes=20ACTUALLY=20kept;=20dumbb?= =?UTF-8?q?ell=20right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ENTER SUBMITS REGARDLESS OF FOCUS (owner): dictation means the dock fields are rarely focused. New useEnterSubmits hook — a window-level listener inside both docks (mounted only while a problem/question is active). Plain Enter anywhere outside a typing surface submits the practice thinking / the teaching answer; typing surfaces keep their own handlers, buttons keep their click-activation (the collapsed Problem bar still expands on Enter), modified Enters untouched. Teaching sessions get the same law as practice — one standard. - MODEL DASHES: the round-5 join("-") edit never landed (the batch script aborted on an earlier assert before reaching it) — the source still joined with spaces while the commit message claimed otherwise. Now actually joined with dashes: 'glm-5.3-flash' everywhere (picker, setup dialog, stats bar); pins updated to match. - SIDEBAR: the practice dumbbell moves to the far right of the session row — titles sit flush with teaching rows, cleaner list. --- components/enter-submits.ts | 44 +++++++++++++++++++++++++++++++++ components/practice-dock.tsx | 7 ++++++ components/question-dock.tsx | 6 +++++ components/sidebar.tsx | 9 ++++--- components/usage-format.ts | 16 ++++++------ tests/render-settings.test.tsx | 2 +- tests/render-stats-bar.test.tsx | 14 +++++------ 7 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 components/enter-submits.ts diff --git a/components/enter-submits.ts b/components/enter-submits.ts new file mode 100644 index 0000000..5f2a890 --- /dev/null +++ b/components/enter-submits.ts @@ -0,0 +1,44 @@ +"use client"; + +/** + * ENTER SUBMITS — regardless of where focus is (owner round 6, 2026-10-05). + * The dictation reality: the learner dictates into the dock's field without + * ever focusing it, so Enter must submit from ANYWHERE — body focus, a + * scroll, a stray link. The field's own Enter handler still fires first + * when it IS focused (this listener sits at the window level, bubble's last + * stop) — one law, two entry points, no double submit (the focused field's + * handler claims the event via its own keydown; this hook skips typing + * surfaces entirely). + * + * What the window listener refuses to touch: + * - typing surfaces (input/textarea/select/contenteditable) — the focused + * surface owns its Enter (the dock field, the composer, settings); + * - BUTTONS — Enter on a focused button is its click (keyboard button + * semantics: the collapsed Problem bar must expand, not submit); + * - modified Enter (⇧↵ is a newline, ⌘/Ctrl↵ belongs to the surface). + * + * The submit callbacks self-guard (empty thinking / canSubmit) — this hook + * never checks answer validity, only focus honesty. + */ + +import { useEffect, useRef } from "react"; +import { isTypingSurface } from "@/src/core"; + +/** Mount-scoped: while the dock is mounted (a problem/question is active), + * plain Enter anywhere outside a typing surface or button submits it. */ +export function useEnterSubmits(submit: () => void): void { + const submitRef = useRef(submit); + submitRef.current = submit; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (isTypingSurface(e)) return; + if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return; + e.preventDefault(); + submitRef.current(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index 5c1ee47..e38c428 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -5,6 +5,7 @@ import { ChevronRightIcon } from "lucide-react"; import { MessageResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; import { joinDictation, useDictationTarget } from "@/components/dictation"; +import { useEnterSubmits } from "@/components/enter-submits"; import { cn } from "@/lib/utils"; /** @@ -69,6 +70,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi onSubmit(text); }; + /* ENTER SUBMITS FROM ANYWHERE (owner round 6, 2026-10-05): dictation + * means the field is often never focused — the window-level Enter + * (typing surfaces and buttons excluded) submits the trace. Mounted + * while the dock is — collapsed bar included (the trace survives). */ + useEnterSubmits(submit); + /* ── the collapsed bar ── */ if (!expanded) { return ( diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 752ae70..57f54f5 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation"; +import { useEnterSubmits } from "@/components/enter-submits"; import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; import { @@ -367,6 +368,11 @@ function QuestionDockForm({ onSubmit(buildAnswer(qd, req)); }; + /* ENTER SUBMITS FROM ANYWHERE (owner round 6, 2026-10-05): the same + * law as the practice dock — dictated answers rarely focus the field. + * Rides submit() so the dictation-flush law is shared, not forked. */ + useEnterSubmits(submit); + // Selecting Other never clears the thinking field — two channels, the // answer and the reasoning behind it, side by side. const selectOther = () => setSel(toggleOther(selectMode, sel)); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 60d3b22..88bf8c1 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -268,9 +268,6 @@ export function SessionRow({ )} > - {(chat.kind ?? "teach") === "practice" && ( - - )} {/* Titles are the learner's/teacher's WORDS — Source Sans 3, the same voice the words were typed in (every input is lh-prose); @@ -280,6 +277,12 @@ export function SessionRow({ {chat.title} {sessionMeta(chat)} + {/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) — + was left of the name; right-aligned keeps the titles flush with + the teaching rows and reads cleaner. */} + {(chat.kind ?? "teach") === "practice" && ( + + )} ); } diff --git a/components/usage-format.ts b/components/usage-format.ts index b19a859..584b371 100644 --- a/components/usage-format.ts +++ b/components/usage-format.ts @@ -25,12 +25,14 @@ export function splitQualified(id: string): { provider: string | null; id: strin } /** - * "z-ai/glm-5.3-flash" → "Glm 5.3 Flash" — vendor prefix dropped, dashes to - * spaces, capitalized; a trailing all-digit segment (OpenRouter's date - * suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly. + * "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the + * name KEPT EXACTLY as its provider writes it (dashes stay, no + * auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit + * segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped. + * Quiet-chip friendly. * * With `qualified` (usage stamps are "provider:id", model-slots wave): - * the provider rides as a quiet suffix — "Glm 5.3 Flash · openrouter" — + * the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" — * because with federation the same model via two providers is genuinely * different bookkeeping. Bare catalog ids are unchanged. */ @@ -39,8 +41,8 @@ export function prettyModel(id: string, qualified = false): string { const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; const parts = short.split("-"); const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; - // No auto-capitalization (owner round 4, 2026-10-05): model names - // render exactly as their providers write them. - const pretty = trimmed.join(" "); + // No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05): + // model names render exactly as their providers write them. + const pretty = trimmed.join("-"); return qualified && provider ? `${pretty} · ${provider}` : pretty; } \ No newline at end of file diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 9bf2a56..6df9cfa 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -155,7 +155,7 @@ describe("AgentSection (thin SSR)", () => { ); // The trigger shows the stored id even with no catalog loaded (a // trigger reading "Default" over a live override would lie). - assert.ok(html.includes("glm 4.7 flash"), "the override id is visible (prettyModel form, no auto-caps)"); + assert.ok(html.includes("glm-4.7-flash"), "the override id is visible (prettyModel form, no auto-caps)"); assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off"); assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on"); }); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 04bb4ab..829770c 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -77,20 +77,20 @@ describe("stats bar", () => { assert.match(html, /data-testid="stats-context">11k { - assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm 5.3 flash"); // no auto-caps (owner round 4) - assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8 max"); + it("prettyModel strips vendor + date suffix, keeps dashes", () => { + assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) + assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); }); it("provider-qualified stamps (usage contract, model-slots wave)", () => { // qualified: the provider rides as a quiet suffix — federation makes // the same model via two providers different bookkeeping - assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm 5.3 flash · openrouter"); - assert.equal(prettyModel("openai:gpt-5.2", true), "gpt 5.2 · openai"); + assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter"); + assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai"); // unqualified on a qualified stamp: provider silently dropped (old shape) - assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm 5.3 flash"); + assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash"); // qualified on a bare id: no suffix invented - assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm 5.3 flash"); + assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash"); }); });