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
+47 -39
View File
@@ -10,10 +10,12 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar";
import { SetupDialog } from "@/components/setup-dialog";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { StatsBar } from "@/components/stats-bar";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { SessionStarter } from "@/components/start-session";
import type { ChatSettings, GraphSnapshot } from "@/src/core";
/** The draft's pick set (owner round 10): the SAME shape the home pick
@@ -35,18 +37,18 @@ export type DraftPick = { id: string; name: string; statement: string };
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
/**
* The draft's whole behavior as one pure seam: create the session (title =
* the first message), persist the lifted pre-chat settings, return the push
* target — /chat/<id>?prompt=<msg> — whose initialPrompt path delivers the
* message as turn 1. Injected fetch/router so tests pin the contract.
* Returns null on empty text; rethrows on transport failure (the caller's
* busy flag resets).
* Create, safely hand over the full opening text, then return a short
* /chat/<id> target. Storage/creation failures leave the caller's draft
* intact. A per-composer starter reuses an already-created session on an
* unchanged retry. The server stamps settings at birth; no settings PUT.
*/
export async function startDraftSession(
text: string,
fetchLike: typeof fetch,
mode?: "practice",
picks?: readonly DraftPick[],
starter = new SessionStarter(),
settingsIdentity?: string,
): Promise<string | null> {
const t = text.trim();
if (!t) return null;
@@ -54,19 +56,13 @@ export async function startDraftSession(
// from the pick bar creates its session WITH the graph context — the
// same contract as the home screen's practice send (the marker with
// [id] name: statement lines, target validation's truth set).
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
),
});
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.
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
return starter.start(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
fetchLike,
settingsIdentity,
);
}
/** The pure shell (no router) — the render-test seam: the draft view's
@@ -79,6 +75,7 @@ export function DraftShell({
text,
onChange,
onSend,
startError = false,
setupCta,
mode = "teach",
picks = [],
@@ -90,6 +87,7 @@ export function DraftShell({
text: string;
onChange: (v: string) => void;
onSend: () => void;
startError?: boolean;
/** The practice draft (owner, 2026-10-04): ?mode=practice — the
* created session carries kind:"practice" and the copy changes
* (the practice agent opens; no probing). */
@@ -119,7 +117,7 @@ export function DraftShell({
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
// the only text field on this surface — the cycle's whole chain.
const cycleComposer = useCallback((): readonly HTMLElement[] =>
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] [data-file-path-input]')),
[]);
useFieldCycle(cycleComposer);
const dictation = useDictation({
@@ -143,7 +141,7 @@ export function DraftShell({
};
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
* the keyboard from the stop — falling edge of `active` only. */
const composerInputRef = useRef<HTMLTextAreaElement>(null);
const composerInputRef = useRef<HTMLDivElement>(null);
const dictationWasActiveRef = useRef(false);
useEffect(() => {
if (dictation.active) {
@@ -193,6 +191,7 @@ export function DraftShell({
)}
setup={setupCta}
/>
{startError && <p role="alert" className="pointer-events-auto mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
@@ -247,6 +246,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
setText(v);
};
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
// THE PICK BAR (owner round 10): the draft's lifted pick set + the
// snapshot the standalone chain-table picker reads. Lazy — the graph
// is fetched on FIRST open only, then reused.
@@ -279,19 +281,12 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// 33 providers, chats cannot start. The draft stays in the textarea;
// Save stores the slots and never sends (owner ruling 2026-10-09) —
// the learner sends the draft themselves.
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();
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const [setupOpen, setSetupOpen] = useState(false);
const send = useCallback(() => {
const draft = textRef.current.trim();
if (!draft || busy) return;
if (!draft || startingRef.current) return;
if (!configured) {
setSetupOpen(true);
return;
@@ -299,25 +294,39 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
// BEFORE startDraftSession creates the record — a refused first
// send would otherwise leave an orphan error-shell chat in the
// sidebar. Same skip-everything-and-open-setup treatment; the
// server's 503 gate remains the belt for /chat/[id] sessions.
if (zeroKeys || strongKeyless) {
// sidebar. Zero providers opens connection setup; an unavailable
// selection with another provider opens the ordinary Strong 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; // The learner replaces it explicitly, then sends again.
}
startingRef.current = true;
setBusy(true);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
setStartError(false);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined,
starterRef.current, JSON.stringify(catalog?.slots.strong))
.then((target) => {
if (target) router.push(target);
else setBusy(false);
else { startingRef.current = false; setBusy(false); }
})
.catch(() => setBusy(false));
}, [busy, router, configured, zeroKeys, strongKeyless]);
.catch(() => {
startingRef.current = false;
setBusy(false);
setStartError(true);
});
}, [router, configured, zeroKeys, catalog, mode]);
return (
<>
<DraftShell
busy={busy}
startError={startError}
text={text}
onChange={applyText}
onSend={send}
@@ -372,7 +381,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
open={setupOpen}
onClose={() => setSetupOpen(false)}
onDone={() => {
setConfigured(true);
setSetupOpen(false);
// Save only saves (owner ruling 2026-10-09): the draft
// waits in the composer; the learner sends it.