Build image / build-and-push (push) Successful in 11s
The instant scroll runs on every step of a turn, so it now follows new content only while the view is at the end of the thread (within 80px). Scrolling up to read something stays put until the person comes back down or sends the next message, which returns them to the end. Co-Authored-By: Claude Fable 5 <[email protected]>
216 lines
9.7 KiB
TypeScript
216 lines
9.7 KiB
TypeScript
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)
|
|
const thread = useRef<HTMLDivElement>(null)
|
|
// Whether the view is pinned to the end of the thread. It follows new
|
|
// content only while it is; a person who scrolled up to read something is
|
|
// left there until they come back down or send the next message.
|
|
const stuck = useRef(true)
|
|
|
|
// 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.
|
|
// Instant, not smooth: Chrome left the thread at the top with
|
|
// `behavior: 'smooth'` — a smooth scrollIntoView into this nested scroller
|
|
// never moved it, so every new reply landed out of view below a long
|
|
// conversation (found live, 2026-08-23). The instant form scrolls. It runs
|
|
// on every step of a turn too, so it is gated on `stuck`: following the
|
|
// stream is right when the person is at the end, and a snap they didn't
|
|
// ask for when they had scrolled up.
|
|
useEffect(() => {
|
|
if (stuck.current) bottom.current?.scrollIntoView({ block: 'end' })
|
|
}, [history.data, pending])
|
|
const onThreadScroll = () => {
|
|
const el = thread.current
|
|
if (el) stuck.current = el.scrollHeight - el.clientHeight - el.scrollTop < 80
|
|
}
|
|
|
|
const send = () => {
|
|
const message = input.trim()
|
|
if (!message || pending) return
|
|
setInput('')
|
|
stuck.current = true // sending is a return to the end of the thread
|
|
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">
|
|
{/* The thread scrolls on its own so the composer stays put: with the whole
|
|
tab scrolling, a long conversation pushed the input off the bottom and
|
|
every new message scrolled it further away. */}
|
|
<div ref={thread} onScroll={onThreadScroll} className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto">
|
|
{!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 min-w-0 max-w-[90%] flex-col items-start gap-1 self-start">
|
|
{/* min-w-0 / max-w-full: a wide markdown table scrolls inside its own
|
|
wrapper instead of widening the bubble past the panel. */}
|
|
<div className={cn('min-w-0 max-w-full 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>
|
|
<div className="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>
|
|
)
|
|
}
|