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
+36 -36
View File
@@ -9,11 +9,14 @@ import { GraphView } from "@/components/graph-view";
import { MinimalHome } from "@/components/minimal-home";
import { SetupDialog } from "@/components/setup-dialog";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { PathInput } from "@/components/path-input";
import { SessionStarter } from "@/components/start-session";
import type { TurnUsage } from "@/src/core";
/**
@@ -44,15 +47,8 @@ export function Home({
// start until the user has picked all three slots — no defaults for
// 33 providers. The dock shows the CTA while unconfigured; submitting
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
// stores the slots and the queued message sends).
const [configured, setConfigured] = useState(initialConfigured);
// The zero-keys truth (zero-keys wave): no provider key = the pill
// takes precedence over the models CTA (keys come first in the chain).
const zeroKeys = useZeroKeys();
// The strong slot's keyless truth (pre-send key guards, owner interlude
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
// provider has none — the same skip-everything treatment on send.
const strongKeyless = useStrongKeyless();
// stores slots only; the learner sends the message themselves).
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const [setupOpen, setSetupOpen] = useState(false);
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// topic draft; the strip lives in the stats bar's middle slot.
@@ -66,10 +62,13 @@ export function Home({
},
});
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on
* the keyboard from the stop. Falling edge only; this surface's only
* dictation target IS this textarea, so no case-split is needed. */
const topicInputRef = useRef<HTMLTextAreaElement>(null);
const topicInputRef = useRef<HTMLDivElement>(null);
// Tab navigates INTO the topic composer (owner, keyboard round
// 2026-10-06): the only text field on this surface — and home's
// composer is its OWN textarea, not the shared Composer component,
@@ -115,7 +114,7 @@ export function Home({
};
const begin = async () => {
const t = topicRef.current.trim();
if (!t || busy) return;
if (!t || startingRef.current) return;
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
// stays in the textarea; the learner sends it after Save).
if (!configured) {
@@ -126,36 +125,37 @@ export function Home({
// models case guards BEFORE creation; the key cases must too — a
// refused first send would otherwise leave an orphan error-shell
// chat in the sidebar (the 503 lands only after POST /api/chats
// already created the record). Same skip-everything-and-open-setup
// treatment; the server's 503 gate remains the belt for /chat/[id]
// sessions.
if (zeroKeys || strongKeyless) {
// already created the record). Zero providers opens connection setup;
// an unavailable selection with another provider opens its ordinary
// picker. The server's admission gate remains the belt.
if (zeroKeys) {
setSetupOpen(true);
return;
}
const strong = catalog?.slots.strong?.model;
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
openModelPicker();
return; // Preserve the draft and never create an orphan session.
}
startingRef.current = true;
setBusy(true);
setStartError(false);
try {
// The map context (owner, 2026-10-04): picks in EITHER mode ride
// the CREATION as graphContext — the server writes a
// graph-context marker as message[0] (a separate custom message
// before the learner's first user message, propositions + the
// chains among them); the prompt stays the learner's own words.
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}),
});
const { id } = (await res.json()) as { id: string };
// No settings PUT on create (per-session models, owner
// 2026-09-28): the SERVER stamps the newborn from the strong
// slot — the same default this surface's model control edits.
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
const target = await starterRef.current.start({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}, fetch, JSON.stringify(catalog?.slots.strong));
router.push(target);
} catch {
startingRef.current = false;
setBusy(false);
setStartError(true);
}
};
@@ -243,6 +243,7 @@ export function Home({
)
)}
/>
{startError && <p role="alert" className="mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
@@ -250,13 +251,14 @@ export function Home({
void start();
}}
>
<textarea
<PathInput
ref={topicInputRef}
value={topic}
onChange={(e) => {
topicRef.current = e.target.value;
setTopic(e.target.value);
onValueChange={(value) => {
topicRef.current = value;
setTopic(value);
}}
defaultDropTarget
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or
// Esc) — stopPropagation claims it (keyboard wave A) so
@@ -274,7 +276,6 @@ export function Home({
}}
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
{/* No tooltip on the start square — the ↵ glyph is the
@@ -328,7 +329,6 @@ export function Home({
open={setupOpen}
onClose={() => setSetupOpen(false)}
onDone={() => {
setConfigured(true);
setSetupOpen(false);
// Save only saves: the draft waits in the composer.
}}