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
79 lines
2.5 KiB
TypeScript
79 lines
2.5 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { create } from 'zustand'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
type ToastTone = 'info' | 'error'
|
|
interface Toast {
|
|
id: number
|
|
message: string
|
|
tone: ToastTone
|
|
}
|
|
|
|
interface ToastState {
|
|
toasts: Toast[]
|
|
push: (message: string, tone?: ToastTone) => void
|
|
dismiss: (id: number) => void
|
|
}
|
|
|
|
let nextId = 1
|
|
|
|
export const useToastStore = create<ToastState>((set) => ({
|
|
toasts: [],
|
|
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
|
}))
|
|
|
|
/** Fire a toast from anywhere (including non-component code). */
|
|
export const toast = {
|
|
info: (m: string) => useToastStore.getState().push(m, 'info'),
|
|
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
|
}
|
|
|
|
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
|
function ToastItem({ item }: { item: Toast }) {
|
|
const dismiss = useToastStore((s) => s.dismiss)
|
|
const isError = item.tone === 'error'
|
|
useEffect(() => {
|
|
// Error toasts are the primary report that a mutation failed, so they do NOT
|
|
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
|
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
|
if (isError) return
|
|
const t = setTimeout(() => dismiss(item.id), 4000)
|
|
return () => clearTimeout(t)
|
|
}, [item.id, dismiss, isError])
|
|
return (
|
|
<div
|
|
role={isError ? 'alert' : 'status'}
|
|
className={cn(
|
|
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
|
isError
|
|
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
|
: 'border-border bg-surface text-fg',
|
|
)}
|
|
>
|
|
<span className="flex-1">{item.message}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => dismiss(item.id)}
|
|
aria-label="Dismiss"
|
|
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Fixed stack of active toasts. Mount once near the app root. */
|
|
export function Toaster() {
|
|
const toasts = useToastStore((s) => s.toasts)
|
|
if (toasts.length === 0) return null
|
|
return (
|
|
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
|
{toasts.map((t) => (
|
|
<ToastItem key={t.id} item={t} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|