ui: round 6 — Enter submits from anywhere; model dashes ACTUALLY kept; dumbbell right
- 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.
This commit is contained in:
@@ -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);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ChevronRightIcon } from "lucide-react";
|
|||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
onSubmit(text);
|
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 ── */
|
/* ── the collapsed bar ── */
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
|
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -367,6 +368,11 @@ function QuestionDockForm({
|
|||||||
onSubmit(buildAnswer(qd, req));
|
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
|
// Selecting Other never clears the thinking field — two channels, the
|
||||||
// answer and the reasoning behind it, side by side.
|
// answer and the reasoning behind it, side by side.
|
||||||
const selectOther = () => setSel(toggleOther(selectMode, sel));
|
const selectOther = () => setSel(toggleOther(selectMode, sel));
|
||||||
|
|||||||
@@ -268,9 +268,6 @@ export function SessionRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SessionStatus busy={chat.busy} />
|
<SessionStatus busy={chat.busy} />
|
||||||
{(chat.kind ?? "teach") === "practice" && (
|
|
||||||
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{/* Titles are the learner's/teacher's WORDS — Source Sans 3, the
|
{/* Titles are the learner's/teacher's WORDS — Source Sans 3, the
|
||||||
same voice the words were typed in (every input is lh-prose);
|
same voice the words were typed in (every input is lh-prose);
|
||||||
@@ -280,6 +277,12 @@ export function SessionRow({
|
|||||||
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
||||||
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{/* 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" && (
|
||||||
|
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the
|
||||||
* spaces, capitalized; a trailing all-digit segment (OpenRouter's date
|
* name KEPT EXACTLY as its provider writes it (dashes stay, no
|
||||||
* suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly.
|
* 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):
|
* 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
|
* because with federation the same model via two providers is genuinely
|
||||||
* different bookkeeping. Bare catalog ids are unchanged.
|
* 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 short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
||||||
const parts = short.split("-");
|
const parts = short.split("-");
|
||||||
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
||||||
// No auto-capitalization (owner round 4, 2026-10-05): model names
|
// No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05):
|
||||||
// render exactly as their providers write them.
|
// model names render exactly as their providers write them.
|
||||||
const pretty = trimmed.join(" ");
|
const pretty = trimmed.join("-");
|
||||||
return qualified && provider ? `${pretty} · ${provider}` : pretty;
|
return qualified && provider ? `${pretty} · ${provider}` : pretty;
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ describe("AgentSection (thin SSR)", () => {
|
|||||||
);
|
);
|
||||||
// The trigger shows the stored id even with no catalog loaded (a
|
// The trigger shows the stored id even with no catalog loaded (a
|
||||||
// trigger reading "Default" over a live override would lie).
|
// 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-demo").includes('aria-checked="false"'), "demo off");
|
||||||
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
|
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,20 +77,20 @@ describe("stats bar", () => {
|
|||||||
assert.match(html, /data-testid="stats-context">11k</);
|
assert.match(html, /data-testid="stats-context">11k</);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prettyModel strips vendor + date suffix (no auto-caps)", () => {
|
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
|
||||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm 5.3 flash"); // no auto-caps (owner round 4)
|
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");
|
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
||||||
// qualified: the provider rides as a quiet suffix — federation makes
|
// qualified: the provider rides as a quiet suffix — federation makes
|
||||||
// the same model via two providers different bookkeeping
|
// 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("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("openai:gpt-5.2", true), "gpt-5.2 · openai");
|
||||||
// unqualified on a qualified stamp: provider silently dropped (old shape)
|
// 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
|
// 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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user