diff --git a/components/composer-actions.tsx b/components/composer-actions.tsx new file mode 100644 index 0000000..f2be32a --- /dev/null +++ b/components/composer-actions.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { ReactNode } from "react"; +import { DumbbellIcon } from "lucide-react"; + +/** Home and the draft share one wrapping action row. Picker controls lead; + * setup is centered only when it is the sole action. */ +export function ComposerActions({ picker, setup }: { picker?: ReactNode; setup?: ReactNode }) { + if (!picker && !setup) return null; + return ( +
+ {picker} + {setup} +
+ ); +} + +/** Count and Clear stay together if the setup action wraps onto another line. + * These are node counts, even when a whole chain supplied the selection. */ +export function NodePickerControl({ count, onOpen, onClear }: { + count: number; + onOpen?: () => void; + onClear?: () => void; +}) { + return ( +
+ + {count > 0 && ( + + )} +
+ ); +} diff --git a/components/home.tsx b/components/home.tsx index 5c674a6..4b16d62 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -6,6 +6,7 @@ import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; 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 { DictationBar, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -217,43 +218,18 @@ export function Home({ send consumes it). Practice mode with nothing picked shows the pick HINT so the affordance is discoverable; teaching mode with nothing picked renders nothing. */} - {(practiceProps.length > 0 || mode === "practice") && ( -
- - {practiceProps.length > 0 && ( - - )} -
- )} - {zeroKeys ? ( - setSetupOpen(true)} /> - ) : ( - !configured && ( -
+ 0 || mode === "practice") && ( + setChainSelectOpen(true)} + onClear={() => setPracticeProps([])} + /> + )} + setup={zeroKeys ? ( + setSetupOpen(true)} /> + ) : ( + !configured && ( -
- ) - )} + ) + )} + />
{ diff --git a/components/new-session.tsx b/components/new-session.tsx index 4a6d608..c2a0930 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -11,7 +11,7 @@ import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; -import { DumbbellIcon } from "lucide-react"; +import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; import type { ChatSettings, GraphSnapshot } from "@/src/core"; /** The draft's pick set (owner round 10): the SAME shape the home pick @@ -97,8 +97,8 @@ export function DraftShell({ * NewSession, which owns the configured state and the SetupDialog. */ setupCta?: ReactNode; /** THE PICK BAR (owner round 10): the SAME pill the home screen - * carries above its composer — dashed "Pick propositions from the - * map" when empty, count + Clear when picked. The draft screen has + * carries above its composer — dashed "Pick nodes" when empty, + * count + Clear when picked. The draft screen has * no graph behind it, so the picker opens the standalone chain-table * overlay over fetched snapshot data (owned by NewSession). */ picks?: DraftPick[]; @@ -175,7 +175,7 @@ export function DraftShell({ title={mode === "practice" ? "New practice session" : "New session"} description={ mode === "practice" - ? "Describe what to practice — propositions, a chain, or a problem you brought." + ? "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem." : "The teacher will open by probing what you already know." } /> @@ -184,40 +184,12 @@ export function DraftShell({
- {setupCta} - {(picks.length > 0 || mode === "practice") && ( - /* pointer-events-auto: the sticky wrapper is a none-mask - * (953b22b) — every interactive row below it must re-enable - * itself, like the composer box and the CTA row do. */ -
- - {picks.length > 0 && ( - - )} -
- )} + 0 || mode === "practice") && ( + + )} + setup={setupCta} + />
setSetupOpen(true)} /> + setSetupOpen(true)} /> ) : ( !configured && ( -
-
) ) } diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index e9cea7d..b97a816 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -61,19 +61,18 @@ export function useZeroKeys(): boolean { /** The pill itself — the models CTA's chrome and placement, the keys * chain's copy. testid "setup-cta-keys" (the models CTA keeps * "setup-cta"; probes can tell them apart). */ -export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) { - return ( -
- -
+export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) { + const button = ( + ); + return inline ? button :
{button}
; } /** Pure: the strong slot is pinned to a provider whose key is GONE (owner * interlude, 2026-09-25 — the original deleted-key scenario). The catalog diff --git a/tests/composer-actions.test.tsx b/tests/composer-actions.test.tsx new file mode 100644 index 0000000..98d557d --- /dev/null +++ b/tests/composer-actions.test.tsx @@ -0,0 +1,86 @@ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { ComposerActions, NodePickerControl } from "../components/composer-actions.tsx"; +import { ZeroKeysPill } from "../components/setup-cta.tsx"; +import { DraftShell } from "../components/new-session.tsx"; + +const modelSetup = ; +const keySetup = {}} />; +function documentFor(node: React.ReactNode) { + return parseHTML(`${renderToStaticMarkup(node)}`).document; +} + +describe("composer action row", () => { + it("renders nothing when neither action exists", () => { + assert.equal(renderToStaticMarkup(), ""); + }); + + for (const [label, setup] of [["provider key", keySetup], ["models", modelSetup]] as const) { + it(`${label}: setup-only stays centered`, () => { + const document = documentFor(); + const row = document.querySelector('[data-testid="composer-actions"]')!; + assert.ok(row.classList.contains("justify-center")); + assert.equal(row.children.length, 1); + assert.equal(row.firstElementChild!.tagName, "BUTTON", "setup must not reserve a nested full-width row"); + }); + + it(`${label}: picker leads in one left-aligned wrapping row`, () => { + const document = documentFor(} setup={setup} />); + const row = document.querySelector('[data-testid="composer-actions"]')!; + const chip = document.querySelector('[data-testid="practice-props-chip"]')!; + assert.ok(row.classList.contains("justify-start")); + assert.ok(row.classList.contains("flex-wrap")); + assert.ok(row.classList.contains("pointer-events-auto")); + assert.equal(row.children.length, 2); + assert.equal(chip.parentElement, row.firstElementChild); + assert.equal(row.lastElementChild!.tagName, "BUTTON"); + assert.equal(chip.textContent, "Pick nodes"); + assert.equal(document.querySelector('[data-testid="practice-props-clear"]'), null); + }); + + it(`${label}: the practice draft uses that same row and order`, () => { + const document = documentFor( {}} onSend={() => {}} mode="practice" setupCta={setup} />); + const rows = document.querySelectorAll('[data-testid="composer-actions"]'); + assert.equal(rows.length, 1); + assert.ok(rows[0]!.classList.contains("justify-start")); + assert.ok(rows[0]!.firstElementChild!.querySelector('[data-testid="practice-props-chip"]')); + assert.equal(rows[0]!.lastElementChild!.tagName, "BUTTON"); + }); + } + + it("picker-only remains left-aligned", () => { + const document = documentFor(} />); + assert.ok(document.querySelector('[data-testid="composer-actions"]')!.classList.contains("justify-start")); + }); + + it("singular and plural node counts keep Clear in the same wrapping group", () => { + for (const count of [1, 2]) { + const document = documentFor(} setup={modelSetup} />); + const chip = document.querySelector('[data-testid="practice-props-chip"]')!; + const clear = document.querySelector('[data-testid="practice-props-clear"]')!; + assert.equal(chip.textContent, `${count} ${count === 1 ? "node" : "nodes"} selected`); + assert.equal(chip.parentElement, clear.parentElement); + assert.equal(chip.parentElement!.parentElement!.getAttribute("data-testid"), "composer-actions"); + assert.ok(!chip.parentElement!.classList.contains("flex-wrap"), "count and Clear never wrap apart"); + } + }); + + it("existing chat callers retain the standalone provider-key pill wrapper", () => { + const document = documentFor( {}} />); + const wrapper = document.body.firstElementChild!; + assert.equal(wrapper.tagName, "DIV"); + assert.ok(wrapper.classList.contains("justify-center")); + assert.ok(wrapper.classList.contains("mb-2")); + }); + + it("the practice shell preserves a selected node in teaching mode", () => { + const document = documentFor( {}} onSend={() => {}} mode="teach" picks={[{id:"n_1",name:"Parity",statement:"Odd + odd = even."}]} />); + assert.equal(document.querySelector('[data-testid="practice-props-chip"]')!.textContent, "1 node selected"); + assert.ok(document.querySelector('[data-testid="practice-props-clear"]')); + }); +}); diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index e77776d..db834bc 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -122,7 +122,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => { />, ); assert.match(html, /New practice session/); - assert.match(html, /Describe what to practice/); + assert.ok(html.includes("Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.")); assert.match(html, /What do you want to practice\?/); }); @@ -136,7 +136,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => { mode="practice" />, ); - assert.match(html, /Pick propositions from the map/); + assert.match(html, />Pick nodes<\/button>/); // picked: count + Clear, the home register verbatim const picked = renderToStaticMarkup( { picks={[{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]} />, ); - assert.match(picked, /1 proposition selected/); + assert.match(picked, /1 node selected/); assert.match(picked, /Clear/); // teach mode with nothing picked renders nothing (home's law) const teach = renderToStaticMarkup( {}} onSend={() => {}} mode="teach" />, ); - assert.ok(!teach.includes("Pick propositions from the map")); + assert.ok(!teach.includes('data-testid="practice-props-chip"')); }); it("startDraftSession carries the picks as graphContext (the home practice contract)", async () => {