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);
});
- it("prettyModel strips vendor + date suffix (no auto-caps)", () => {
- 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");
});
});