Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user