Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent'
|
||||
import type { useUndo } from '@/lib/history'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
|
||||
// Lazy so the markdown renderer loads only when an assistant message renders.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/** Falls back to the raw text if the markdown chunk can't load or throws. */
|
||||
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk to the garden assistant beside the canvas — watching the plan change as
|
||||
* it works IS the confirmation. Every turn lands as one undoable step, so each
|
||||
* assistant reply that changed something carries its own Undo.
|
||||
*/
|
||||
export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType<typeof useUndo>; large?: boolean }) {
|
||||
const history = useAgentHistory(gardenId, true)
|
||||
const clear = useClearAgentHistory(gardenId)
|
||||
const refresh = useAgentRefresh(gardenId)
|
||||
const [input, setInput] = useState('')
|
||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [warning, setWarning] = useState<string | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
const bottom = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deliberately NOT aborted on unmount: selecting a bed switches the rail to
|
||||
// the inspector, and that must not kill a turn mid-flight. The request runs
|
||||
// on; the exchange is persisted server-side; coming back shows it.
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [history.data, pending])
|
||||
|
||||
const send = () => {
|
||||
const message = input.trim()
|
||||
if (!message || pending) return
|
||||
setInput('')
|
||||
setError(null)
|
||||
setWarning(null)
|
||||
setPending({ message, steps: [] })
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
void streamChat(
|
||||
gardenId,
|
||||
message,
|
||||
{
|
||||
onStep: (step) => {
|
||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||
refresh.canvas()
|
||||
},
|
||||
onDone: (turn: AgentTurn) => {
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
if (turn.truncated) setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||
},
|
||||
onWarning: setWarning,
|
||||
onError: (m) => {
|
||||
setPending(null)
|
||||
setError(m)
|
||||
refresh.everything()
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
abort.current?.abort()
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
}
|
||||
|
||||
const messages = history.data ?? []
|
||||
const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
|
||||
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
|
||||
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
|
||||
{history.isSuccess && messages.length === 0 && !pending && (
|
||||
<p className="text-[13px] leading-relaxed text-ink-mute">
|
||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the
|
||||
west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m) =>
|
||||
m.role === 'user' ? (
|
||||
<div key={m.id} className={cn('self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText, 'max-w-[85%]')}>
|
||||
{m.body}
|
||||
</div>
|
||||
) : (
|
||||
<div key={m.id} className="flex max-w-[90%] flex-col items-start gap-1 self-start">
|
||||
<div className={cn('rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||
<MarkdownMessage>{m.body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
</div>
|
||||
{m.changeSetId != null && canEdit && <TurnUndo changeSetId={m.changeSetId} undo={undo} />}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{pending && (
|
||||
<>
|
||||
<div className={cn('max-w-[85%] self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText)}>
|
||||
{pending.message}
|
||||
</div>
|
||||
<div className={cn('max-w-[90%] self-start rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||
{pending.steps.map((s) => (
|
||||
<div key={s.index} className="text-xs text-ink-mute">
|
||||
{describeStep(s)}…
|
||||
</div>
|
||||
))}
|
||||
<p className="flex items-center gap-1.5 text-xs text-ink-mute">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{warning && <Alert tone="info">{warning}</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div ref={bottom} />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
|
||||
{messages.length > 0 && !pending && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost self-end px-2 py-0.5 text-[11.5px]"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate(undefined, { onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")) })}
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className={cn('input', large && 'input-lg')}
|
||||
placeholder="Ask about your garden…"
|
||||
aria-label="Message the assistant"
|
||||
value={input}
|
||||
disabled={!!pending}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pending ? (
|
||||
<IconButton label="Stop" icon="square" iconSize={large ? 16 : 15} size={large ? 44 : 36} onClick={stop} />
|
||||
) : (
|
||||
<IconButton label="Send" icon="send" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={!input.trim()} onClick={send} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Undo on the turn itself, so the common case never involves the History tab. */
|
||||
function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType<typeof useUndo> }) {
|
||||
const outcome = undo.outcomeFor(changeSetId)
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5 pl-1">
|
||||
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" disabled={outcome?.tone === 'pending'} onClick={() => undo.undo({ id: changeSetId })}>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : 'Undo this'}
|
||||
</button>
|
||||
{outcome && outcome.tone !== 'pending' && (
|
||||
<p role="status" className={cn('text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user