ui: unify practice composer actions and node picker copy

This commit is contained in:
amosblomqvist
2026-10-17 01:06:28 +03:00
parent 487a63eaa5
commit 3d1ad38e46
6 changed files with 184 additions and 97 deletions
+56
View File
@@ -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 (
<div
data-testid="composer-actions"
className={`pointer-events-auto mb-2 flex flex-wrap items-center gap-2 ${picker ? "justify-start" : "justify-center"}`}
>
{picker}
{setup}
</div>
);
}
/** 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 (
<div className="flex items-center gap-1.5">
<button
type="button"
data-testid="practice-props-chip"
onClick={onOpen}
className={
"flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 font-ui text-xs transition-colors " +
(count > 0
? "border-border bg-card text-foreground hover:bg-accent"
: "border-dashed border-border/80 bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground")
}
>
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{count > 0 ? `${count} ${count === 1 ? "node" : "nodes"} selected` : "Pick nodes"}
</button>
{count > 0 && (
<button
type="button"
data-testid="practice-props-clear"
onClick={onClear}
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
)}
</div>
);
}
+16 -40
View File
@@ -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") && (
<div className="mb-2 flex items-center justify-start gap-1.5">
<button
type="button"
data-testid="practice-props-chip"
onClick={() => setChainSelectOpen(true)}
className={
"flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 font-ui text-xs transition-colors " +
(practiceProps.length > 0
? "border-border bg-card text-foreground hover:bg-accent"
: "border-dashed border-border/80 bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground")
}
>
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{practiceProps.length > 0
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
: rendering
? "Pick propositions from the map"
: "Pick propositions"}
</button>
{practiceProps.length > 0 && (
<button
type="button"
data-testid="practice-props-clear"
onClick={() => setPracticeProps([])}
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
)}
</div>
)}
{zeroKeys ? (
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
) : (
!configured && (
<div className="mb-2 flex justify-center">
<ComposerActions
picker={(practiceProps.length > 0 || mode === "practice") && (
<NodePickerControl
count={practiceProps.length}
onOpen={() => setChainSelectOpen(true)}
onClear={() => setPracticeProps([])}
/>
)}
setup={zeroKeys ? (
<ZeroKeysPill inline onOpen={() => setSetupOpen(true)} />
) : (
!configured && (
<button
type="button"
data-testid="setup-cta"
@@ -262,9 +238,9 @@ export function Home({
>
Set up your models
</button>
</div>
)
)}
)
)}
/>
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
+11 -41
View File
@@ -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({
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4">
<div className={DOCK}>
{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. */
<div className="pointer-events-auto mb-2 flex items-center justify-start gap-1.5">
<button
type="button"
data-testid="practice-props-chip"
onClick={onOpenPicker}
className={
"flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 font-ui text-xs transition-colors " +
(picks.length > 0
? "border-border bg-card text-foreground hover:bg-accent"
: "border-dashed border-border/80 bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground")
}
>
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{picks.length > 0
? `${picks.length} ${picks.length === 1 ? "proposition" : "propositions"} selected`
: "Pick propositions from the map"}
</button>
{picks.length > 0 && (
<button
type="button"
data-testid="practice-props-clear"
onClick={onClearPicks}
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
)}
</div>
)}
<ComposerActions
picker={(picks.length > 0 || mode === "practice") && (
<NodePickerControl count={picks.length} onOpen={onOpenPicker} onClear={onClearPicks} />
)}
setup={setupCta}
/>
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
@@ -376,10 +348,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
}
setupCta={
zeroKeys ? (
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
<ZeroKeysPill inline onOpen={() => setSetupOpen(true)} />
) : (
!configured && (
<div className="pointer-events-auto mb-2 flex justify-center">
<button
type="button"
data-testid="setup-cta"
@@ -388,7 +359,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
>
Set up your models
</button>
</div>
)
)
}
+11 -12
View File
@@ -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 (
<div className="pointer-events-auto mb-2 flex justify-center">
<button
type="button"
data-testid="setup-cta-keys"
onClick={onOpen}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Add a provider key
</button>
</div>
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
const button = (
<button
type="button"
data-testid="setup-cta-keys"
onClick={onOpen}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Add a provider key
</button>
);
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>;
}
/** 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