The safe, self-contained wins from the #85 bundle: - **Mobile viewport.** The editor used `100vh`, which on mobile Safari/Chrome is the LARGEST viewport (URL bar hidden), so with the bar visible the canvas bottom and Fit button were pushed under the browser chrome. `100dvh` fixes it. - **Session expiry mid-chat.** A 401 during a chat turn was mapped to "the assistant is not available right now" — sending the user to debug a config problem that isn't there. Now it says the session expired and redirects to /login, preserving the path, like the rest of the app treats 401. - **Error toasts persist.** Error toasts were the primary report that a mutation failed, yet auto-dismissed at 4s with no way to retrieve them — look away and it's gone. Errors now stay until dismissed; info toasts still time out; both get a close button. - **Journal date filter.** `from`/`to` existed in the API and JournalFilter but had no UI, so "show me last spring" was unreachable. Added two date inputs (with a Clear) that feed the existing filter. No backend change. Deferred to their own follow-ups (too big for this bundle, or need a decision): revision-history retention/pruning, the agent toolbox's missing corrective tools, plop-level journal entries from the UI, and the editor's max-width cap (a layout call worth confirming rather than changing blind). The DESIGN.md API-listing drift the issue mentioned was already fixed in #89. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
255 lines
8.8 KiB
TypeScript
255 lines
8.8 KiB
TypeScript
// The garden assistant's client (#57).
|
||
//
|
||
// The chat lives in the editor, not on its own page, because watching the canvas
|
||
// change as the agent works IS the confirmation — which is what makes acting
|
||
// without asking first tolerable. So this module's job is as much about
|
||
// surfacing progress as it is about sending a message.
|
||
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { z } from 'zod'
|
||
import { API_BASE, api } from './api'
|
||
import { gardenFullKey } from './objects'
|
||
import { historyKey } from './history'
|
||
|
||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||
|
||
export const capabilitiesKey = ['capabilities'] as const
|
||
|
||
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
||
* at all — a dead button is worse than no button.
|
||
*
|
||
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
||
* in Settings (#79), so this must be able to change under a running page. The
|
||
* settings save invalidates this key directly; the finite staleTime just means
|
||
* another admin's change is picked up on the next focus/remount rather than
|
||
* never. */
|
||
export function useCapabilities() {
|
||
return useQuery({
|
||
queryKey: capabilitiesKey,
|
||
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
||
staleTime: 60_000,
|
||
})
|
||
}
|
||
|
||
export const agentMessageSchema = z.object({
|
||
id: z.number(),
|
||
conversationId: z.number(),
|
||
role: z.enum(['user', 'assistant']),
|
||
body: z.string(),
|
||
changeSetId: z.number().optional(),
|
||
createdAt: z.string(),
|
||
})
|
||
export type AgentMessage = z.infer<typeof agentMessageSchema>
|
||
|
||
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
|
||
|
||
export function agentHistoryKey(gardenId: number) {
|
||
return ['gardens', gardenId, 'agent-history'] as const
|
||
}
|
||
|
||
/** The stored conversation, so a reload doesn't lose the thread — which is
|
||
* exactly when someone reloads, to check whether a change actually landed. */
|
||
export function useAgentHistory(gardenId: number, enabled: boolean) {
|
||
return useQuery({
|
||
queryKey: agentHistoryKey(gardenId),
|
||
enabled,
|
||
queryFn: async (): Promise<AgentMessage[]> =>
|
||
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
|
||
})
|
||
}
|
||
|
||
export function useClearAgentHistory(gardenId: number) {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (): Promise<void> => {
|
||
await api.delete(`/gardens/${gardenId}/agent/history`)
|
||
},
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
|
||
})
|
||
}
|
||
|
||
/** One completed turn, as the server reports it. */
|
||
export interface AgentTurn {
|
||
reply: string
|
||
changeSetId?: number
|
||
steps: number
|
||
truncated?: boolean
|
||
}
|
||
|
||
/** A step the model just finished, named in the app's own vocabulary. */
|
||
export interface AgentStep {
|
||
index: number
|
||
tools: string[]
|
||
}
|
||
|
||
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
|
||
// agent is busy; these tell you what it's busy DOING, which is the difference
|
||
// between the panel feeling alive and feeling hung.
|
||
const TOOL_LABELS: Record<string, string> = {
|
||
list_gardens: 'Looking at your gardens',
|
||
describe_garden: 'Reading the garden',
|
||
create_object: 'Adding a bed',
|
||
move_object: 'Moving a bed',
|
||
place_planting: 'Planting',
|
||
fill_region: 'Filling a bed',
|
||
clear_object: 'Clearing a bed',
|
||
find_plant: 'Looking up a plant',
|
||
create_plant: 'Adding a plant to your catalog',
|
||
add_journal_entry: 'Writing a journal note',
|
||
}
|
||
|
||
export function describeStep(step: AgentStep): string {
|
||
if (step.tools.length === 0) return 'Thinking'
|
||
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
|
||
// Repeated tools in one step read as one action, not a list of identical ones.
|
||
return [...new Set(labels)].join(', ')
|
||
}
|
||
|
||
const chatEventSchema = z.object({
|
||
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
|
||
done: z
|
||
.object({
|
||
reply: z.string(),
|
||
changeSetId: z.number().optional(),
|
||
steps: z.number(),
|
||
truncated: z.boolean().optional(),
|
||
})
|
||
.optional(),
|
||
error: z.string().optional(),
|
||
// The turn worked but something adjacent to it didn't — currently, the
|
||
// exchange couldn't be saved. Dropping this on the floor would recreate
|
||
// exactly the silent swallow the server added it to avoid.
|
||
warning: z.string().optional(),
|
||
})
|
||
|
||
export interface StreamHandlers {
|
||
onStep: (step: AgentStep) => void
|
||
onDone: (turn: AgentTurn) => void
|
||
onError: (message: string) => void
|
||
/** The turn succeeded, but something alongside it didn't. */
|
||
onWarning?: (message: string) => void
|
||
}
|
||
|
||
/**
|
||
* Send a message and stream the reply.
|
||
*
|
||
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
|
||
* POST body. The wire format is still SSE so a proxy that understands it doesn't
|
||
* buffer, and so switching to EventSource later wouldn't change the server.
|
||
*/
|
||
export async function streamChat(
|
||
gardenId: number,
|
||
message: string,
|
||
handlers: StreamHandlers,
|
||
signal?: AbortSignal,
|
||
): Promise<void> {
|
||
let res: Response
|
||
try {
|
||
res = await fetch(`${API_BASE}/agent/chat`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ gardenId, message }),
|
||
credentials: 'same-origin',
|
||
signal,
|
||
})
|
||
} catch {
|
||
// An abort here is the caller's own doing — Stop, or navigating away — not a
|
||
// failure to report back to them. The read loop below already knew this; the
|
||
// request path did not.
|
||
if (signal?.aborted) return
|
||
handlers.onError('Could not reach the server.')
|
||
return
|
||
}
|
||
if (res.status === 401) {
|
||
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||
// unavailable" would send the user chasing a config problem that isn't there.
|
||
// Send them to sign in again, preserving where they were.
|
||
handlers.onError('Your session has expired — please sign in again.')
|
||
const back = encodeURIComponent(location.pathname + location.search)
|
||
window.location.assign(`/login?redirect=${back}`)
|
||
return
|
||
}
|
||
if (!res.ok || !res.body) {
|
||
// 503 is the assistant being turned off at runtime (#79) — the route exists,
|
||
// there's just no model behind it. Distinct from a 404, which would mean the
|
||
// whole endpoint is absent.
|
||
handlers.onError(
|
||
res.status === 503
|
||
? "The assistant isn't enabled on this instance."
|
||
: res.status === 404
|
||
? "This instance doesn't have the assistant configured."
|
||
: 'The assistant is not available right now.',
|
||
)
|
||
return
|
||
}
|
||
|
||
const reader = res.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
for (;;) {
|
||
let chunk: ReadableStreamReadResult<Uint8Array>
|
||
try {
|
||
chunk = await reader.read()
|
||
} catch {
|
||
// An aborted read is the caller navigating away, not a failure worth
|
||
// reporting back to them.
|
||
if (signal?.aborted) return
|
||
handlers.onError('The connection dropped partway through.')
|
||
return
|
||
}
|
||
if (chunk.done) break
|
||
buffer += decoder.decode(chunk.value, { stream: true })
|
||
|
||
// SSE frames are separated by a blank line; anything after the last one is
|
||
// a partial frame to keep for the next chunk.
|
||
const frames = buffer.split('\n\n')
|
||
buffer = frames.pop() ?? ''
|
||
for (const frame of frames) {
|
||
const line = frame.split('\n').find((l) => l.startsWith('data:'))
|
||
if (!line) continue
|
||
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
|
||
// working stream, and shouldn't be trusted into the UI either.
|
||
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
|
||
if (!parsed.success) continue
|
||
const e = parsed.data
|
||
if (e.warning) handlers.onWarning?.(e.warning)
|
||
if (e.error) handlers.onError(e.error)
|
||
else if (e.step) handlers.onStep(e.step)
|
||
else if (e.done) handlers.onDone(e.done)
|
||
}
|
||
}
|
||
}
|
||
|
||
function safeJson(raw: string): unknown {
|
||
try {
|
||
return JSON.parse(raw)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refreshes for the two moments that need different amounts of work.
|
||
*
|
||
* Mid-turn, only the canvas can have changed: the change set isn't written until
|
||
* the turn commits, and the exchange isn't stored until it finishes. Refetching
|
||
* those on every step would be up to 2×(N−1) requests per turn for data that
|
||
* cannot have moved.
|
||
*/
|
||
export function useAgentRefresh(gardenId: number) {
|
||
const qc = useQueryClient()
|
||
const canvas = () => {
|
||
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
|
||
}
|
||
return {
|
||
/** After a step: the garden may have changed under the conversation. */
|
||
canvas,
|
||
/** After a turn: the change set and the stored exchange exist now too. */
|
||
everything: () => {
|
||
canvas()
|
||
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
||
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
|
||
},
|
||
}
|
||
}
|