Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+51 -31
View File
@@ -7,10 +7,12 @@ 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 { PathInput } from "@/components/path-input";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import {
buildAnswer,
repairMathForDisplay,
normalizeQuizType,
CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX,
@@ -30,6 +32,7 @@ import {
type DockSelection,
type ExplainToolInput,
type QuestionAnswerData,
type QuestionFormDraft,
type QuestionData,
type QuestionMode,
type QuizToolInput,
@@ -73,6 +76,9 @@ export interface QuestionDockProps {
reveal?: boolean;
onExpand: () => void;
onSubmit: (output: QuestionAnswerData) => void;
/** Restore only this call's unconfirmed submission after recovery. */
initialDraft?: QuestionFormDraft;
onSaveDraft?: (draft: QuestionFormDraft) => void;
/** Escape in a question field stops dictation (STT wave) — threaded
* from the page that owns the dictation hook. */
onEscape?: () => void;
@@ -102,6 +108,8 @@ export function QuestionDock({
reveal = false,
onExpand,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
phase = "probe",
dictationClaim = true,
@@ -157,6 +165,8 @@ export function QuestionDock({
phase={phase}
active={expanded}
onSubmit={onSubmit}
initialDraft={initialDraft}
onSaveDraft={onSaveDraft}
onEscape={onEscape}
dictationClaim={dictationClaim}
compactionRunning={compactionRunning}
@@ -168,7 +178,7 @@ export function QuestionDock({
/** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */
function teaserText(q: string): string {
return q
return repairMathForDisplay(q)
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
@@ -253,6 +263,8 @@ function QuestionDockForm({
phase = "probe",
active,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
dictationClaim = true,
compactionRunning = false,
@@ -315,22 +327,22 @@ function QuestionDockForm({
// single-select displaces, multi-select coexists — picking the ask
// "Other…" row in a multi-select must never clear already-picked
// options, and picking an option must never clear a typed custom answer.
const [sel, setSel] = useState<DockSelection>(emptyDockSelection());
const [sel, setSel] = useState<DockSelection>(() => initialDraft?.selection ?? emptyDockSelection());
// The learner's transfer channel — thinking, never "a note".
const [thinking, setThinking] = useState("");
const [other, setOther] = useState("");
const [text, setText] = useState("");
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
const [other, setOther] = useState(initialDraft?.other ?? "");
const [text, setText] = useState(initialDraft?.text ?? "");
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
* round, owner 2026-10-01): the run's flush lands via setState, which
* a closure waiting through `await finishDictation()` cannot observe
* in time — submit reads these, kept current at every write site. */
const thinkingTextRef = useRef("");
const answerTextRef = useRef("");
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
const answerTextRef = useRef(initialDraft?.text ?? "");
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
// resting at 50 — a real coin flip by definition, untouched or
// deliberate (the same number, the same meaning). The dock unmounts
// between questions, so each question starts fresh at 50.
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
const [confIdx, setConfIdx] = useState<number>(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX);
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
// The PRIMARY dictation input (owner, STT round 1): while a question
@@ -394,6 +406,15 @@ function QuestionDockForm({
// "untouched"); buildAnswer drops it on "I don't know".
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
};
// Snapshot before optimistic submission unmounts the form. Ref values
// include the dictation finals that just landed, unlike stale closures.
onSaveDraft?.({
selection: { ...sel, selected: new Set(sel.selected) },
thinking: thinkingTextRef.current,
other,
text: answerTextRef.current,
confidenceIndex: confIdx,
});
onSubmit(buildAnswer(qd, req, toolName));
};
@@ -423,8 +444,8 @@ function QuestionDockForm({
expanded — collapsed, the bar's native Enter expands it. */
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null);
const otherInputRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLDivElement>(null);
// One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). Text mode (ask/explain)
// has no rows — the composer IS the answer surface.
@@ -457,7 +478,7 @@ function QuestionDockForm({
const focusRow = (idx: number) => {
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
if (!row) return;
const el = (row.querySelector("input") as HTMLElement | null) ?? row;
const el = (row.querySelector("input, [data-file-path-input]") as HTMLElement | null) ?? row;
el.focus();
};
@@ -548,7 +569,7 @@ function QuestionDockForm({
{/* Question area — scrolls internally if very tall; the bottom row stays put.
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
long questions short — owner round, 2026-10-05). */}
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div data-dock-scroll-area="" data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "probe"
? t("Probe")
@@ -564,11 +585,11 @@ function QuestionDockForm({
{qd.mode === "multi-select" && t(" · select all that apply")}
</div>
<div className="mb-3 font-medium">
<MessageResponse>{qd.question}</MessageResponse>
<ToolContentResponse>{qd.question}</ToolContentResponse>
</div>
{qd.context && (
<div className="mb-3 text-sm text-muted-foreground">
<MessageResponse>{qd.context}</MessageResponse>
<ToolContentResponse>{qd.context}</ToolContentResponse>
</div>
)}
@@ -604,7 +625,7 @@ function QuestionDockForm({
)}
</span>
<span>
<MessageResponse>{opt.label}</MessageResponse>
<ToolContentResponse>{opt.label}</ToolContentResponse>
{kind !== "quiz" && opt.description && (
// Option subtext renders in Source Sans 3 (owner's mockup);
// the "I don't know" copy stays JetBrains Mono. QUIZ options render
@@ -613,9 +634,9 @@ function QuestionDockForm({
// records must not leak either. Same MessageResponse path as the
// label (owner, 2026-10-12): descriptions are full markdown —
// LaTeX included — not plain text.
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
<ToolContentResponse className="lh-prose block text-xs text-muted-foreground">
{opt.description}
</MessageResponse>
</ToolContentResponse>
)}
</span>
</button>
@@ -680,11 +701,12 @@ function QuestionDockForm({
</span>
)}
</span>
<input
<PathInput
ref={otherInputRef}
singleLine
value={other}
onChange={(e) => {
setOther(e.target.value);
onValueChange={(value) => {
setOther(value);
// Typing selects the row — displacing in
// single-select, alongside the siblings in multi.
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
@@ -834,11 +856,11 @@ function QuestionDockForm({
{qd.mode === "text" ? t("Your answer") : t("YOUR THINKING")}
</div>
{qd.mode === "text" ? (
<textarea
<PathInput
value={text}
onChange={(e) => {
answerTextRef.current = e.target.value;
setText(e.target.value);
onValueChange={(value) => {
answerTextRef.current = value;
setText(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -858,16 +880,15 @@ function QuestionDockForm({
}
}}
placeholder={t("Type your answer…")}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
) : (
<textarea
<PathInput
ref={thinkingRef}
value={thinking}
onChange={(e) => {
thinkingTextRef.current = e.target.value;
setThinking(e.target.value);
onValueChange={(value) => {
thinkingTextRef.current = value;
setThinking(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -888,7 +909,6 @@ function QuestionDockForm({
}
}}
placeholder={t("Why this answer? Whatever comes to mind.")}
rows={1}
className={cn(
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
// Phase-scaled prominence: teaching invites depth,